From 2e1f3b0a4161cd8f4b083e6274d60c5b32131d5c Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:25:24 +0200 Subject: [PATCH 0001/1452] Content-Aware tools: Photoshop's behaviour, measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop 25.4 was measured as a black box (synthetic and public-domain images, results read back exactly) and the content-aware family follows it: - Fill (inpaint): coarse to fine with the coarser level's matches scaled up and voted with the finer level's own pixels, the coarsest level filled from the hole's edge inwards, a shallower pyramid. The fill is pieces of the image copied pixel for pixel with narrow seams, as Photoshop's; before, it was visibly blurred. Pixels have roles (hole, context, source, void), so excluded areas stay context instead of being filled. - Colour adaptation: Image Melding's per-patch gain and bias, limited per level, instead of a low-frequency membrane pull. - Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50)) centred on the selection, slid inside the canvas; Edit › Fill, Rectangular, Auto, Delete and Fill and Spot Healing use it. - Auto sampling area: what looks like the selection's surroundings (local colour and contrast, one min cut), fitted to Photoshop's overlay. - Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers). - Content-Aware Move: Structure 3..7 alike, a band about a patch wide; Color as a gain/bias limited to about 0.04 per step. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/algo/src/content_aware.rs | 477 ++++++++++++++---- crates/algo/src/inpaint.rs | 474 ++++++++++++++--- crates/engine/src/edit_menu_cmds.rs | 23 +- crates/engine/src/edit_menu_cmds/tests.rs | 16 + crates/engine/src/fill_cmds.rs | 14 +- crates/engine/src/retouch_cmds.rs | 13 +- .../src/retouch_cmds/content_aware_move.rs | 201 +++++--- crates/engine/src/retouch_cmds/patch.rs | 71 +++ crates/engine/src/retouch_cmds/tests.rs | 80 ++- crates/ui-egui/src/patch_preview.rs | 4 + crates/ui-egui/src/retouch_ui.rs | 60 ++- crates/ui-egui/src/state.rs | 7 + 12 files changed, 1154 insertions(+), 286 deletions(-) diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 95e03b65f3..a33d5059a4 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -1,30 +1,43 @@ -//! Edit › Content-Aware Fill: patch-based completion ([`crate::inpaint::complete`]) with a -//! restricted sampling area, geometric adaptation and colour adaptation. +//! Edit › Content-Aware Fill: patch-based completion ([`crate::inpaint::complete_masked`]) with +//! Photoshop's sampling area, colour adaptation and geometric adaptation. //! -//! * **Sampling area**: pixels outside the allowed `source` mask may not be copied from. The -//! completion only copies fully-known patches, so excluded pixels are handed to it as -//! unknown and restored afterwards; only the real hole keeps the synthesised values. +//! Photoshop 25.4 was measured as a black box (synthetic and public-domain images, results read +//! back exactly; none of its code or assets were used): +//! +//! * **Sampling window** ([`sampling_window`]): the fill copies only from a square centred on the +//! selection's bounding box, of side `4·√(max(w, 50)·max(h, 50))`: four times a square +//! selection's side, 200 px at least, and for a long thin selection four times the geometric +//! mean of its sides. At the canvas edge the square slides inwards rather than being cut. Pixels +//! outside it never change the result (Edit › Fill and the workspace's Rectangular area alike). +//! * **Auto sampling area** ([`auto_sampling`]): the workspace's default keeps, within that +//! square, only what looks like the selection's surroundings: a region of another brightness or +//! another texture is left out along its edges, wherever it lies (a bright band through the +//! window is excluded, the matching area beyond it is not). +//! * **The fill** copies pieces of the sampling area pixel for pixel (15–40 px across), joined by +//! seams a few pixels wide: see [`crate::inpaint`]. +//! * **Colour adaptation** None / Default / High / Very High: with None the copies are exact; the +//! others let each copied patch take a gain and a bias towards its surroundings, more at each +//! level ([`color_level`]). The patch transform is that of S. Darabi et al., *Image Melding*, +//! SIGGRAPH 2012. //! * **Rotation / scale / mirror adaptation**: the sampling window is extended with rotated, -//! rescaled and mirrored copies of itself, laid out side by side (separated by unknown gaps -//! wider than a patch), so the patch search can pick transformed source patches, as in -//! the generalised PatchMatch of C. Barnes, E. Shechtman, D. B. Goldman, A. Finkelstein, -//! *The Generalized PatchMatch Correspondence Algorithm*, ECCV 2010 (which searches over -//! rotations and scales; we enumerate a small set instead). -//! * **Colour adaptation**: the low frequencies of the fill are pulled towards the smooth -//! membrane interpolation of the hole boundary (P. Pérez, M. Gangnet, A. Blake, *Poisson -//! Image Editing*, SIGGRAPH 2003 uses the same membrane for seamless cloning), which corrects -//! gradual brightness and colour changes across the hole while keeping the copied texture. +//! rescaled and mirrored copies of itself, laid out side by side (separated by gaps that are no +//! image at all), so the patch search can pick transformed source patches, as in the +//! generalised PatchMatch of C. Barnes, E. Shechtman, D. B. Goldman, A. Finkelstein, *The +//! Generalized PatchMatch Correspondence Algorithm*, ECCV 2010 (which searches over rotations +//! and scales; we enumerate a small set instead). //! //! Buffers are interleaved `w × h × ch` normalised floats (any model/depth); deterministic. -use crate::inpaint::{CompleteParams, complete_with}; +use photocraft_geom::Rect; + +use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked}; use crate::poisson::membrane_fill_with; /// Options for [`fill`]. #[derive(Clone, Debug, PartialEq)] pub struct FillOptions { - /// Colour adaptation strength `0..=1` (Photoshop: none 0, default ≈0.35, high, very high). - pub color_adaptation: f32, + /// Colour adaptation of the copies ([`color_level`]). + pub color_adaptation: Adapt, /// Extra source rotations in degrees (Photoshop's rotation adaptation levels). pub rotations: Vec, /// Add downscaled and upscaled source copies. @@ -36,7 +49,7 @@ pub struct FillOptions { impl Default for FillOptions { fn default() -> Self { - Self { color_adaptation: 0.35, rotations: Vec::new(), scale: false, mirror: false, seed: 1 } + Self { color_adaptation: color_level("default"), rotations: Vec::new(), scale: false, mirror: false, seed: 1 } } } @@ -51,16 +64,43 @@ pub fn rotation_level(level: &str) -> Vec { } } -/// Colour adaptation strength for Photoshop's levels: none, default, high, veryHigh. -pub fn color_level(level: &str) -> f32 { +/// Colour adaptation for Photoshop's levels: none, default, high, veryHigh. Measured on a texture +/// under a brightness ramp: with None the copies are exact; Default shifts them by up to about +/// 10/255, High about 20, Very High about 35, scaling their contrast a little more at each level. +pub fn color_level(level: &str) -> Adapt { match level { - "none" => 0.0, - "high" => 0.65, - "veryHigh" => 0.9, - _ => 0.35, + "none" => Adapt::NONE, + "high" => Adapt { gain: (0.8, 1.25), bias: 0.08 }, + "veryHigh" => Adapt { gain: (0.67, 1.5), bias: 0.14 }, + _ => Adapt { gain: (0.9, 1.1), bias: 0.04 }, } } +/// Photoshop's sampling window for a selection with bounding box `hole`: the square of side +/// `4·√(max(w, 50)·max(h, 50))` centred on it, slid inside `canvas` (and clipped to it only where +/// the canvas is smaller than the square). A selection longer than that square (more than 16 +/// times as long as it is wide, which was not measured) keeps its whole length. +pub fn sampling_window(hole: Rect, canvas: Rect) -> Rect { + if hole.is_empty() || canvas.is_empty() { + return Rect::EMPTY; + } + let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50))); + let side = (4.0 * (w * h).sqrt()).round().min(i32::MAX as f64) as i64; + // Twice the centre, so that odd sizes stay exact. + let span = |c2: i64, extent: u32, lo: i32, hi: i32| -> (i32, i32) { + let (lo, hi) = (i64::from(lo), i64::from(hi)); + let side = side.max(i64::from(extent)); + if side >= hi - lo { + return (lo as i32, hi as i32); + } + let a = ((c2 - side) / 2).clamp(lo, hi - side); + (a as i32, (a + side) as i32) + }; + let (x0, x1) = span(i64::from(hole.x0) + i64::from(hole.x1), hole.width(), canvas.x0, canvas.x1); + let (y0, y1) = span(i64::from(hole.y0) + i64::from(hole.y1), hole.height(), canvas.y0, canvas.y1); + Rect::new(x0, y0, x1, y1) +} + /// One source variant: pixels and which of them are usable. struct Variant { w: usize, @@ -132,31 +172,6 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant { out } -/// Separable box blur of radius `r` (edge-clamped). -fn box_blur(w: usize, h: usize, ch: usize, v: &[f32], r: usize) -> Vec { - if r == 0 { - return v.to_vec(); - } - let pass = |src: &[f32], horizontal: bool| -> Vec { - let mut dst = vec![0.0f32; src.len()]; - let (n, m) = if horizontal { (h, w) } else { (w, h) }; - let idx = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line }; - let norm = 1.0 / (2 * r + 1) as f32; - for line in 0..n { - for c in 0..ch { - let at = |k: i64| src[idx(line, k.clamp(0, m as i64 - 1) as usize) * ch + c]; - let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); - for k in 0..m { - dst[idx(line, k) * ch + c] = acc * norm; - acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); - } - } - } - dst - }; - pass(&pass(v, true), false) -} - /// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole). /// Returns the full buffer (unchanged outside the hole). Falls back to the membrane fill when /// nothing can be sampled. @@ -183,7 +198,7 @@ pub fn fill_with( if !hole.iter().any(|h| *h) { return Ok(img.to_vec()); } - let params = CompleteParams { seed: opts.seed, ..Default::default() }; + let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() }; let gap = 2 * params.patch_radius + 2; let ok: Vec = hole.iter().zip(source).map(|(h, s)| !h && *s).collect(); let src = Src { w, h, ch, img, ok: &ok }; @@ -199,18 +214,24 @@ pub fn fill_with( extra.push(variant(src, 0.0, 0.8, false)); extra.push(variant(src, 0.0, 1.25, false)); } - // Mosaic: identity at the left, variants in a column to its right. + // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); let mw = if extra.is_empty() { w } else { w + gap + col_w }; let mh = h.max(col_h); let mut mimg = vec![0.0f32; mw * mh * ch]; - let mut unknown = vec![true; mw * mh]; + let mut roles = vec![Px::Void; mw * mh]; for y in 0..h { for x in 0..w { let (s, d) = (y * w + x, y * mw + x); mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]); - unknown[d] = !ok[s]; + roles[d] = if hole[s] { + Px::Hole + } else if ok[s] { + Px::Source + } else { + Px::Known + }; } } let mut oy = 0; @@ -219,16 +240,18 @@ pub fn fill_with( for x in 0..v.w { let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x); mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]); - unknown[d] = !v.ok[s]; + if v.ok[s] { + roles[d] = Px::Source; + } } } oy += v.h + gap; } ctl.check()?; - // The completion is nearly all the work: it reports 0–90 %. + // The completion is nearly all the work. let cancel = || ctl.cancelled(); - let progress = |f: f32| ctl.progress(f * 0.9); - let filled = complete_with(mw, mh, ch, &mimg, &unknown, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?; + let progress = |f: f32| ctl.progress(f * 0.99); + let filled = complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?; let mut out = img.to_vec(); match filled { Some(f) => { @@ -244,47 +267,203 @@ pub fn fill_with( None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?, } ctl.check()?; - if opts.color_adaptation > 0.0 { - adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation, ctl)?; - } ctl.progress(1.0); Ok(out) } -/// Pull the fill's low frequencies towards the membrane interpolation of the hole boundary. -#[allow(clippy::too_many_arguments)] -fn adapt_colors( - w: usize, - h: usize, - ch: usize, - img: &[f32], - hole: &[bool], - out: &mut [f32], - k: f32, - ctl: &photocraft_raster::Interrupt, -) -> Result<(), photocraft_raster::Cancelled> { - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); +/// Box mean of radius `r` over a `w × h` plane (edge-clamped), by summed areas. +fn box_mean(w: usize, h: usize, v: &[f32], r: usize) -> Vec { + let w1 = w + 1; + let mut s = vec![0.0f64; w1 * (h + 1)]; + for y in 0..h { + let mut row = 0.0f64; + for x in 0..w { + row += f64::from(v[y * w + x]); + s[(y + 1) * w1 + x + 1] = s[y * w1 + x + 1] + row; + } + } + let mut out = vec![0.0f32; w * h]; + for y in 0..h { + let (y0, y1) = (y.saturating_sub(r), (y + r + 1).min(h)); + for x in 0..w { + let (x0, x1) = (x.saturating_sub(r), (x + r + 1).min(w)); + let sum = s[y1 * w1 + x1] + s[y0 * w1 + x0] - s[y0 * w1 + x1] - s[y1 * w1 + x0]; + out[y * w + x] = (sum / ((x1 - x0) * (y1 - y0)) as f64) as f32; + } + } + out +} + +/// Largest side [`auto_sampling`] works at: bigger windows are segmented at a reduced scale. +const AUTO_SIDE: usize = 384; + +/// Content-Aware Fill's Auto sampling area within a window (the whole `w × h` buffer): the pixels +/// that look like the hole's surroundings. Each pixel is described by its local mean colour and +/// local contrast (7 × 7); the ring around the hole is summarised by a few clusters of those; a +/// pixel near a cluster prefers to be sampled, one far from all of them not, and one min cut +/// (contrast-sensitive, so the boundary follows edges) settles the area. Returns the mask of +/// pixels that may be copied from (never the hole). Falls back to everything outside the hole +/// when the area would be too small to copy from. +pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec { + use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with}; + assert_eq!(img.len(), w * h * ch); + assert_eq!(hole.len(), w * h); + let everything: Vec = hole.iter().map(|h| !h).collect(); + if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) { + return everything; + } + // Work at a reduced scale for big windows (the area is smooth at this scale). + let k = w.max(h).div_ceil(AUTO_SIDE).max(1); + let (sw, sh) = (w.div_ceil(k), h.div_ceil(k)); + let nc = ch.min(3); + let mut planes = vec![vec![0.0f32; sw * sh]; nc]; + let mut shole = vec![false; sw * sh]; + let mut cnt = vec![0.0f32; sw * sh]; for y in 0..h { for x in 0..w { - if hole[y * w + x] { - (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + let i = (y / k) * sw + x / k; + let p = y * w + x; + if hole[p] { + shole[i] = true; + } + for (c, pl) in planes.iter_mut().enumerate() { + pl[i] += img[p * ch + c]; + } + cnt[i] += 1.0; + } + } + for pl in &mut planes { + pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0)); + } + // Features: local means and local contrast (of the luma-ish average of the colour planes). + let r = 3; + let means: Vec> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect(); + let luma: Vec = (0..sw * sh).map(|i| planes.iter().map(|p| p[i]).sum::() / nc as f32).collect(); + let luma2: Vec = luma.iter().map(|v| v * v).collect(); + let (lm, lm2) = (box_mean(sw, sh, &luma, r), box_mean(sw, sh, &luma2, r)); + let contrast: Vec = lm.iter().zip(&lm2).map(|(m, m2)| (m2 - m * m).max(0.0).sqrt()).collect(); + let nf = nc + 1; + let feat = |i: usize, out: &mut [f32]| { + for (c, m) in means.iter().enumerate() { + out[c] = m[i]; + } + out[nc] = 2.0 * contrast[i]; + }; + // The ring: outside the hole, within a few pixels of it. + let dist = crate::selection::edt(&shole, sw, sh); + let ring_w = 6.0f32 / k as f32 + 1.0; + let ring: Vec = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect(); + if ring.is_empty() { + return everything; + } + // A few cluster centres of the ring's features (k-means, deterministic start). + let kc = 6.min(ring.len()); + let mut f = vec![0.0f32; nf]; + let mut centres: Vec> = (0..kc) + .map(|j| { + feat(ring[j * ring.len() / kc], &mut f); + f.clone() + }) + .collect(); + let d2 = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::(); + for _ in 0..8 { + let mut acc = vec![vec![0.0f32; nf]; kc]; + let mut n = vec![0usize; kc]; + for &i in &ring { + feat(i, &mut f); + let j = (0..kc).min_by(|&a, &b| d2(&f, ¢res[a]).total_cmp(&d2(&f, ¢res[b]))).unwrap_or(0); + acc[j].iter_mut().zip(&f).for_each(|(a, v)| *a += v); + n[j] += 1; + } + for j in 0..kc { + if n[j] > 0 { + centres[j] = acc[j].iter().map(|v| v / n[j] as f32).collect(); } } } - let r = ((x1 - x0).max(y1 - y0) / 6).clamp(1, 32); - let membrane = membrane_fill_with(w, h, ch, img, hole, ctl)?; - ctl.progress(0.97); - ctl.check()?; - let low = box_blur(w, h, ch, out, r); - for (i, &hl) in hole.iter().enumerate() { - if hl { - for c in 0..ch { - let j = i * ch + c; - out[j] = (out[j] + k * (membrane[j] - low[j])).clamp(0.0, 1.0); - } + let nearest = |i: usize, f: &mut [f32]| -> f32 { + feat(i, f); + centres.iter().map(|c| d2(f, c)).fold(f32::INFINITY, f32::min).sqrt() + }; + // How much the ring itself varies around its clusters sets the scale of "similar". + let mut spread: Vec = ring.iter().map(|&i| nearest(i, &mut f)).collect(); + let q = (spread.len() * 9 / 10).min(spread.len() - 1); + let (_, s90, _) = spread.select_nth_unstable_by(q, |a, b| a.total_cmp(b)); + let tau = (*s90 * 1.6).max(0.03); + let mut cost_fg = vec![0.0f32; sw * sh]; + let mut cost_bg = vec![0.0f32; sw * sh]; + let mut fixed = vec![FREE; sw * sh]; + for i in 0..sw * sh { + if shole[i] || dist[i] <= 1.5 { + fixed[i] = HARD_FG; + continue; + } + let d = nearest(i, &mut f) / tau; + cost_fg[i] = d * d; + cost_bg[i] = 1.0; + } + let rgb = RgbImage::from_fn(sw, sh, |x, y| { + let i = y * sw + x; + let c = |j: usize| means[j.min(nc - 1)][i]; + [c(0), c(1), c(2)] + }); + // Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of + // "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten + // synthetic and public-domain cases). + let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05); + let mut out = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + out[i] = !hole[i] && area[(y / k) * sw + x / k]; } } - Ok(()) + // Too little to copy from (fewer source pixels than twice the hole): sample everything. + let n_hole = hole.iter().filter(|h| **h).count(); + if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) { + return everything; + } + out +} + +/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation +/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0 +/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a +/// fraction), 6 about 80 % of 42 levels, 10 most of the way (35 of 42, 95 of 102). So the shift is +/// limited to about `0.04 · color`, the contrast change likewise widening. +pub fn color_amount(color: u8) -> Adapt { + if color == 0 { + return Adapt::NONE; + } + let c = f32::from(color.min(10)); + Adapt { gain: (1.0 - 0.033 * c, 1.0 + 0.05 * c), bias: 0.04 * c } +} + +/// Gain and bias per channel taking the samples `from` to the level and contrast of the samples +/// `to` (both interleaved, `ch` per sample), within `a`: how copied content is fitted to its new +/// surroundings. +pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f32, f32)> { + let moments = |v: &[f32], c: usize| { + let (mut s, mut s2, mut n) = (0.0f64, 0.0f64, 0.0f64); + for x in v.chunks_exact(ch.max(1)) { + s += f64::from(x[c]); + s2 += f64::from(x[c]) * f64::from(x[c]); + n += 1.0; + } + (s, s2, n) + }; + (0..ch) + .map(|c| { + let (fs, fs2, fnn) = moments(from, c); + let (ts, ts2, tn) = moments(to, c); + if a.is_none() || fnn == 0.0 || tn == 0.0 { + return (1.0, 0.0); + } + // Both sets scaled to the same count for the fit. + let k = fnn / tn; + crate::inpaint::fit(a, fnn as f32, (ts * k) as f32, (ts2 * k) as f32, fs as f32, fs2 as f32) + }) + .collect() } #[cfg(test)] @@ -323,7 +502,7 @@ mod tests { let (w, h) = (48, 32); let (img, hole) = stripes(w, h); let src = vec![true; w * h]; - let out = fill(w, h, 1, &img, &hole, &src, &FillOptions { color_adaptation: 0.0, ..Default::default() }); + let out = fill(w, h, 1, &img, &hole, &src, &FillOptions { color_adaptation: Adapt::NONE, ..Default::default() }); for i in 0..w * h { if !hole[i] { assert_eq!(out[i], img[i]); @@ -341,7 +520,7 @@ mod tests { let img: Vec = (0..w * h).map(|i| if i % w < 20 { 0.1 } else { 0.9 }).collect(); let hole: Vec = (0..w * h).map(|i| (16..24).contains(&(i % w)) && (6..14).contains(&(i / w))).collect(); let right: Vec = (0..w * h).map(|i| i % w >= 24).collect(); - let out = fill(w, h, 1, &img, &hole, &right, &FillOptions { color_adaptation: 0.0, ..Default::default() }); + let out = fill(w, h, 1, &img, &hole, &right, &FillOptions { color_adaptation: Adapt::NONE, ..Default::default() }); let mean: f32 = (0..w * h).filter(|i| hole[*i]).map(|i| out[i]).sum::() / 64.0; assert!(mean > 0.8, "mean {mean}"); } @@ -352,7 +531,7 @@ mod tests { let (img, hole) = stripes(w, h); let rgb: Vec = img.iter().flat_map(|v| [*v, *v * 0.5, 1.0 - *v]).collect(); let src = vec![true; w * h]; - let opts = FillOptions { color_adaptation: 0.65, rotations: rotation_level("low"), scale: true, mirror: true, seed: 7 }; + let opts = FillOptions { color_adaptation: color_level("high"), rotations: rotation_level("low"), scale: true, mirror: true, seed: 7 }; let a = fill(w, h, 3, &rgb, &hole, &src, &opts); let b = fill(w, h, 3, &rgb, &hole, &src, &opts); assert_eq!(a, b); @@ -366,11 +545,127 @@ mod tests { let img: Vec = (0..w * h).map(|i| (i % w) as f32 / w as f32).collect(); let hole: Vec = (0..w * h).map(|i| (14..26).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); let src = vec![true; w * h]; - let out = fill(w, h, 1, &img, &hole, &src, &FillOptions { color_adaptation: 0.9, ..Default::default() }); + let out = fill(w, h, 1, &img, &hole, &src, &FillOptions { color_adaptation: color_level("veryHigh"), ..Default::default() }); let err: f32 = (0..w * h).filter(|i| hole[*i]).map(|i| (out[i] - img[i]).abs()).sum::() / 120.0; assert!(err < 0.12, "mean error {err}"); } + /// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied. + fn noise(w: usize, h: usize, seed: u64) -> Vec { + let mut z = seed; + (0..w * h * 3) + .map(|_| { + z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + 0.2 + 0.6 * ((z >> 40) as f32 / (1u64 << 24) as f32) + }) + .collect() + } + + #[test] + fn sampling_window_is_photoshops() { + let canvas = Rect::new(0, 0, 800, 800); + let sq = |x0: i32, y0: i32, w: i32, h: i32| Rect::new(x0, y0, x0 + w, y0 + h); + // Photoshop 25.4's sampling overlay: a square of side 4·√(max(w,50)·max(h,50)) centred on + // the selection's bounds. + assert_eq!(sampling_window(sq(375, 375, 50, 50), canvas), sq(300, 300, 200, 200)); + assert_eq!(sampling_window(sq(350, 350, 100, 100), canvas), sq(200, 200, 400, 400)); + assert_eq!(sampling_window(sq(395, 395, 10, 10), canvas), sq(300, 300, 200, 200)); + assert_eq!(sampling_window(sq(370, 370, 60, 60), canvas), sq(280, 280, 240, 240)); + // Long selections (Photoshop's overlay, read off the screen to a pixel: 281, 399, 691). + assert_eq!(sampling_window(sq(350, 385, 100, 30), canvas), sq(258, 258, 283, 283)); + assert_eq!(sampling_window(sq(300, 375, 200, 50), canvas), sq(200, 200, 400, 400)); + assert_eq!(sampling_window(sq(250, 350, 300, 100), canvas), sq(53, 53, 693, 693)); + // At the canvas edge the square slides inwards rather than being cut. + assert_eq!(sampling_window(sq(10, 375, 50, 50), canvas), sq(0, 300, 200, 200)); + assert_eq!(sampling_window(sq(5, 5, 60, 60), canvas), sq(0, 0, 240, 240)); + // A canvas smaller than the square clips it; a very long selection keeps its length. + assert_eq!(sampling_window(sq(40, 40, 50, 50), Rect::new(0, 0, 150, 120)), Rect::new(0, 0, 150, 120)); + assert_eq!(sampling_window(sq(100, 995, 1800, 10), Rect::new(0, 0, 2000, 2000)), sq(100, 400, 1800, 1200)); + assert_eq!(sampling_window(Rect::EMPTY, canvas), Rect::EMPTY); + } + + #[test] + fn auto_sampling_keeps_what_looks_like_the_surroundings() { + // Grey texture, dark on the left, 50 levels brighter on the right; the hole is in the dark + // part near the boundary (Photoshop's Auto leaves the bright part out). + let (w, h) = (200, 200); + let tex = noise(w, h, 3); + let shade = |x: usize, band: bool| { + if band { + if (130..150).contains(&x) { 0.55 } else { 0.35 } + } else if x < 135 { + 0.35 + } else { + 0.55 + } + }; + for band in [false, true] { + let img: Vec = (0..w * h * 3).map(|i| shade((i / 3) % w, band) + (tex[i] - 0.5) * 0.1).collect(); + let hole: Vec = (0..w * h).map(|i| (75..125).contains(&(i % w)) && (75..125).contains(&(i / w))).collect(); + let area = auto_sampling(w, h, 3, &img, &hole); + let frac = |x0: usize, x1: usize| { + let n = (0..w * h).filter(|i| (x0..x1).contains(&(i % w)) && !hole[*i]).count(); + (0..w * h).filter(|i| (x0..x1).contains(&(i % w)) && area[*i]).count() as f32 / n as f32 + }; + assert!(hole.iter().zip(&area).all(|(hl, a)| !(*hl && *a)), "never the hole"); + assert!(frac(0, 125) > 0.95, "the dark side is sampled ({})", frac(0, 125)); + if band { + // A bright band through the window is left out; the dark area beyond it is not. + assert!(frac(133, 147) < 0.05, "the band is excluded ({})", frac(133, 147)); + assert!(frac(155, 200) > 0.9, "beyond the band is sampled ({})", frac(155, 200)); + } else { + assert!(frac(140, 200) < 0.05, "the bright side is excluded ({})", frac(140, 200)); + } + } + } + + #[test] + fn without_colour_adaptation_the_fill_copies_pixels_exactly() { + // A smooth texture with fine noise, so that every value is unique: a filled pixel equal to + // an image pixel was copied from it. Photoshop's fill (colour adaptation None) is such + // copies, joined by narrow seams. + let (w, h) = (120, 120); + let fine = noise(w, h, 9); + let img: Vec = (0..w * h * 3) + .map(|i| { + let (x, y, c) = (((i / 3) % w) as f32, ((i / 3) / w) as f32, i % 3); + 0.5 + 0.2 * (x * 0.21 + c as f32).sin() * (y * 0.17).cos() + 0.1 * (x * 0.05 - y * 0.08).sin() + (fine[i] - 0.5) * 0.08 + }) + .collect(); + let hole: Vec = (0..w * h).map(|i| (40..80).contains(&(i % w)) && (40..80).contains(&(i / w))).collect(); + // Votes of identical copies average to the copy up to float rounding. + let key = |v: &[f32], i: usize| [0, 1, 2].map(|c| (v[i * 3 + c] * 1e5).round() as i64); + let known: std::collections::HashSet<[i64; 3]> = (0..w * h).filter(|i| !hole[*i]).map(|i| key(&img, i)).collect(); + let out = fill(w, h, 3, &img, &hole, &vec![true; w * h], &FillOptions { color_adaptation: Adapt::NONE, ..Default::default() }); + let exact = (0..w * h).filter(|i| hole[*i] && known.contains(&key(&out, *i))).count(); + assert!(exact as f32 > 0.2 * 1600.0, "{exact} of 1600 filled pixels are exact copies"); + // And the fill is as busy as the noise around it, not a blur of it. + let lap = |v: &[f32], x: usize, y: usize| { + let p = |x: usize, y: usize| v[(y * w + x) * 3]; + (4.0 * p(x, y) - p(x - 1, y) - p(x + 1, y) - p(x, y - 1) - p(x, y + 1)).abs() + }; + let mean = |inside: bool| { + let pts: Vec<(usize, usize)> = (1..h - 1).flat_map(|y| (1..w - 1).map(move |x| (x, y))).filter(|&(x, y)| hole[y * w + x] == inside).collect(); + pts.iter().map(|&(x, y)| lap(&out, x, y)).sum::() / pts.len() as f32 + }; + assert!(mean(true) > 0.6 * mean(false), "fill detail {} against {}", mean(true), mean(false)); + } + + #[test] + fn patch_color_shifts_up_to_about_four_hundredths_per_step() { + // Content 0.4 brighter than where it lands (Photoshop's Patch, Color 3 and 10). + let from: Vec = (0..300).map(|i| 0.7 + 0.05 * ((i % 7) as f32 / 7.0)).collect(); + let to: Vec = from.iter().map(|v| v - 0.4).collect(); + let level = from.iter().sum::() / 300.0; + let shift = |c: u8| { + let gb = level_transform(1, &from, &to, color_amount(c)); + level - from.iter().map(|v| gb[0].0 * v + gb[0].1).sum::() / 300.0 + }; + assert_eq!(shift(0), 0.0); + assert!((shift(3) - 0.12).abs() < 0.01, "{}", shift(3)); + assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10)); + } + #[test] fn variant_geometry() { let img = vec![0.5f32; 10 * 6]; diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 6b85d01524..80ce81a8b2 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -1,13 +1,28 @@ -//! Hole filling for Spot Healing: patch-based completion, proximity match and texture synthesis. +//! Hole filling for Content-Aware Fill and Spot Healing: patch-based completion, proximity match +//! and texture synthesis. //! //! * [`complete`], content-aware fill: the multi-scale "space-time completion" EM scheme of //! Y. Wexler, E. Shechtman, M. Irani, *Space-Time Completion of Video*, IEEE TPAMI 2007, with the //! nearest-neighbour field computed by C. Barnes, E. Shechtman, A. Finkelstein, D. B. Goldman, //! *PatchMatch: A Randomized Correspondence Algorithm for Structural Image Editing*, SIGGRAPH 2009 //! (random init, propagation, exponentially shrinking random search). Each EM iteration finds, for -//! every patch overlapping the hole, its most similar fully-known patch, then re-estimates hole -//! pixels by a similarity-weighted vote of the overlapping matches. Coarse-to-fine over an image -//! pyramid (the coarsest level is initialised by membrane interpolation), so large holes stay cheap. +//! every patch overlapping the hole, its most similar source patch, then re-estimates hole pixels +//! by a similarity-weighted vote of the overlapping matches. Coarse to fine over an image pyramid +//! (down to where the hole is about four patches across): the coarsest level starts by filling +//! the hole from its edge inwards, ring by ring, each pixel from the patch that best matches what +//! is known around it (rather than from a smooth membrane, which draws the fill towards flat +//! areas); every finer level starts from the coarser level's matches scaled up and voted with the +//! finer level's own pixels, so the texture stays sharp. Where neighbouring patches agree on their +//! source (the usual case), the vote copies the source pixels exactly; only along the seams +//! between copied pieces does it blend. +//! +//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4 +//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel +//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour +//! adaptation the copies come with a gain and bias. That is the patch transform of S. Darabi, +//! E. Shechtman, C. Barnes, D. B. Goldman, P. Sen, *Image Melding: Combining Inconsistent Images +//! using Patch-based Synthesis*, SIGGRAPH 2012: each source patch may be scaled and offset per +//! channel to match its target, within limits ([`Adapt`]), and is voted in adjusted. //! * [`best_offset`], proximity match: the single displacement whose surrounding ring best matches the //! ring around the hole (sum of squared differences), i.e. an automatic Healing Brush source. //! * [`synthesize`], create texture: patch-based texture synthesis in the style of A. Efros, @@ -47,6 +62,30 @@ impl Rng { } } +/// How far a copied patch may be adjusted to its target, per channel: `gain · source + bias`, the +/// gain within `gain` and the bias within `±bias` (Image Melding's photometric transform). The +/// gain is the ratio of the patches' standard deviations, the bias what then matches their means. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Adapt { + pub gain: (f32, f32), + pub bias: f32, +} + +impl Adapt { + /// Copies are taken as they are. + pub const NONE: Adapt = Adapt { gain: (1.0, 1.0), bias: 0.0 }; + + pub fn is_none(&self) -> bool { + self.gain == (1.0, 1.0) && self.bias == 0.0 + } +} + +impl Default for Adapt { + fn default() -> Self { + Self::NONE + } +} + /// Parameters for [`complete`]. #[derive(Clone, Debug, PartialEq)] pub struct CompleteParams { @@ -57,49 +96,71 @@ pub struct CompleteParams { pub min_em_iters: usize, /// PatchMatch passes per EM iteration. pub pm_iters: usize, - /// Levels where the hole is wider than this (in that level's pixels) only *refine* the upsampled - /// match field (one local PatchMatch pass and one vote), which keeps big holes tractable: the - /// structure is decided at coarse scale, fine levels just add detail. + /// Levels where the hole is wider than this (in that level's pixels) only *refine* the + /// scaled-up match field (one local PatchMatch pass and one vote), which keeps big holes + /// tractable: the structure is decided at coarse scale, fine levels just add detail. pub refine_extent: usize, + /// Colour adaptation of the copies. + pub adapt: Adapt, pub seed: u64, } impl Default for CompleteParams { fn default() -> Self { - Self { patch_radius: 3, em_iters: 8, min_em_iters: 2, pm_iters: 2, refine_extent: 96, seed: 1 } + Self { patch_radius: 3, em_iters: 10, min_em_iters: 3, pm_iters: 2, refine_extent: 192, adapt: Adapt::NONE, seed: 1 } } } +/// What a pixel is to [`complete_masked`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Px { + /// Not part of the image (a gap, or outside a transformed copy): ignored. + Void, + /// Known, part of the context the fill must agree with, but not copied from. + Known, + /// Known and may be copied from. + Source, + /// To be filled. + Hole, +} + /// One pyramid level. #[derive(Clone)] struct Level { w: usize, h: usize, img: Vec, - hole: Vec, + px: Vec, } +/// Half-size level: a cell is a hole if any of its pixels is; else it averages its non-void +/// pixels, and may be copied from only if all of them may. fn downsample(l: &Level, ch: usize) -> Level { let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2)); let mut img = vec![0.0f32; w * h * ch]; - let mut hole = vec![false; w * h]; + let mut px = vec![Px::Void; w * h]; for y in 0..h { for x in 0..w { let mut n = 0.0f32; let o = (y * w + x) * ch; + let (mut hole, mut all_source, mut any) = (false, true, false); for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { let (sx, sy) = (x * 2 + dx, y * 2 + dy); if sx >= l.w || sy >= l.h { continue; } let si = sy * l.w + sx; - if l.hole[si] { - hole[y * w + x] = true; - } else { - for c in 0..ch { - img[o + c] += l.img[si * ch + c]; + match l.px[si] { + Px::Hole => hole = true, + Px::Void => all_source = false, + k => { + any = true; + all_source &= k == Px::Source; + for c in 0..ch { + img[o + c] += l.img[si * ch + c]; + } + n += 1.0; } - n += 1.0; } } if n > 0.0 { @@ -107,9 +168,18 @@ fn downsample(l: &Level, ch: usize) -> Level { img[o + c] /= n; } } + px[y * w + x] = if hole { + Px::Hole + } else if !any { + Px::Void + } else if all_source { + Px::Source + } else { + Px::Known + }; } } - Level { w, h, img, hole } + Level { w, h, img, px } } /// Summed-area table of a boolean grid (`(w+1) × (h+1)`). @@ -151,6 +221,10 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, b } +/// A finished coarser level: width, height, pixels and match field (source centre per target, +/// `i32::MIN` where there is none). +type Coarser = (usize, usize, Vec, Vec<(i32, i32)>); + /// Per-level PatchMatch/EM state. struct Solver<'a> { w: usize, @@ -158,24 +232,42 @@ struct Solver<'a> { ch: usize, r: i32, img: &'a mut Vec, - hole: &'a [bool], - /// Valid source centres: the whole patch is inside the grid and contains no hole pixel. + px: &'a [Px], + /// Valid source centres: the whole patch is inside the grid and may be copied from. valid: Vec, valid_list: Vec<(i32, i32)>, /// Target centres (patch overlaps the hole), row-major. targets: Vec, + adapt: Adapt, /// Cancellation: checked per row band; a cancelled solve's output is discarded. ctl: photocraft_raster::Interrupt<'a>, } +/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)` +/// over `n` samples, within the limits. +#[inline] +pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f32, f32) { + let (mt, ms) = (st / n, ss / n); + let vt = (stt / n - mt * mt).max(0.0); + let vs = (sss / n - ms * ms).max(0.0); + let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 }; + let g = g.clamp(a.gain.0, a.gain.1); + let b = (mt - g * ms).clamp(-a.bias, a.bias); + (g, b) +} + impl Solver<'_> { #[inline] fn is_valid(&self, x: i32, y: i32) -> bool { x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.valid[y as usize * self.w + x as usize] } - /// SSD between the (clipped) target patch at `t` and the source patch at `s`, per sample. + /// Distance between the target patch at `t` (its pixels inside the grid and not void) and the + /// source patch at `s`, per sample; with colour adaptation the source is fitted first. fn dist(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 { + if !self.adapt.is_none() { + return self.dist_adapted(t, s); + } let (w, h, ch, r) = (self.w as i32, self.h as i32, self.ch, self.r); let full = ((2 * r + 1) * (2 * r + 1)) as f32 * ch as f32; let mut sum = 0.0f32; @@ -188,7 +280,7 @@ impl Solver<'_> { let sy = s.1 + dy; for dx in -r..=r { let tx = t.0 + dx; - if tx < 0 || tx >= w { + if tx < 0 || tx >= w || self.px[ty as usize * self.w + tx as usize] == Px::Void { continue; } let ti = (ty as usize * self.w + tx as usize) * ch; @@ -207,6 +299,70 @@ impl Solver<'_> { if n == 0 { f32::INFINITY } else { sum / n as f32 } } + /// Moments of the target patch at `t` and the source patch at `s` per channel: + /// `[Σt, Σt², Σs, Σs², Σts]`, and the sample count. + fn moments(&self, t: (i32, i32), s: (i32, i32), m: &mut [[f32; 5]]) -> usize { + let (w, h, ch, r) = (self.w as i32, self.h as i32, self.ch, self.r); + m.iter_mut().for_each(|v| *v = [0.0; 5]); + let mut n = 0usize; + for dy in -r..=r { + let ty = t.1 + dy; + if ty < 0 || ty >= h { + continue; + } + let sy = s.1 + dy; + for dx in -r..=r { + let tx = t.0 + dx; + if tx < 0 || tx >= w || self.px[ty as usize * self.w + tx as usize] == Px::Void { + continue; + } + let ti = (ty as usize * self.w + tx as usize) * ch; + let si = (sy as usize * self.w + (s.0 + dx) as usize) * ch; + for (c, mc) in m.iter_mut().enumerate().take(ch) { + let (a, b) = (self.img[ti + c], self.img[si + c]); + mc[0] += a; + mc[1] += a * a; + mc[2] += b; + mc[3] += b * b; + mc[4] += a * b; + } + n += 1; + } + } + n + } + + fn dist_adapted(&self, t: (i32, i32), s: (i32, i32)) -> f32 { + let mut m = [[0.0f32; 5]; 8]; + let ch = self.ch.min(8); + let n = self.moments(t, s, &mut m[..ch]); + if n == 0 { + return f32::INFINITY; + } + let nf = n as f32; + let mut sum = 0.0f32; + for mc in &m[..ch] { + let (g, b) = fit(self.adapt, nf, mc[0], mc[1], mc[2], mc[3]); + // Σ (t − g s − b)², expanded. + sum += mc[1] + g * g * mc[3] + nf * b * b - 2.0 * g * mc[4] - 2.0 * b * mc[0] + 2.0 * g * b * mc[2]; + } + sum.max(0.0) / (nf * ch as f32) + } + + /// Gain and bias per channel for the match `t → s` (identity without adaptation). + fn transform(&self, t: (i32, i32), s: (i32, i32), out: &mut [(f32, f32)]) { + if self.adapt.is_none() { + out.iter_mut().for_each(|v| *v = (1.0, 0.0)); + return; + } + let mut m = [[0.0f32; 5]; 8]; + let ch = self.ch.min(8); + let n = self.moments(t, s, &mut m[..ch]); + for (c, o) in out.iter_mut().enumerate() { + *o = if n == 0 || c >= ch { (1.0, 0.0) } else { fit(self.adapt, n as f32, m[c][0], m[c][1], m[c][2], m[c][3]) }; + } + } + /// One PatchMatch pass over all target rows, in parallel bands (each band propagates within itself). fn patchmatch(&self, nnf: &mut [(i32, i32)], cost: &mut [f32], pass: usize, seed: u64, search_radius: i32) { const BAND: usize = 16; @@ -282,7 +438,32 @@ impl Solver<'_> { } } - /// Re-estimate hole pixels by a similarity-weighted vote of all overlapping patch matches. + /// Recompute every target's match cost (hole pixels change between iterations). + fn costs(&self, nnf: &[(i32, i32)], cost: &mut [f32]) -> Result<(), photocraft_raster::Cancelled> { + let w = self.w; + let row = |(y, co): (usize, &mut [f32])| { + if self.ctl.cancelled() { + return; + } + for (x, c) in co.iter_mut().enumerate() { + let i = y * w + x; + if self.targets[i] { + *c = self.dist((x as i32, y as i32), nnf[i], f32::INFINITY); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if w * self.h >= PAR_MIN { + use rayon::prelude::*; + cost.par_chunks_mut(w).enumerate().for_each(row); + return self.ctl.check(); + } + cost.chunks_mut(w).enumerate().for_each(row); + self.ctl.check() + } + + /// Re-estimate hole pixels by a similarity-weighted vote of all overlapping patch matches + /// (each copy adjusted by its gain and bias). fn vote(&mut self, nnf: &[(i32, i32)], cost: &[f32]) { let (w, h, ch, r) = (self.w, self.h, self.ch, self.r); // σ² from the 75th percentile of match costs (Wexler et al. use a robust scale like this). @@ -294,6 +475,32 @@ impl Solver<'_> { let (_, v, _) = cs.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); v.max(1e-6) }; + // Each target's photometric transform (computed once, used by every pixel it covers). + let adapted = !self.adapt.is_none(); + let gb: Vec<(f32, f32)> = if adapted { + let mut gb = vec![(1.0f32, 0.0f32); w * h * ch]; + let this = &*self; + let row = |(y, out): (usize, &mut [(f32, f32)])| { + for x in 0..w { + let i = y * w + x; + if this.targets[i] && cost[i].is_finite() { + this.transform((x as i32, y as i32), nnf[i], &mut out[x * ch..(x + 1) * ch]); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if w * h >= PAR_MIN { + use rayon::prelude::*; + gb.par_chunks_mut(w * ch).enumerate().for_each(row); + } else { + gb.chunks_mut(w * ch).enumerate().for_each(row); + } + #[cfg(target_arch = "wasm32")] + gb.chunks_mut(w * ch).enumerate().for_each(row); + gb + } else { + Vec::new() + }; let img = &*self.img; let row = |y: usize| -> Vec<(usize, Vec)> { let mut out = Vec::new(); @@ -301,7 +508,7 @@ impl Solver<'_> { return out; } for x in 0..w { - if !self.hole[y * w + x] { + if self.px[y * w + x] != Px::Hole { continue; } let mut acc = vec![0.0f32; ch]; @@ -325,7 +532,13 @@ impl Solver<'_> { let wt = (-cost[ti] / (2.0 * sigma2)).exp().max(1e-8); let si = (sy as usize * w + sx as usize) * ch; for c in 0..ch { - acc[c] += img[si + c] * wt; + let v = if adapted { + let (g, b) = gb[ti * ch + c]; + g * img[si + c] + b + } else { + img[si + c] + }; + acc[c] += v * wt; } wsum += wt; } @@ -354,8 +567,71 @@ impl Solver<'_> { } } -/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known -/// patch to copy from (the caller can fall back to [`membrane_fill`]). +/// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the +/// source patch that best matches what is already known around it. +fn onion_peel(w: usize, h: usize, ch: usize, r: i32, img: &mut [f32], px: &[Px], sources: &[(i32, i32)]) { + let mut known: Vec = px.iter().map(|k| matches!(k, Px::Known | Px::Source)).collect(); + loop { + let front: Vec = (0..w * h) + .filter(|&i| { + if known[i] || px[i] != Px::Hole { + return false; + } + let (x, y) = ((i % w) as i32, (i / w) as i32); + (-1..=1).any(|dy: i32| { + (-1..=1).any(|dx: i32| { + let (nx, ny) = (x + dx, y + dy); + nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && known[ny as usize * w + nx as usize] + }) + }) + }) + .collect(); + if front.is_empty() { + break; + } + let best = |&i: &usize| -> Vec { + let (x, y) = ((i % w) as i32, (i / w) as i32); + let mut best = (f32::INFINITY, (0i32, 0i32)); + for &(sx, sy) in sources { + let (mut e, mut n) = (0.0f32, 0usize); + for dy in -r..=r { + for dx in -r..=r { + let (tx, ty) = (x + dx, y + dy); + if tx < 0 || ty < 0 || tx as usize >= w || ty as usize >= h || !known[ty as usize * w + tx as usize] { + continue; + } + let (ti, si) = ((ty as usize * w + tx as usize) * ch, ((sy + dy) as usize * w + (sx + dx) as usize) * ch); + for c in 0..ch { + let d = img[ti + c] - img[si + c]; + e += d * d; + } + n += 1; + } + } + if n > 0 && e / (n as f32) < best.0 { + best = (e / n as f32, (sx, sy)); + } + } + let si = (best.1.1 as usize * w + best.1.0 as usize) * ch; + img[si..si + ch].to_vec() + }; + #[cfg(not(target_arch = "wasm32"))] + let vals: Vec> = { + use rayon::prelude::*; + front.par_iter().map(best).collect() + }; + #[cfg(target_arch = "wasm32")] + let vals: Vec> = front.iter().map(best).collect(); + for (&i, v) in front.iter().zip(vals) { + img[i * ch..(i + 1) * ch].copy_from_slice(&v); + known[i] = true; + } + } +} + +/// Content-aware completion of `hole` (see module docs), copying from any pixel outside it. Returns +/// `None` when there is no fully-known patch to copy from (the caller can fall back to +/// [`membrane_fill`]). pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option> { // Never cancelled, so never `Err`. complete_with(w, h, ch, img, hole, p, &photocraft_raster::Interrupt::NONE).unwrap_or(None) @@ -372,18 +648,37 @@ pub fn complete_with( p: &CompleteParams, ctl: &photocraft_raster::Interrupt, ) -> Result>, photocraft_raster::Cancelled> { - assert_eq!(img.len(), w * h * ch); assert_eq!(hole.len(), w * h); - let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) }; + let px: Vec = hole.iter().map(|h| if *h { Px::Hole } else { Px::Source }).collect(); + complete_masked(w, h, ch, img, &px, p, ctl) +} + +/// [`complete_with`] with every pixel's role given ([`Px`]): the hole, the context, what may be +/// copied, and what isn't image at all. +pub fn complete_masked( + w: usize, + h: usize, + ch: usize, + img: &[f32], + px: &[Px], + p: &CompleteParams, + ctl: &photocraft_raster::Interrupt, +) -> Result>, photocraft_raster::Cancelled> { + assert_eq!(img.len(), w * h * ch); + assert_eq!(px.len(), w * h); + let hole: Vec = px.iter().map(|k| *k == Px::Hole).collect(); + let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, &hole) else { return Ok(Some(img.to_vec())) }; + drop(hole); let r = p.patch_radius.max(1); let psz = 2 * r + 1; - // Pyramid: shrink until the hole is a few patches across. - let mut levels = vec![Level { w, h, img: img.to_vec(), hole: hole.to_vec() }]; + // Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the + // structure where the texture has averaged out, and fills flat). + let mut levels = vec![Level { w, h, img: img.to_vec(), px: px.to_vec() }]; let mut ext = (bx1 - bx0).max(by1 - by0); let mut exts = vec![ext]; while let Some(l) = levels.last() { ctl.check()?; - if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { + if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { break; } let d = downsample(l, ch); @@ -392,17 +687,42 @@ pub fn complete_with( exts.push(ext); } let nlev = levels.len(); - let mut nnf: Vec<(i32, i32)> = Vec::new(); - let mut prev: Option<(usize, usize, Vec)> = None; + // The coarser level's result: its size, pixels and match field (empty when it had nothing to + // copy). + let mut prev: Option = None; let total_px: usize = levels.iter().map(|l| l.w * l.h).sum(); let mut done_px = 0usize; + let ri = r as i32; for li in (0..nlev).rev() { ctl.check()?; - let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone(); - // Initialise the hole: membrane at the coarsest level, upsampled result elsewhere. + let Level { w: lw, h: lh, img: mut limg, px: lpx } = std::mem::replace(&mut levels[li], Level { w: 0, h: 0, img: Vec::new(), px: Vec::new() }); + let lhole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect(); + let source: Vec = lpx.iter().map(|k| *k == Px::Source).collect(); + let hs = integral(lw, lh, &lhole); + let ss = integral(lw, lh, &source); + let full = (psz * psz) as u32; + let mut valid = vec![false; lw * lh]; + let mut valid_list = Vec::new(); + let mut targets = vec![false; lw * lh]; + for y in 0..lh as i32 { + if y % 32 == 0 { + ctl.check()?; + } + for x in 0..lw as i32 { + let i = y as usize * lw + x as usize; + let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32; + if inside && window_count(&ss, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1) == full { + valid[i] = true; + valid_list.push((x, y)); + } + targets[i] = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1) > 0; + } + } + // Initialise the hole: at the coarsest level peeled from its edge inwards (or a membrane + // where nothing can be copied), else the coarser level's pixels, refined below by a vote of + // its scaled-up matches. match &prev { - None => limg = membrane_fill(lw, lh, ch, &limg, &lhole), - Some((pw, ph, pimg)) => { + Some((pw, ph, pimg, _)) => { for y in 0..lh { for x in 0..lw { if lhole[y * lw + x] { @@ -413,41 +733,30 @@ pub fn complete_with( } } } - } - let ri = r as i32; - let hs = integral(lw, lh, &lhole); - let mut valid = vec![false; lw * lh]; - let mut valid_list = Vec::new(); - let mut targets = vec![false; lw * lh]; - for y in 0..lh as i32 { - if y % 32 == 0 { - ctl.check()?; - } - for x in 0..lw as i32 { - let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1); - let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32; - let i = y as usize * lw + x as usize; - if inside && cnt == 0 { - valid[i] = true; - valid_list.push((x, y)); + None if !valid_list.is_empty() => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, &valid_list), + None => { + let unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + let m = membrane_fill(lw, lh, ch, &limg, &unknown); + for (i, hl) in lhole.iter().enumerate() { + if *hl { + limg[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]); + } } - targets[i] = cnt > 0; } } if valid_list.is_empty() { if li == 0 { return Ok(None); } - prev = Some((lw, lh, limg)); - nnf.clear(); + prev = Some((lw, lh, limg, Vec::new())); continue; } - let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: *ctl }; - // NNF init: upsampled from the coarser level where possible, random otherwise. + let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, px: &lpx, valid, valid_list, targets, adapt: p.adapt, ctl: *ctl }; + // NNF init: the coarser level's matches scaled up where they land on a valid source, + // random otherwise. let mut rng = Rng::new(p.seed ^ (li as u64) << 20); - let pw_prev = prev.as_ref().map_or(0, |p| p.0); - let old = std::mem::take(&mut nnf); - let mut new_nnf = vec![(0i32, 0i32); lw * lh]; + let mut nnf = vec![(0i32, 0i32); lw * lh]; + let mut upsampled = false; for y in 0..lh { if y % 32 == 0 { ctl.check()?; @@ -457,19 +766,29 @@ pub fn complete_with( continue; } let mut cand = None; - if !old.is_empty() && pw_prev > 0 { - let pi = (y / 2) * pw_prev + (x / 2); - if let Some(&(sx, sy)) = old.get(pi) { - let c = (sx * 2 + (x % 2) as i32, sy * 2 + (y % 2) as i32); + if let Some((pw, ph, _, old)) = &prev + && !old.is_empty() + { + let (cx, cy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); + let (sx, sy) = old[cy * pw + cx]; + if sx != i32::MIN { + // The same displacement, twice as long. + let c = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32)); if solver.is_valid(c.0, c.1) { cand = Some(c); } } } - new_nnf[y * lw + x] = cand.unwrap_or_else(|| solver.valid_list[(rng.next() % solver.valid_list.len() as u64) as usize]); + upsampled |= cand.is_some(); + nnf[y * lw + x] = cand.unwrap_or_else(|| solver.valid_list[(rng.next() % solver.valid_list.len() as u64) as usize]); } } - nnf = new_nnf; + let mut cost = vec![f32::INFINITY; lw * lh]; + // A finer level starts from the coarse matches voted with its own (sharp) pixels. + if upsampled { + solver.costs(&nnf, &mut cost)?; + solver.vote(&nnf, &cost); + } let refine_only = li + 1 < nlev && exts[li] > p.refine_extent; let em = if li + 1 == nlev { p.em_iters @@ -480,18 +799,11 @@ pub fn complete_with( }; let pm_iters = if refine_only { 1 } else { p.pm_iters }; let full_search = (lw.max(lh)) as i32; - let mut cost = vec![f32::INFINITY; lw * lh]; for it in 0..em { // Costs change as hole pixels are re-estimated: refresh before each PatchMatch. - for (i, c) in cost.iter_mut().enumerate() { - if i % (lw * 32).max(1) == 0 { - ctl.check()?; - } - if solver.targets[i] { - *c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY); - } - } - // Wide random search on coarse levels / first iteration, local refinement afterwards. + solver.costs(&nnf, &mut cost)?; + // Wide random search on the coarsest level and on each level's first iteration, + // local refinement afterwards. let radius = if refine_only { 2 } else if li + 1 == nlev || it == 0 { @@ -507,12 +819,14 @@ pub fn complete_with( solver.vote(&nnf, &cost); ctl.check()?; } + // Keep the match field (unmatched cells marked) for the next level. + let field: Vec<(i32, i32)> = nnf.iter().zip(&solver.targets).map(|(n, t)| if *t { *n } else { (i32::MIN, i32::MIN) }).collect(); // Finer levels cost ~4× the previous one: weight progress by pixel count. done_px += lw * lh; ctl.progress(done_px as f32 / total_px.max(1) as f32); - prev = Some((lw, lh, limg)); + prev = Some((lw, lh, limg, field)); } - Ok(prev.map(|(_, _, img)| img)) + Ok(prev.map(|(_, _, img, _)| img)) } /// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index a0b60fdce6..2ecbe52fd7 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -331,13 +331,14 @@ fn content_aware_fill(s: &mut Session, p: &Value) -> Result { } /// Delete and Fill Selection (#1286): Photoshop's one-click removal from the selection-tool -/// context menu. Content-Aware Fill with its default settings into the layer, no dialog. +/// context menu. Content-Aware Fill into the layer, no dialog, sampling as Edit › Fill does (the +/// whole window, measured on Photoshop 25.4) with the default colour adaptation. fn delete_and_fill(s: &mut Session, _: &Value) -> Result { - content_aware_fill_as(s, &json!({}), "edit.deleteAndFillSelection", "Delete and Fill Selection") + content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection") } fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { - use photocraft_algo::content_aware::{FillOptions, color_level, fill_with, rotation_level}; + use photocraft_algo::content_aware::{FillOptions, auto_sampling, color_level, fill_with, rotation_level}; let id = pixel_layer(s).map_err(EngineError::Other)?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = st.doc.clone(); @@ -346,14 +347,17 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & if hb.is_empty() { return Err(bad(cmd, "the selection is outside the canvas")); } - // The canvas is at most i32 wide, so the extent fits; saturate anyway rather than wrap (#963). - let ext = i32::try_from(hb.width().max(hb.height())).unwrap_or(i32::MAX); let sampling = str_or(p, "sampling", "auto").to_string(); let custom_mask: Option = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned(); let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None }; + // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` + // asks for a rectangle that far around the selection instead. let window = match sampling.as_str() { - "auto" => hb.inflate(crate::fill_cmds::sampling_margin(hb)), - "rectangular" => hb.inflate(int(p, "margin").map_or(ext.max(16), |m| m.clamp(0, 100_000) as i32)), + "auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()), + "rectangular" => match int(p, "margin") { + Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), + None => crate::fill_cmds::sampling_window(hb, doc.bounds()), + }, "custom" => { let r = match (custom_rect, custom_mask.as_ref()) { (Some(r), _) => r, @@ -413,6 +417,11 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & } } ctx.check()?; + // Auto: only what looks like the selection's surroundings. + if sampling == "auto" { + source = auto_sampling(w, h, n, &img, &hole); + } + ctx.check()?; let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_with(w, h, n, &img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; Ok((img, cover, hole, filled)) }, diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index c17f475331..a94a054103 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -218,6 +218,22 @@ fn delete_and_fill_selection_removes_the_object_in_one_step() { assert!(s.execute("edit.deleteAndFillSelection", json!({})).is_err()); } +#[test] +fn content_aware_fill_samples_photoshops_window() { + // A 50 px selection samples the 200 px square centred on it (Photoshop 25.4's overlay); + // at the canvas edge the square slides inwards. Edit › Fill uses the same window. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap(); + for (x, y, want) in [(275, 175, [200, 100, 200, 200]), (10, 10, [0, 0, 200, 200])] { + s.execute("select.rect", json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap(); + for sampling in ["auto", "rectangular"] { + let r = s.execute("edit.contentAwareFill", json!({"sampling": sampling})).unwrap(); + assert_eq!(r["window"], json!(want), "{sampling} at {x},{y}"); + s.undo(); + } + } +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 827bef8662..9453652e7f 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -191,7 +191,7 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: if hole_bounds.is_empty() { return Err(EngineError::Other("the selection is outside the canvas".into())); } - let window = hole_bounds.inflate(sampling_margin(hole_bounds)).intersect(&canvas); + let window = sampling_window(hole_bounds, canvas); let fmt = surf.format(); let n = fmt.channels(); let (w, h) = window_size(window)?; @@ -219,12 +219,12 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: Ok(Source::Pixels(window, px)) } -/// The automatic Content-Aware sampling margin around `hole`: three quarters of its larger side, -/// at least 32 pixels. Worked out in u64 because a loaded document can declare a selection whose -/// extent times three overflows u32 (#963); a margin beyond i32 saturates, as `Rect::inflate` does. -pub(crate) fn sampling_margin(hole: Rect) -> i32 { - let ext = u64::from(hole.width().max(hole.height())); - i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32) +/// Photoshop's Content-Aware sampling window around the selection bounds `hole` +/// ([`photocraft_algo::content_aware::sampling_window`]): Edit › Fill, the workspace's Rectangular +/// and Auto areas and the retouching tools all copy from within it. Worked out in i64, so a loaded +/// document declaring an absurd selection can't overflow it (#963). +pub(crate) fn sampling_window(hole: Rect, canvas: Rect) -> Rect { + photocraft_algo::content_aware::sampling_window(hole.intersect(&canvas), canvas) } /// The size of a Content-Aware sampling window, or an error for a window too large to read and diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 472bea882f..f8ac42f1e8 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -585,14 +585,19 @@ enum SpotType { /// Fill the stroke area with no source, then gradient-domain blend it into its surroundings. With /// `all` (Sample All Layers) it heals what is visible, so it works on an empty layer above the image. +/// +/// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes +/// its texture from around it, as Edit › Fill does from its window +/// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Rect { let (bounds, cov) = stroke_coverage(stroke); let size = stroke.brush.size; - let margin = (size.max(8.0) * 1.0).ceil() as i32 + 8; - let region_rect = bounds.inflate(margin).intersect(&pre.bounds()); + let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { return Rect::EMPTY; } + // Proximity Match looks for its source anywhere in that window. + let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; let (w, h, ch) = (img.width(), img.height(), img.ch); @@ -1083,7 +1088,7 @@ pub fn specs() -> Vec { label: "Patch", menu: &[], shortcut: None, - params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#, + params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#, enabled: patch::enabled, run: patch::patch, journal: true, @@ -1093,7 +1098,7 @@ pub fn specs() -> Vec { label: "Content-Aware Move", menu: &[], shortcut: None, - params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (7 keeps the content up to its edge, lower blends a wider edge band),"color":0..10=0 (how far the content's colour adapts to its new place),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#, + params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#, enabled: content_aware_move::enabled, run: content_aware_move::content_aware_move, journal: true, diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index 5b31bd1bff..4be15cfa73 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -2,22 +2,28 @@ //! content-aware fill close the gaps. //! //! The selection is the content, `offset` how far the user dragged it. At the new place the content -//! is pasted, its colour fitted to the new surroundings by `color` (0 keeps it, 10 is a full -//! seamless clone as the Patch Tool's), and a band along its edge, inside the selection, is -//! re-synthesised from the surroundings so that it merges in. `structure` sets that band: 7 keeps -//! the content up to the selection's edge, lower values hand a wider band to the surroundings. In -//! `move` mode the place the content left is then filled content-aware (`extend` keeps it). That -//! fill never samples the moved content, so the object doesn't come back as a ghost; the edge band -//! may sample it (it is the context the band blends towards), but not the content's old place. -//! (`content_aware::fill` treats excluded pixels as unknown, so excluding the content there would -//! turn the whole moved area into one hole and smear the band further.) +//! is pasted, fitted to its new surroundings by `color`, and a band along its edge, inside the +//! selection, is re-synthesised from the surroundings so that it merges in. In `move` mode the place +//! the content left is then filled content-aware (`extend` keeps it). That fill never samples the +//! moved content, so the object doesn't come back as a ghost; the edge band may sample it (it is the +//! context the band blends towards), but not the content's old place. +//! +//! Measured on Photoshop 25.4 (a 60 px selection holding a ring-shaped object, dragged 150 px): +//! * **Structure** 3 to 7 give the same result, bit for bit: the content is copied exactly up to +//! about 8 px from the selection's edge, and that band blends into the surroundings. 2 widens the +//! band; 1 re-synthesises the content too, so even its middle is no longer an exact copy. +//! * **Color** 0 pastes the content as it is; higher values bring its level towards the new +//! surroundings by up to about `0.04 · color` (Patch Tool, content 42 and 102 levels brighter: +//! 3 shifts it 32 and 31 levels, 10 shifts it 35 and 95), a limited gain and bias +//! ([`photocraft_algo::content_aware::color_amount`]). //! //! The selection moves with the content, as in Photoshop. A soft selection counts wherever it is //! above zero (as the Patch Tool's); the band does the blending. The fills are PatchMatch //! completions and can take seconds on large selections, so the command runs as a background job -//! (#210): with progress, cancellable, and the document unchanged until it applies. +//! (#210): with progress, cancellable, and the document unchanged until it applies. The Patch +//! Tool's Content-Aware mode lands its content the same way ([`land`]). -use photocraft_algo::content_aware::{FillOptions, fill_with}; +use photocraft_algo::content_aware::{FillOptions, color_amount, color_level, fill_with, level_transform}; use super::patch::{coverage, offset, selection_area, target_layer}; use super::*; @@ -58,10 +64,18 @@ fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result { } } +/// The pixels a step reads and writes: a layer (or, with `None`, the channel `p` targets), and +/// whether reading samples the visible composite (Sample All Layers). +#[derive(Clone, Copy, Debug)] +pub(super) struct Target { + pub id: Option, + pub all_layers: bool, +} + /// What a move does, checked before any work starts. #[derive(Clone, Copy, Debug)] struct Plan { - id: Option, + target: Target, canvas: Rect, /// The selected area (where the content comes from). area: Rect, @@ -69,7 +83,6 @@ struct Plan { mode: Mode, structure: u8, color: u8, - all_layers: bool, } fn plan(s: &Session, p: &Value) -> Result { @@ -101,19 +114,21 @@ fn plan(s: &Session, p: &Value) -> Result { return Err(bad(CMD, "the moved selection must stay inside the canvas")); } let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { id, canvas, area, offset: (dx, dy), mode, structure, color, all_layers }) + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), mode, structure, color }) } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content -/// `min_side` pixels across. 7 keeps everything; the fractions grow the band roughly geometrically -/// towards 1, so the default 4 blends about a tenth of the content into its surroundings. -fn band_width(structure: u8, min_side: u32) -> usize { - const FRACTION: [f32; 7] = [0.30, 0.22, 0.15, 0.10, 0.06, 0.03, 0.0]; - let f = FRACTION.get(usize::from(structure.saturating_sub(1))).copied().unwrap_or(0.0); - if f == 0.0 { - return 0; - } - ((min_side as f32 * f).round() as usize).clamp(2, 128) +/// `min_side` pixels across. Photoshop gives the same result for 3 to 7: the band is about a patch +/// wide whatever the content's size; 2 and 1 widen it (1 so far that the content itself is +/// re-synthesised). +pub(super) fn band_width(structure: u8, min_side: u32) -> usize { + let side = min_side as f32; + let w = match structure { + 0 | 1 => (side * 0.3).max(12.0), + 2 => (side * 0.18).max(9.0), + _ => 6.0, + }; + (w.round() as usize).min(128) } /// The cells of a `w × h` mask whose whole `(2r+1)²` neighbourhood, as far as it lies on the grid, @@ -147,17 +162,17 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec { out } -/// Where a fill samples from around `r`: as Edit › Content-Aware Fill's automatic sampling area, -/// three quarters of the extent around it (at least 32, at most [`MAX_MARGIN`] pixels). -fn window(r: Rect, canvas: Rect) -> Rect { - r.inflate(crate::fill_cmds::sampling_margin(r).min(MAX_MARGIN)).intersect(&canvas) +/// Where a fill samples from around `r`: Photoshop's Content-Aware window +/// ([`crate::fill_cmds::sampling_window`]), at most [`MAX_MARGIN`] pixels around `r`. +pub(super) fn window(r: Rect, canvas: Rect) -> Rect { + crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(MAX_MARGIN)) } /// The pixels a step works on: the targeted surface, or with Sample All Layers the visible /// composite (encoded in the target's format). -fn read(doc: &mut Document, plan: &Plan, p: &Value, rect: Rect) -> Result { - let (surf, _) = crate::channel_cmds::target_surface(doc, plan.id, p)?; - if plan.all_layers { +pub(super) fn read(doc: &mut Document, t: Target, p: &Value, rect: Rect) -> Result { + let (surf, _) = crate::channel_cmds::target_surface(doc, t.id, p)?; + if t.all_layers { let fmt = surf.format(); return Ok(composite_region(doc, None, SampleLayers::All, rect, fmt)); } @@ -167,15 +182,15 @@ fn read(doc: &mut Document, plan: &Plan, p: &Value, rect: Rect) -> Result Result { - let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; +pub(super) fn replace(doc: &mut Document, t: Target, p: &Value, out: &Region, mask: &[bool]) -> Result { + let (surf, lock) = crate::channel_cmds::target_surface(doc, t.id, p)?; let fmt = surf.format(); let a = alpha_index(&fmt); let rect = out.rect; let mut px = surf.read_region(rect); let n = out.ch; if px.len() != out.data.len() || mask.len() * n != px.len() { - return Err(EngineError::Other("internal error: content-aware move buffers disagree in size".into())); + return Err(EngineError::Other("internal error: content-aware buffers disagree in size".into())); } for ((dst, src), m) in px.chunks_exact_mut(n).zip(out.data.chunks_exact(n)).zip(mask) { if !*m { @@ -199,16 +214,80 @@ fn replace(doc: &mut Document, plan: &Plan, p: &Value, out: &Region, mask: &[boo } /// Selection coverage over `rect` as a mask (set wherever it is above zero). -fn mask(sel: &Surface, rect: Rect) -> Vec { +pub(super) fn mask(sel: &Surface, rect: Rect) -> Vec { coverage(sel, rect).into_iter().map(|c| c > 0.0).collect() } /// Fill options: the fill's own colour adaptation (Edit › Content-Aware Fill's default), seeded so /// the result is reproducible. -fn fill_options() -> FillOptions { +pub(super) fn fill_options() -> FillOptions { FillOptions { seed: 0x00ca_4e00, ..FillOptions::default() } } +/// A ring of `w` pixels just outside `m` (within the grid). +fn outer_ring(gw: usize, gh: usize, m: &[bool], w: usize) -> Vec { + let inside: Vec = m.iter().map(|v| !*v).collect(); + erode(gw, gh, &inside, w).iter().zip(m).map(|(keep, sel)| !*keep && !*sel).collect() +} + +/// Content landing at `dst_area`: the pixels of the window around it, with `selected` (the +/// selection's shape there) to be replaced by the content found `shift` away (`content` = that +/// window, so `content` pixel = window pixel + `shift`). The content's middle is copied, its level +/// fitted to the new surroundings by `color`; a `band_width(structure)` band inside the selection's +/// edge is re-synthesised from the surroundings and that middle, never sampling `avoid`. Writes the +/// result into the target and returns the rectangle it changed. +#[allow(clippy::too_many_arguments)] +pub(super) fn land( + doc: &mut Document, + t: Target, + p: &Value, + win: Rect, + selected: &[bool], + content: &Region, + avoid: &[bool], + structure: u8, + color: u8, + stage: (f32, f32), + ctx: &crate::jobs::JobCtx, + label: &str, +) -> Result { + let (w, h) = (win.width() as usize, win.height() as usize); + let here = read(doc, t, p, win)?; + let n = here.ch; + let (sx0, sy0, sx1, sy1) = selected.iter().enumerate().filter(|(_, s)| **s).fold((w, h, 0, 0), |b, (i, _)| { + let (x, y) = (i % w, i / w); + (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)) + }); + let side = (sx1.saturating_sub(sx0)).min(sy1.saturating_sub(sy0)) as u32; + let core = erode(w, h, selected, band_width(structure, side)); + ctx.check()?; + // Colour: the content's surroundings (where it came from) against the new ones, both just + // outside the selection's shape. + let ring = outer_ring(w, h, selected, 6); + let pick = |r: &Region| -> Vec { ring.iter().enumerate().filter(|(_, k)| **k).flat_map(|(i, _)| r.data[i * n..(i + 1) * n].to_vec()).collect() }; + let gb = level_transform(n, &pick(content), &pick(&here), color_amount(color)); + let fmt = crate::channel_cmds::target_surface(doc, t.id, p)?.0.format(); + let alpha = alpha_index(&fmt); + let mut buf = here; + for (i, _) in core.iter().enumerate().filter(|(_, c)| **c) { + for (c, &(g, b)) in gb.iter().enumerate() { + let v = content.data[i * n + c]; + // Colour channels are fitted; alpha is copied. + buf.data[i * n + c] = if Some(c) == alpha { v } else { g * v + b }; + } + } + clamp_samples(&fmt, &mut buf.data); + // Structure: the band between the kept core and the selection's edge comes from the + // surroundings and the kept core, never from what `avoid` marks. + let band: Vec = selected.iter().zip(&core).map(|(m, c)| *m && !*c).collect(); + if band.iter().any(|b| *b) { + let allowed: Vec = avoid.iter().map(|o| !*o).collect(); + let opts = FillOptions { color_adaptation: if color == 0 { color_level("default") } else { color_amount(color) }, ..fill_options() }; + buf.data = ctx.stage(stage.0, stage.1, label, |ctl| fill_with(w, h, n, &buf.data, &band, &allowed, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; + } + replace(doc, t, p, &buf, selected) +} + /// The new target surface and the rectangle it changed: the content pasted at the offset, then /// (`move`) its old place filled. Works on `doc`, a copy of the document. fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Surface, Rect)> { @@ -217,62 +296,30 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra let split = if plan.mode == Mode::Move { 0.4 } else { 1.0 }; let cancelled = |_| EngineError::Cancelled; - // 1. The content at its new place. - let dst_area = plan.area.translate(dx, dy); - let win = window(dst_area, plan.canvas); - let (w, h) = (win.width() as usize, win.height() as usize); - let here = read(doc, plan, p, win)?; - let fmt = crate::channel_cmds::target_surface(doc, plan.id, p)?.0.format(); - let n = here.ch; - // The content with its original surroundings, laid over this window. - let mut src = read(doc, plan, p, win.translate(-dx, -dy))?; - src.rect = win; + // 1. The content at its new place: the content with its original surroundings laid over the + // window there; never sampling the content's old place. + let win = window(plan.area.translate(dx, dy), plan.canvas); + let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; + content.rect = win; let moved = mask(sel, win.translate(-dx, -dy)); let old_place = mask(sel, win); - let side = plan.area.width().min(plan.area.height()); - let core = erode(w, h, &moved, band_width(plan.structure, side)); - ctx.check()?; - // Colour: fit the kept content to the new surroundings (seamless clone), mixed in by `color`. - let fitted = if plan.color > 0 && core.iter().any(|c| *c) { - let healed = heal_region(&fmt, &src, &here, &core); - let k = f32::from(plan.color) / 10.0; - let mut f = src.clone(); - f.data.iter_mut().zip(&healed.data).for_each(|(s, h)| *s += (h - *s) * k); - f - } else { - src - }; - let mut buf = here; - for (i, _) in core.iter().enumerate().filter(|(_, c)| **c) { - let r = i * n..(i + 1) * n; - if let (Some(d), Some(s)) = (buf.data.get_mut(r.clone()), fitted.data.get(r)) { - d.copy_from_slice(s); - } - } - // Structure: the band between the kept core and the selection's edge comes from the surroundings - // and the kept core (see the module docs), never from the content's old place. - let band: Vec = moved.iter().zip(&core).map(|(m, c)| *m && !*c).collect(); - if band.iter().any(|b| *b) { - let allowed: Vec = old_place.iter().map(|o| !*o).collect(); - buf.data = ctx.stage(0.0, split, label, |ctl| fill_with(w, h, n, &buf.data, &band, &allowed, &opts, ctl)).map_err(cancelled)?; - } - let mut damage = replace(doc, plan, p, &buf, &moved)?; + let mut damage = land(doc, plan.target, p, win, &moved, &content, &old_place, plan.structure, plan.color, (0.0, split), ctx, label)?; // 2. Move: fill the place the content left (where the content didn't land on it). if plan.mode == Mode::Move { ctx.check()?; let win = window(plan.area, plan.canvas); let (w, h) = (win.width() as usize, win.height() as usize); - let img = read(doc, plan, p, win)?; + let img = read(doc, plan.target, p, win)?; let landed = mask(sel, win.translate(-dx, -dy)); let hole: Vec = mask(sel, win).iter().zip(&landed).map(|(s, l)| *s && !*l).collect(); if hole.iter().any(|h| *h) { let allowed: Vec = landed.iter().map(|l| !*l).collect(); let data = ctx.stage(split, 1.0, label, |ctl| fill_with(w, h, img.ch, &img.data, &hole, &allowed, &opts, ctl)).map_err(cancelled)?; - damage = damage.union(&replace(doc, plan, p, &Region { rect: win, ch: img.ch, data }, &hole)?); + damage = damage.union(&replace(doc, plan.target, p, &Region { rect: win, ch: img.ch, data }, &hole)?); } } - let surf = crate::channel_cmds::target_surface(doc, plan.id, p)?.0.clone(); + let surf = crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0.clone(); Ok((surf, damage)) } @@ -298,7 +345,7 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { move |s, (surf, damage)| { let (dx, dy) = plan.offset; s.edit(label, |doc, _| { - *crate::channel_cmds::target_surface(doc, plan.id, &p)?.0 = surf; + *crate::channel_cmds::target_surface(doc, plan.target.id, &p)?.0 = surf; // The selection follows the content, ready for another drag. if let Some(sel) = doc.selection.as_ref() { doc.selection = Some(crate::layer_multi_cmds::shift_surface(sel, dx, dy)); diff --git a/crates/engine/src/retouch_cmds/patch.rs b/crates/engine/src/retouch_cmds/patch.rs index dc0f5e3620..f3cb4765a2 100644 --- a/crates/engine/src/retouch_cmds/patch.rs +++ b/crates/engine/src/retouch_cmds/patch.rs @@ -6,6 +6,16 @@ //! takes the colour and lighting around the repaired area. A feathered selection blends the result //! in by its coverage. Only the targeted surface is read and written: in Normal mode the patch //! samples the current layer. +//! +//! With `contentAware` (Photoshop's Patch: Content-Aware) the selected area is filled from the +//! dragged-to place content-aware, as the Content-Aware Move Tool lands its content +//! ([`super::content_aware_move::land`]): the middle is copied, fitted to the selection's +//! surroundings by `color` (0–10), and a band along the edge, set by `structure` (1–7), is +//! re-synthesised. Measured on Photoshop 25.4 (a 60 px selection dragged 150 px onto a ring-shaped +//! object): only the selection changes; Structure 3 to 7 give the same result; Color 0 keeps the +//! copied level, higher values shift it towards the surroundings by up to about `0.04 · color` +//! (3: 32 of 42 levels, 31 of 102; 10: 35 of 42, 95 of 102). It runs as a background job, like +//! the move. use super::*; @@ -202,6 +212,9 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { + if flag(p, "contentAware", false) { + return patch_content_aware(s, p); + } let plan = plan(s, p)?; let label = if plan.destination { "Patch (Destination)" } else { "Patch" }; let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { @@ -229,3 +242,61 @@ pub fn preview(s: &Session, p: &Value, max_cells: u64, min_step: u32) -> Result< let dmg = patch_surface(surf, sel, &plan, lock, step); Ok((doc, dmg)) } + +/// Optional whole-number param in `lo..=hi`; absent or null gives `default`. +fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&x) => Ok(x.round() as u8), + _ => Err(bad(CMD, format!("`{k}` must be a number {lo}..{hi}"))), + }, + } +} + +/// Patch: Content-Aware. The selection is filled from the place it was dragged to (see the module +/// docs), as a background job. +fn patch_content_aware(s: &mut Session, p: &Value) -> Result { + use super::content_aware_move::{Target, land, mask, read, window}; + const LABEL: &str = "Patch (Content-Aware)"; + let plan = plan(s, p)?; + let structure = level(p, "structure", 4, 1, 7)?; + let color = level(p, "color", 0, 0, 10)?; + let target = Target { id: plan.id, all_layers: flag(p, "sampleAllLayers", false) && targets_pixels(p) }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let doc = d.doc.clone(); + let p = p.clone(); + let (dx, dy) = plan.offset; + let (area, canvas) = (plan.area, plan.canvas); + crate::jobs::run( + s, + LABEL, + true, + { + let p = p.clone(); + move |ctx| { + ctx.progress(0.0, LABEL); + let mut work = (*doc).clone(); + let sel = doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + let win = window(area, canvas); + let mut content = read(&mut work, target, &p, win.translate(dx, dy))?; + content.rect = win; + let selected = mask(sel, win); + let avoid = vec![false; selected.len()]; + let damage = land(&mut work, target, &p, win, &selected, &content, &avoid, structure, color, (0.0, 1.0), ctx, LABEL)?; + let surf = crate::channel_cmds::target_surface(&mut work, target.id, &p)?.0.clone(); + Ok((surf, damage)) + } + }, + move |s, (surf, damage)| { + s.edit(LABEL, |doc, _| { + *crate::channel_cmds::target_surface(doc, target.id, &p)?.0 = surf; + Ok(()) + })?; + if let Some(st) = s.active_mut() { + st.last_damage = Some(damage); + } + Ok(json!({ "damage": damage_json(damage), "offset": [dx, dy], "contentAware": true })) + }, + ) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index ed7eb271b1..243351d424 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -677,6 +677,39 @@ fn patch_source_repairs_the_selection_with_texture_from_the_drag_target() { } } +#[test] +fn patch_content_aware_copies_the_dragged_to_content_into_the_selection() { + // Photoshop's Patch: Content-Aware (measured on 25.4): only the selection changes; its middle + // is the dragged-to content (Color 0 keeps its level), the edge band blends. + for depth in [8, 16] { + let mut s = session(128, 64, depth, "rgb"); + paint_layer(&mut s, blemished(40)); + s.execute("select.rect", json!({"x": 32, "y": 12, "width": 24, "height": 24})).unwrap(); + let r = s.execute("paint.patch", json!({"offset": [-30, 2], "contentAware": true, "structure": 7, "color": 0})).unwrap(); + assert_eq!(r["contentAware"], json!(true)); + for (x, y) in [(42, 22), (44, 24), (46, 26)] { + let (got, want) = (rgba(&s, x, y), texture(x - 30, y + 2)); + for c in 0..4 { + assert!((got[c] - want[c]).abs() <= tol(depth), "depth {depth}: middle copied at {x},{y}: {got:?} vs {want:?}"); + } + } + for (x, y) in [(31, 20), (56, 25), (12, 20), (44, 40)] { + let (got, want) = (rgba(&s, x, y), texture(x, y)); + assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth}: untouched {x},{y}: {got:?}"); + } + assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch (Content-Aware)")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(rgba(&s, 44, 24)[1], 0.0, "depth {depth}: undo brings the blemish back"); + } + // Bad options are refused before any work. + let mut s = session(96, 48, 8, "rgb"); + paint_layer(&mut s, blemished(40)); + s.execute("select.rect", json!({"x": 36, "y": 16, "width": 16, "height": 16})).unwrap(); + for p in [json!({"offset": [-30, 0], "contentAware": true, "structure": 9}), json!({"offset": [-30, 0], "contentAware": true, "color": -1})] { + assert!(s.execute("paint.patch", p.clone()).is_err(), "{p}"); + } +} + #[test] fn patch_destination_repairs_the_drag_target_from_the_selection() { let mut s = session(96, 48, 16, "rgb"); @@ -834,40 +867,55 @@ fn content_aware_extend_keeps_the_original() { #[test] fn content_aware_move_structure_and_color_shape_the_result() { - // Structure 7, Color 0: the content is copied exactly, edge to edge. - let mut s = session(128, 64, 16, "rgb"); - paint_layer(&mut s, texture); - s.execute("select.rect", json!({"x": 10, "y": 10, "width": 24, "height": 24})).unwrap(); - s.execute("paint.contentAwareMove", json!({"offset": [70, 20], "structure": 7, "mode": "extend"})).unwrap(); - for (x, y) in [(10, 10), (22, 22), (33, 33)] { + // Structure 7, Color 0: the content is copied exactly but for a band about a patch wide along + // its edge, which blends into the new surroundings; Structure 3 gives the same, bit for bit + // (as in Photoshop). + let moved = |structure: u64| { + let mut s = session(128, 64, 16, "rgb"); + paint_layer(&mut s, texture); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 24, "height": 24})).unwrap(); + s.execute("paint.contentAwareMove", json!({"offset": [70, 20], "structure": structure, "mode": "extend"})).unwrap(); + s + }; + let s = moved(7); + for (x, y) in [(16, 16), (22, 22), (27, 27)] { let (got, want) = (rgba(&s, x + 70, y + 20), texture(x, y)); for c in 0..4 { assert!((got[c] - want[c]).abs() <= tol(16), "strict copy at {x},{y}: {got:?} vs {want:?}"); } } - // A lower Structure re-synthesises an edge band: the edge differs from a plain copy, the - // centre doesn't. - let mut s = session(128, 64, 16, "rgb"); - paint_layer(&mut s, texture); + let s3 = moved(3); + for y in 30..54 { + for x in 80..104 { + assert_eq!(rgba(&s, x, y), rgba(&s3, x, y), "structure 3 and 7 agree at {x},{y}"); + } + } + // Structure 2 widens the re-synthesised band: the edge differs from a plain copy, the centre + // doesn't. Structure 1 re-synthesises the content too, its centre included. let copy = |s: &Session, x: i32, y: i32| (rgba(s, x + 70, y + 20), rgba(s, x, y)); - s.execute("select.rect", json!({"x": 10, "y": 10, "width": 24, "height": 24})).unwrap(); - s.execute("paint.contentAwareMove", json!({"offset": [70, 20], "structure": 1, "mode": "extend"})).unwrap(); + let s = moved(2); let (centre, orig) = copy(&s, 22, 22); assert_eq!(centre, orig, "the centre is kept"); let edge = (0..24).filter(|i| copy(&s, 10 + i, 10).0 != copy(&s, 10 + i, 10).1).count(); assert!(edge > 12, "the top edge row is blended in ({edge} of 24 pixels differ)"); + let s = moved(1); + let (centre, orig) = copy(&s, 22, 22); + assert_ne!(centre, orig, "structure 1 re-synthesises the centre too"); // Color 10 fits a bright patch to a dark place; Color 0 keeps it bright. let mean = |color: u64| { let mut s = session(128, 64, 32, "rgb"); paint_layer(&mut s, |x, _| if x < 64 { [0.8, 0.8, 0.8, 1.0] } else { [0.2, 0.2, 0.2, 1.0] }); - s.execute("select.rect", json!({"x": 20, "y": 20, "width": 16, "height": 16})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).unwrap(); s.execute("paint.contentAwareMove", json!({"offset": [70, 0], "structure": 7, "color": color, "mode": "extend"})).unwrap(); - (94..102).map(|x| rgba(&s, x, 28)[0]).sum::() / 8.0 + // The copied middle (the edge band is re-synthesised from the dark surroundings). + (98..106).map(|x| rgba(&s, x, 32)[0]).sum::() / 8.0 }; - let (kept, fitted) = (mean(0), mean(10)); + let (kept, some, fitted) = (mean(0), mean(3), mean(10)); assert!((kept - 0.8).abs() < 1e-3, "color 0 keeps the colour: {kept}"); - assert!(fitted < 0.3, "color 10 adapts to the dark surroundings: {fitted}"); + // Photoshop shifts by up to about 0.04 per step of Color. + assert!((some - 0.68).abs() < 0.03, "color 3 adapts part of the way: {some}"); + assert!((fitted - 0.4).abs() < 0.03, "color 10 adapts most of the way: {fitted}"); } #[test] diff --git a/crates/ui-egui/src/patch_preview.rs b/crates/ui-egui/src/patch_preview.rs index 3876343cef..b669a60ed3 100644 --- a/crates/ui-egui/src/patch_preview.rs +++ b/crates/ui-egui/src/patch_preview.rs @@ -31,6 +31,10 @@ pub(crate) struct PatchPreview { /// The offset of the current Patch drag, if one is dragging the patch (not drawing a lasso). fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> { + // Content-Aware patches are background jobs: the canvas shows the result when it is done. + if app.ui.tool_options.patch_content_aware { + return None; + } let d = app.drag.as_ref().filter(|d| d.tool == Tool::Patch)?; let (start, mods) = (d.start, d.modifiers); let end = d.points.last().map_or(start, |p| [p[0], p[1]]); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 7b057e9e08..af1067f993 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -170,6 +170,23 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { return; } let preview = crate::patch_preview::take(app); + let o = &app.ui.tool_options; + if o.patch_content_aware { + // A background job (progress dialog, Esc cancels), like the Content-Aware Move. + let p = json!({ + "offset": off, + "contentAware": true, + "structure": o.patch_structure.round().clamp(1.0, 7.0), + "color": o.patch_color.round().clamp(0.0, 10.0), + "sampleAllLayers": o.sample_all_layers, + "target": crate::canvas::paint_target(app), + }); + if let Err(e) = app.run("paint.patch", p) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } let p = json!({"offset": off, "mode": app.ui.tool_options.patch_mode, "target": crate::canvas::paint_target(app)}); match app.run("paint.patch", p) { Ok(_) => crate::patch_preview::committed(app, preview, off), @@ -287,11 +304,25 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); } Tool::Patch => { + // Photoshop's options bar: Patch: Normal (Source / Destination) or Content-Aware + // (Structure, Color, Sample All Layers). opt(ui, tl!("Patch:")); - for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] { - let mut on = o.patch_mode == k; - if crate::widgets::checkbox(ui, &mut on, l).clicked() { - o.patch_mode = k.into(); + let kinds = [("normal".to_string(), tl!("Normal")), ("contentAware".to_string(), tl!("Content-Aware"))]; + let mut kind = if o.patch_content_aware { "contentAware" } else { "normal" }.to_string(); + crate::widgets::dropdown(ui, "patch-kind", &mut kind, &kinds, 120.0); + o.patch_content_aware = kind == "contentAware"; + if o.patch_content_aware { + opt(ui, tl!("Structure:")); + crate::widgets::value_field(ui, &mut o.patch_structure, 1.0..=7.0, "", 40.0); + opt(ui, tl!("Color:")); + crate::widgets::value_field(ui, &mut o.patch_color, 0.0..=10.0, "", 40.0); + crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + } else { + for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] { + let mut on = o.patch_mode == k; + if crate::widgets::checkbox(ui, &mut on, l).clicked() { + o.patch_mode = k.into(); + } } } crate::widgets::vline(ui, 22.0); @@ -584,6 +615,27 @@ mod tests { assert!(px[0] > 0.95 && px[1] > 0.95 && px[2] > 0.95, "blemish patched with the white background: {px:?}"); } + #[test] + fn patch_tool_content_aware_fills_from_where_it_is_dropped() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[68, 30], [74, 30]], "size": 6, "color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 60, "y": 20, "width": 24, "height": 20})).unwrap(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.patch_content_aware = true; + app.ui.tool_options.patch_structure = 5.0; + app.ui.tool_options.patch_color = 3.0; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 31.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!(id, "paint.patch"); + assert_eq!((p["contentAware"].as_bool(), p["structure"].as_f64(), p["color"].as_f64()), (Some(true), Some(5.0), Some(3.0))); + let px = active(&app).surface().unwrap().rgba(71, 30); + assert!(px[0] > 0.95 && px[1] > 0.95 && px[2] > 0.95, "blemish filled from the white background: {px:?}"); + } + #[test] fn content_aware_move_tool_lassoes_then_moves_or_extends() { for (mode, keeps) in [("move", false), ("extend", true)] { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e047125329..f21d2fdcc3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -459,6 +459,10 @@ pub struct ToolOptions { pub spot_type: String, /// Patch: source (repair the selection) | destination (repair where it is dragged). pub patch_mode: String, + /// Patch: Content-Aware instead of Normal, with its Structure 1..7 and Color 0..10. + pub patch_content_aware: bool, + pub patch_structure: f32, + pub patch_color: f32, /// Content-Aware Move: move | extend, Structure 1..7, Color 0..10. pub cam_mode: String, pub cam_structure: f32, @@ -615,6 +619,9 @@ impl Default for ToolOptions { clone_sample: "current".into(), spot_type: "contentAware".into(), patch_mode: "source".into(), + patch_content_aware: false, + patch_structure: 4.0, + patch_color: 0.0, cam_mode: "move".into(), cam_structure: 4.0, cam_color: 0.0, From b15b5a758775677b5e9ba29d566b814b0636569c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:06:43 +0700 Subject: [PATCH 0002/1452] feat(toolbar): drag from tool group into flyout to pick hidden tools (#2301) --- crates/ui-egui/src/panels.rs | 48 +++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e60db729df..b2bb91c73f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -76,6 +76,18 @@ fn visible_sections(hidden: &[String]) -> Vec)>> { sections } +/// Opening by dragging requires leaving the original button while it remains held (#2301). +fn tool_flyout_drag_open(dragged: bool, hovered: bool) -> bool { + dragged && !hovered +} + +/// A drag/hold started on the original tool button, not on a menu row. Select the +/// row under the primary pointer release rather than waiting for `ir.clicked()`, which +/// cannot fire when the pointer press occurred on the toolbar button (#2301). +fn tool_flyout_release(row: Rect, pointer: Option, released: bool, from_origin: bool) -> bool { + released && from_origin && pointer.is_some_and(|p| row.contains(p)) +} + pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; @@ -152,7 +164,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } - // Right-click or long-press opens the flyout (Photoshop). + // Right-click, long-press, or drag off the tool button opens its flyout (Photoshop). let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); if slot.len() > 1 && let Some(seconds) = held_for @@ -161,10 +173,12 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); } let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); - if slot.len() > 1 && (resp.secondary_clicked() || long_press) { + let drag_open = tool_flyout_drag_open(resp.dragged(), resp.hovered()); + if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { ui.data_mut(|d| { d.insert_temp(flyout_id, (key, resp.rect)); - if long_press { + if long_press || drag_open { + // Suppress the origin button's click when released after opening. d.insert_temp(held_id, key); } }); @@ -222,7 +236,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { t.text_dim, ); } - if ir.clicked() { + let release_from_origin = tool_flyout_release( + r, + ui.input(|i| i.pointer.hover_pos()), + ui.input(|i| i.pointer.primary_released()), + ui.data(|d| d.get_temp::(held_id)) == Some(key), + ); + if ir.clicked() || release_from_origin { app.ui.tool = item; ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } @@ -4080,3 +4100,23 @@ mod group_drag_selection_tests { assert!((uv.width() - 0.25).abs() < 1e-3 && (uv.height() - 1.0).abs() < 1e-3, "{uv:?}"); } } + +#[cfg(test)] +mod tool_flyout_gesture_tests { + use super::*; + + #[test] + fn dragging_off_group_button_opens_and_releasing_on_row_selects() { + assert!(!tool_flyout_drag_open(false, false)); + assert!(!tool_flyout_drag_open(true, true)); + assert!(tool_flyout_drag_open(true, false)); + + let row = Rect::from_min_size(pos2(50.0, 80.0), vec2(160.0, 26.0)); + let inside = Some(row.center()); + assert!(tool_flyout_release(row, inside, true, true)); + assert!(!tool_flyout_release(row, inside, false, true)); + assert!(!tool_flyout_release(row, inside, true, false)); + assert!(!tool_flyout_release(row, Some(row.right_bottom() + vec2(4.0, 4.0)), true, true)); + assert!(!tool_flyout_release(row, None, true, true)); + } +} From 4d7db6dabe7f0180d84958cf2d2edbd9d7ea8aee Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:05:40 +0200 Subject: [PATCH 0003/1452] Content-Aware Fill workspace, as Photoshop's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4) instead of the generic dialog: a full-window dialog like Liquify's. - Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its options bar. - Document view: the sampling area tinted green at 50 % and the fill area's marching ants. - Preview panel: the fill computed in the background after each change, opening framed on the sampling window. - Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color, Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All Layers), Fill Settings, Output Settings, and the reset buttons. - OK, Apply, Cancel. As Photoshop's: - The Sampling Brush subtracts in Auto and Rectangular and adds in Custom, which starts empty; Alt reverses. Its first size is a tenth of the window's side. Painting keeps the option, and added strokes may lie past the window. - The lasso edits the fill area (New, Add, Subtract, Intersect, Expand, Contract); the window and the Auto area follow. - Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes a stroke or an edit. Output opens on New Layer; settings are remembered. The workspace holds plain data. `edit.contentAwareFill` takes it as `samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both the preview and OK run one plan (`content_aware_plan`), so the preview is what gets written. New strings are translated in every catalog. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/algo/src/content_aware.rs | 105 ++ crates/engine/src/edit_menu_cmds.rs | 308 +++- crates/engine/src/edit_menu_cmds/tests.rs | 46 + crates/engine/src/retouch_cmds.rs | 4 +- crates/ui-egui/src/caf_ui.rs | 1650 +++++++++++++++++++++ crates/ui-egui/src/distort_ui.rs | 23 +- crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/de.tsv | 21 + crates/ui-egui/src/i18n/el.tsv | 16 + crates/ui-egui/src/i18n/es.tsv | 21 + crates/ui-egui/src/i18n/fr.tsv | 19 + crates/ui-egui/src/i18n/id.tsv | 19 + crates/ui-egui/src/i18n/it.tsv | 21 + crates/ui-egui/src/i18n/ja.tsv | 16 + crates/ui-egui/src/i18n/ko.tsv | 16 + crates/ui-egui/src/i18n/nl.tsv | 16 + crates/ui-egui/src/i18n/pl.tsv | 16 + crates/ui-egui/src/i18n/pt-br.tsv | 19 + crates/ui-egui/src/i18n/ru.tsv | 23 + crates/ui-egui/src/i18n/uk.tsv | 16 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs_ui.rs | 24 +- crates/ui-egui/src/shortcuts.rs | 6 +- 25 files changed, 2397 insertions(+), 72 deletions(-) create mode 100644 crates/ui-egui/src/caf_ui.rs diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index a33d5059a4..e96ff9b24e 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -466,6 +466,111 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3 .collect() } +/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of +/// diameter `size` along `points` (document px), adding to the sampling area or taking from it. +#[derive(Clone, Debug, PartialEq)] +pub struct SamplingStroke { + pub add: bool, + pub size: f64, + pub points: Vec<[f64; 2]>, +} + +impl SamplingStroke { + /// Bounds of the pixels the stroke covers. + pub fn bounds(&self) -> Rect { + let r = (self.size / 2.0).max(0.5); + self.points.iter().fold(Rect::EMPTY, |acc, p| { + let b = Rect::new((p[0] - r).floor() as i32, (p[1] - r).floor() as i32, (p[0] + r).ceil() as i32 + 1, (p[1] + r).ceil() as i32 + 1); + if acc.is_empty() { b } else { acc.union(&b) } + }) + } +} + +/// Squared distance from `p` to the segment `a`–`b`. +fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]); + qx * qx + qy * qy +} + +/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within +/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). +pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) { + let w = area.width() as usize; + for s in strokes { + let b = s.bounds().intersect(&area); + if b.is_empty() || s.points.is_empty() { + continue; + } + let r2 = (s.size / 2.0).max(0.5).powi(2); + let segs: Vec<([f64; 2], [f64; 2])> = + if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() }; + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let c = [f64::from(x) + 0.5, f64::from(y) + 0.5]; + if segs.iter().any(|(a, bb)| seg_dist2(c, *a, *bb) <= r2) { + mask[(y - area.y0) as usize * w + (x - area.x0) as usize] = s.add; + } + } + } + } +} + +/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule): +/// what a lasso outlines. +pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut out = vec![false; w * h]; + if pts.len() < 3 { + return out; + } + let mut xs: Vec = Vec::new(); + for row in 0..h { + let cy = f64::from(area.y0) + row as f64 + 0.5; + xs.clear(); + for i in 0..pts.len() { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + if (a[1] <= cy) != (b[1] <= cy) { + xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0])); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.as_chunks::<2>().0 { + // Pixel centres x + 0.5 within [pair0, pair1). + let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true); + } + } + out +} + +/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand +/// and Contract. +pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec { + if by == 0 || w == 0 || h == 0 { + return mask.to_vec(); + } + let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX)); + if by > 0 { + let d = crate::selection::edt(mask, w, h); + d.iter().map(|v| *v <= r).collect() + } else { + let outside: Vec = mask.iter().map(|v| !v).collect(); + let d = crate::selection::edt(&outside, w, h); + d.iter().map(|v| *v > r).collect() + } +} + +/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling +/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100). +pub fn default_brush_size(hole: Rect) -> f64 { + let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50))); + (0.4 * (w * h).sqrt()).round() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 2ecbe52fd7..3157a50141 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -110,6 +110,12 @@ fn always(_: &Session) -> std::result::Result<(), String> { fn pixel_layer(s: &Session) -> std::result::Result { let d = s.active().ok_or("no document open")?; let id = d.active_layer.ok_or("no active layer")?; + pixel_layer_id(s, id) +} + +/// `id` if it is an unlocked pixel layer of the active document. +fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result { + let d = s.active().ok_or("no document open")?; let l = d.doc.layer(id).ok_or("no active layer")?; if !matches!(l.content, LayerContent::Raster(_)) { return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name())); @@ -337,38 +343,186 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result { content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection") } -fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { - use photocraft_algo::content_aware::{FillOptions, auto_sampling, color_level, fill_with, rotation_level}; - let id = pixel_layer(s).map_err(EngineError::Other)?; - let st = s.active().ok_or(EngineError::NoDocument)?; - let doc = st.doc.clone(); - let sel = doc.selection.clone().ok_or_else(|| bad(cmd, "no selection"))?; - let hb = sel.content_bounds().intersect(&doc.bounds()); - if hb.is_empty() { +/// Everything one Content-Aware Fill needs, worked out from the document and the command's +/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows +/// is what OK and Apply write. +pub struct CafPlan { + pub layer: LayerId, + /// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom + /// area beyond it. + pub rect: Rect, + /// Photoshop's sampling window around the fill area (`content_aware::sampling_window`). + pub window: Rect, + pub fmt: PixelFormat, + /// The target's pixels over `rect`, and what is sampled (the same, or with Sample All Layers + /// the visible composite in the target's encoding). + pub layer_px: Vec, + pub img: Vec, + /// The fill area (selection coverage, after the workspace's lasso edits) over `rect`. + pub cover: Vec, + pub hole: Vec, + /// What may be copied from: the sampling option's area before the brush strokes, and after. + pub base: Vec, + pub source: Vec, + pub opts: photocraft_algo::content_aware::FillOptions, + pub output: String, +} + +/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points": +/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order. +#[derive(Clone, Debug, PartialEq)] +pub enum FillEdit { + Lasso { op: SelOp, points: Vec<[f64; 2]> }, + Grow(i32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelOp { + New, + Add, + Subtract, + Intersect, +} + +fn points_param(v: &Value, cmd: &str, what: &str) -> Result> { + let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?; + a.iter() + .map(|p| { + let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + match (x, y) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))), + } + }) + .collect() +} + +fn fill_edits(p: &Value, cmd: &str) -> Result> { + let Some(a) = p.get("fillEdits") else { return Ok(Vec::new()) }; + let a = a.as_array().ok_or_else(|| bad(cmd, "`fillEdits` must be an array"))?; + a.iter() + .map(|e| { + let op = e.get("op").and_then(Value::as_str).unwrap_or(""); + Ok(match op { + "expand" | "contract" => { + let by = e + .get("by") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (0.0..=1000.0).contains(v)) + .ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?; + FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) }) + } + "new" | "add" | "subtract" | "intersect" => FillEdit::Lasso { + op: match op { + "new" => SelOp::New, + "add" => SelOp::Add, + "subtract" => SelOp::Subtract, + _ => SelOp::Intersect, + }, + points: points_param(e, cmd, "a lasso edit")?, + }, + o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))), + }) + }) + .collect() +} + +fn sampling_strokes(p: &Value, cmd: &str) -> Result> { + let Some(a) = p.get("samplingStrokes") else { return Ok(Vec::new()) }; + let a = a.as_array().ok_or_else(|| bad(cmd, "`samplingStrokes` must be an array"))?; + a.iter() + .map(|e| { + let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") { + "add" => true, + "subtract" => false, + o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))), + }; + let size = e + .get("size") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (1.0..=5000.0).contains(v)) + .ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?; + Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke")? }) + }) + .collect() +} + +/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`. +pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result { + use photocraft_algo::content_aware::{FillOptions, auto_sampling, color_level, grow_mask, paint_strokes, polygon_mask, rotation_level}; + let canvas = doc.bounds(); + let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?; + let sb = sel.content_bounds().intersect(&canvas); + if sb.is_empty() { return Err(bad(cmd, "the selection is outside the canvas")); } + let edits = fill_edits(p, cmd)?; + let strokes = sampling_strokes(p, cmd)?; + // The fill area: the selection, then the lasso edits, over a rectangle that holds them all. + let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum(); + let reach = edits.iter().fold(sb, |r, e| match e { + FillEdit::Lasso { points, .. } => { + points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1))) + } + FillEdit::Grow(_) => r, + }); + let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas); + let (ew, eh) = crate::fill_cmds::window_size(er)?; + let mut ecover = vec![0.0f32; ew * eh]; + for (i, c) in ecover.iter_mut().enumerate() { + *c = sel.sample_channel(er.x0 + (i % ew) as i32, er.y0 + (i / ew) as i32, 0); + } + let mut ehole: Vec = ecover.iter().map(|c| *c > 0.0).collect(); + for e in &edits { + match e { + FillEdit::Lasso { op, points } => { + let m = polygon_mask(er, points); + for (h, v) in ehole.iter_mut().zip(&m) { + *h = match op { + SelOp::New => *v, + SelOp::Add => *h || *v, + SelOp::Subtract => *h && !*v, + SelOp::Intersect => *h && *v, + }; + } + } + FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by), + } + } + let hb = ehole.iter().enumerate().filter(|(_, h)| **h).fold(Rect::EMPTY, |r, (i, _)| { + let (x, y) = (er.x0 + (i % ew) as i32, er.y0 + (i / ew) as i32); + r.union(&Rect::new(x, y, x + 1, y + 1)) + }); + if hb.is_empty() { + return Err(bad(cmd, "the fill area is empty")); + } let sampling = str_or(p, "sampling", "auto").to_string(); - let custom_mask: Option = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned(); + let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v)); let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None }; // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` - // asks for a rectangle that far around the selection instead. + // asks for a rectangle that far around the fill area instead. let window = match sampling.as_str() { - "auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()), + "auto" => crate::fill_cmds::sampling_window(hb, canvas), "rectangular" => match int(p, "margin") { Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), - None => crate::fill_cmds::sampling_window(hb, doc.bounds()), + None => crate::fill_cmds::sampling_window(hb, canvas), }, "custom" => { - let r = match (custom_rect, custom_mask.as_ref()) { + let r = match (custom_rect, custom_mask) { (Some(r), _) => r, (None, Some(m)) => m.content_bounds(), - (None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h] or `channel` (alpha channel index or name)")), + // The workspace's Custom: only what the Sampling Brush adds. + (None, None) if strokes.iter().any(|s| s.add) => hb, + (None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")), }; r.union(&hb).inflate(4) } other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))), } - .intersect(&doc.bounds()); + .intersect(&canvas); + // Strokes that add to the sampling area may reach past the window. + let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas); let opts = FillOptions { color_adaptation: color_level(str_or(p, "colorAdaptation", "default")), rotations: rotation_level(str_or(p, "rotationAdaptation", "none")), @@ -384,48 +538,100 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & let fmt = surf.format(); let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). - let (w, h) = crate::fill_cmds::window_size(window)?; - // A background job when started with `Session::start` (#210): reading the window and the - // PatchMatch fill run on a worker against the document snapshot, cancellable per row band. + let (w, h) = crate::fill_cmds::window_size(rect)?; + let layer_px = surf.read_region(rect); + let img = if bool_or(p, "sampleAllLayers", false) { + crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data + } else { + layer_px.clone() + }; + let mut cover = vec![0.0f32; w * h]; + let mut hole = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (dx, dy) = (rect.x0 + x as i32, rect.y0 + y as i32); + if er.contains(dx, dy) { + let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize; + hole[y * w + x] = ehole[j]; + // Pixels the lasso added are filled outright; selected ones by their coverage. + cover[y * w + x] = if ehole[j] { if ecover[j] > 0.0 { ecover[j] } else { 1.0 } } else { 0.0 }; + } + } + } + // The sampling option's area: Auto keeps what looks like the surroundings within the window, + // Rectangular the whole window, Custom the given area (or nothing, for the brush to add to). + let mut base = vec![false; w * h]; + match sampling.as_str() { + "auto" | "rectangular" => { + let (ww, wh) = (window.width() as usize, window.height() as usize); + let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize); + let area: Vec = if sampling == "auto" { + let mut wimg = vec![0.0f32; ww * wh * n]; + let mut whole = vec![false; ww * wh]; + for y in 0..wh { + let s = ((oy + y) * w + ox) * n; + wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]); + whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]); + } + auto_sampling(ww, wh, n, &wimg, &whole) + } else { + vec![true; ww * wh] + }; + for y in 0..wh { + base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]); + } + } + _ => { + for y in 0..h { + for x in 0..w { + let (dx, dy) = (rect.x0 + x as i32, rect.y0 + y as i32); + base[y * w + x] = match (custom_rect, custom_mask) { + (Some(r), _) => r.contains(dx, dy), + (None, Some(m)) => m.sample_channel(dx, dy, 0) > 0.5, + _ => false, + }; + } + } + } + } + base.iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h); + let mut source = base.clone(); + paint_strokes(&mut source, rect, &strokes); + source.iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h); + Ok(CafPlan { layer: id, rect, window, fmt, layer_px, img, cover, hole, base, source, opts, output }) +} + +fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { + use photocraft_algo::content_aware::fill_with; + // The workspace names the layer it opened on: after an Apply to a new layer that one is + // active, but the fill still samples the original. + let id = match p.get("layer").and_then(Value::as_u64) { + Some(l) => pixel_layer_id(s, LayerId(l)), + None => pixel_layer(s), + } + .map_err(EngineError::Other)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let doc = st.doc.clone(); + // Check everything (and refuse absurd windows, #963) before the job starts. + let plan = content_aware_plan(&doc, id, p, cmd)?; + // A background job when started with `Session::start` (#210): the PatchMatch fill runs on a + // worker against the plan, cancellable per row band. crate::jobs::run( s, label, true, move |ctx| { ctx.progress(0.0, label); - let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = surf.read_region(window); - let mut cover = vec![0.0f32; w * h]; - let mut hole = vec![false; w * h]; - let mut source = vec![true; w * h]; - for y in 0..h { - if y % 64 == 0 { - ctx.check()?; - } - for x in 0..w { - let (dx, dy) = (window.x0 + x as i32, window.y0 + y as i32); - let i = y * w + x; - cover[i] = sel.sample_channel(dx, dy, 0); - hole[i] = cover[i] > 0.0; - if sampling == "custom" { - source[i] = match (custom_rect, custom_mask.as_ref()) { - (Some(r), _) => r.contains(dx, dy), - (None, Some(m)) => m.sample_channel(dx, dy, 0) > 0.5, - _ => true, - }; - } - } - } - ctx.check()?; - // Auto: only what looks like the selection's surroundings. - if sampling == "auto" { - source = auto_sampling(w, h, n, &img, &hole); - } - ctx.check()?; - let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_with(w, h, n, &img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; - Ok((img, cover, hole, filled)) + let (w, h) = (plan.rect.width() as usize, plan.rect.height() as usize); + let n = plan.fmt.channels(); + let filled = ctx + .stage(0.05, 1.0, label, |ctl| fill_with(w, h, n, &plan.img, &plan.hole, &plan.source, &plan.opts, ctl)) + .map_err(|_| EngineError::Cancelled)?; + Ok((plan, filled)) + }, + move |s, (plan, filled)| { + apply_content_aware_fill(s, label, plan.layer, &plan.output, plan.fmt, plan.rect, &plan.layer_px, &plan.cover, &plan.hole, &filled) }, - move |s, (img, cover, hole, filled)| apply_content_aware_fill(s, label, id, &output, fmt, window, &img, &cover, &hole, &filled), ) } @@ -983,7 +1189,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##, can_caf, content_aware_fill ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index a94a054103..707f9924b9 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -234,6 +234,52 @@ fn content_aware_fill_samples_photoshops_window() { } } +#[test] +fn content_aware_fill_takes_the_workspace_edits() { + // The plan the workspace previews: lasso edits change the fill area (and the window follows), + // sampling-brush strokes change what may be copied, also outside the window. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap(); + let st = s.active().unwrap(); + let (doc, id) = (st.doc.clone(), st.active_layer.unwrap()); + let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill"); + let count = |m: &[bool]| m.iter().filter(|v| **v).count(); + let base = plan(json!({"sampling": "rectangular"})).unwrap(); + assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500)); + assert_eq!(count(&base.source), 200 * 200 - 2500); + // A lasso square added beside the selection: a bigger fill area, a window around both. + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap(); + assert_eq!(count(&p.hole), 5000); + assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window); + // Expand by 2 grows the 50 px square to about 54 px (rounded corners). + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap(); + assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole)); + // Subtracting it all leaves nothing to fill. + assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err()); + // A subtracting stroke takes a band out of the sampling area; an adding one far away reaches + // past the window, and the plan's rectangle grows to hold it. + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap(); + assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60); + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap(); + assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350)); + assert!(count(&p.source) > count(&base.source) + 600); + // Custom from strokes alone; bad edits are errors. + let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap(); + assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source)); + for bad in [ + json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}), + json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}), + json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}), + json!({"fillEdits": [{"op": "expand", "by": -3}]}), + ] { + assert!(plan(bad.clone()).is_err(), "{bad}"); + } + // The command fills with the same plan; `layer` names the layer even when another is active. + let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 2800); +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index f8ac42f1e8..8db64a4b69 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -137,7 +137,7 @@ fn damage_json(r: Rect) -> Value { /// Which pixels Clone Stamp / Healing Brush sample. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SampleLayers { +pub(crate) enum SampleLayers { Current, CurrentAndBelow, All, @@ -154,7 +154,7 @@ fn sample_layers(p: &Value, cmd: &str) -> Result { /// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer /// and everything below it. -fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { +pub(crate) fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { let mut doc = pre.clone(); if which == SampleLayers::CurrentAndBelow && let Some(id) = id diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs new file mode 100644 index 0000000000..c30de11bc7 --- /dev/null +++ b/crates/ui-egui/src/caf_ui.rs @@ -0,0 +1,1650 @@ +//! Edit › Content-Aware Fill…: Photoshop's Content-Aware Fill workspace, a full-window dialog +//! like Liquify's. +//! +//! Measured on Photoshop 25.4 (black box) and laid out the same way: the tools on the left +//! (Sampling Brush, Lasso, Hand, Zoom) with their options bar above, the document in the middle +//! with the sampling area tinted (green, 50 %) and the fill area outlined, a Preview panel, and +//! the Content-Aware Fill panel (Sampling Area Overlay, Sampling Area Options, Fill Settings, +//! Output Settings) with OK, Apply and Cancel. +//! +//! * **Sampling Brush**: a hard round brush. In Auto and Rectangular it subtracts from the +//! sampling area, in Custom (which starts empty) it adds; Alt reverses. Painting keeps the +//! sampling option, and added strokes may reach past the sampling window. Its starting size is +//! a tenth of the window's side (20 px for a 50 px selection). +//! * **Lasso**: edits the fill area itself (New, Add, Subtract, Intersect; Expand and Contract by +//! a few pixels). The window and the Auto area follow the new fill area; brush strokes stay. +//! * **Preview**: the fill computed in the background after each change, opening framed on the +//! sampling window. +//! * **Apply** fills (one history step) and keeps the workspace open; **OK** fills and closes; +//! **Cancel** closes, keeping what was applied. Ctrl+Z undoes the last stroke or lasso edit. +//! +//! What the workspace holds is plain data (the sampling option, brush strokes, lasso edits and +//! fill settings); OK and Apply run `edit.contentAwareFill` with it, and the preview runs the same +//! plan ([`photocraft_engine::edit_menu_cmds::content_aware_plan`]), so the preview is exactly +//! what gets written. The settings are remembered for the next time (`prefs.dialogs`). + +use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; +use photocraft_algo::content_aware::SamplingStroke; +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::theme::Tokens; +use crate::widgets; + +/// Longest side of the document proxy shown in the view (px). +const PROXY_SIDE: usize = 1600; +const LEFT_W: f32 = 48.0; +const BAR_H: f32 = 36.0; +const PANEL_W: f32 = 300.0; +const PREVIEW_W: f32 = 380.0; +const UNDO_LIMIT: usize = 100; +/// Outlines with more edges than this are drawn at the proxy's resolution. +const MAX_OUTLINE_EDGES: usize = 200_000; +/// Photoshop's overlay colour as it shows (a 50 % tint of grey 200 reads 171, 204, 153). +const OVERLAY_COLOR: [u8; 3] = [142, 208, 106]; +/// The `prefs.dialogs` key under which the settings are remembered. +const REMEMBERED: &str = "edit.contentAwareFill.workspace"; +const CMD: &str = "edit.contentAwareFill"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CafTool { + Brush, + Lasso, + Hand, + Zoom, +} + +impl CafTool { + fn name(self) -> &'static str { + match self { + CafTool::Brush => "brush", + CafTool::Lasso => "lasso", + CafTool::Hand => "hand", + CafTool::Zoom => "zoom", + } + } + fn from_name(s: &str) -> Option { + Some(match s { + "brush" => CafTool::Brush, + "lasso" => CafTool::Lasso, + "hand" => CafTool::Hand, + "zoom" => CafTool::Zoom, + _ => return None, + }) + } +} + +/// The lasso's selection modes, in options-bar order. +const LASSO_OPS: [&str; 4] = ["new", "add", "subtract", "intersect"]; + +/// The panel's settings (remembered between uses). +#[derive(Clone, Debug, PartialEq)] +pub struct CafOpts { + pub show_overlay: bool, + /// Overlay opacity, 0..100. + pub opacity: f32, + pub color: [u8; 3], + /// The overlay marks what is left out (Indicates: Excluded Area) rather than the sampling area. + pub excluded: bool, + /// auto | rectangular | custom + pub sampling: String, + pub sample_all: bool, + /// default | none | high | veryHigh + pub color_adaptation: String, + /// none | low | medium | high | full + pub rotation: String, + pub scale: bool, + pub mirror: bool, + /// current | new | duplicate (Photoshop opens on New Layer) + pub output: String, +} + +impl Default for CafOpts { + fn default() -> Self { + CafOpts { + show_overlay: true, + opacity: 50.0, + color: OVERLAY_COLOR, + excluded: false, + sampling: "auto".into(), + sample_all: false, + color_adaptation: "default".into(), + rotation: "none".into(), + scale: false, + mirror: false, + output: "new".into(), + } + } +} + +impl CafOpts { + /// Applies the settings named in `v` (control channel and remembered keys); others stay. + pub fn apply(&mut self, v: &Value) -> Result<(), String> { + let flag = |k: &str| v.get(k).and_then(Value::as_bool); + let text = |k: &str| v.get(k).and_then(Value::as_str); + for (k, slot) in [ + ("showOverlay", &mut self.show_overlay), + ("excluded", &mut self.excluded), + ("sampleAllLayers", &mut self.sample_all), + ("scale", &mut self.scale), + ("mirror", &mut self.mirror), + ] { + if let Some(b) = flag(k) { + *slot = b; + } + } + if let Some(n) = v.get("opacity").and_then(Value::as_f64).filter(|n| n.is_finite()) { + self.opacity = (n as f32).clamp(0.0, 100.0); + } + if let Some(c) = v.get("color").and_then(Value::as_array).filter(|c| c.len() == 3) { + let ch = |i: usize| c[i].as_u64().map(|v| v.min(255) as u8); + if let (Some(r), Some(g), Some(b)) = (ch(0), ch(1), ch(2)) { + self.color = [r, g, b]; + } + } + let pick = |k: &str, allowed: &[&str]| -> Result, String> { + match text(k) { + None => Ok(None), + Some(s) if allowed.contains(&s) => Ok(Some(s.to_string())), + Some(s) => Err(format!("bad {k} `{s}` ({})", allowed.join("|"))), + } + }; + if let Some(s) = pick("sampling", &["auto", "rectangular", "custom"])? { + self.sampling = s; + } + if let Some(s) = pick("colorAdaptation", &["none", "default", "high", "veryHigh"])? { + self.color_adaptation = s; + } + if let Some(s) = pick("rotationAdaptation", &["none", "low", "medium", "high", "full"])? { + self.rotation = s; + } + if let Some(s) = pick("output", &["current", "new", "duplicate"])? { + self.output = s; + } + Ok(()) + } + + /// The settings as [`CafOpts::apply`] reads them. + pub fn to_json(&self) -> Value { + json!({ + "showOverlay": self.show_overlay, + "opacity": self.opacity, + "color": self.color, + "excluded": self.excluded, + "sampling": self.sampling, + "sampleAllLayers": self.sample_all, + "colorAdaptation": self.color_adaptation, + "rotationAdaptation": self.rotation, + "scale": self.scale, + "mirror": self.mirror, + "output": self.output, + }) + } +} + +/// One undoable step. +#[derive(Clone, Debug)] +enum Step { + Stroke(SamplingStroke), + Fill(Value), + /// A change of the sampling option (or a reset), with what it replaced. + State { + sampling: String, + strokes: Vec, + fill_edits: Vec, + }, +} + +fn stroke_json(s: &SamplingStroke) -> Value { + json!({"mode": if s.add { "add" } else { "subtract" }, "size": s.size, "points": s.points}) +} + +/// What the plan (without the brush strokes) says: the window, the fill area and the sampling +/// option's area, over `rect`. +struct Base { + window: Rect, + rect: Rect, + hole: Vec, + base: Vec, +} + +/// A computed preview: the filled rectangle as an image, or why it failed. +#[cfg(not(target_arch = "wasm32"))] +type PreviewResult = Result<(Rect, egui::ColorImage), String>; + +/// The preview being computed or shown. +#[derive(Default)] +struct Preview { + /// Bumped whenever the state changes; a result for an older generation is dropped. + want: u64, + #[cfg(not(target_arch = "wasm32"))] + running: Option<(u64, std::sync::mpsc::Receiver, photocraft_engine::jobs::JobCtx)>, + shown: u64, + /// A finished preview waiting to be uploaded (on the UI thread, which has the context). + pending_image: Option<(Rect, egui::ColorImage)>, + tex: Option<(TextureHandle, Rect)>, + /// Screen px per document px (0 = frame the window on the next frame) and the view centre. + zoom: f32, + center: [f64; 2], + error: Option, +} + +pub struct CafWorkspace { + pub layer: LayerId, + layer_name: String, + canvas: Rect, + /// The document revision the textures and the base were made from. + revision: u64, + pub opts: CafOpts, + pub tool: CafTool, + pub brush_add: bool, + pub brush_size: f64, + pub lasso_op: usize, + pub expand_by: f32, + pub strokes: Vec, + pub fill_edits: Vec, + undo: Vec, + redo: Vec, + cur: Option, + /// The lasso polygon being drawn and its selection mode. + lasso: Option<(usize, Vec<[f64; 2]>)>, + base: Base, + /// Fill-area outline edges (document px), or `None` when there are too many. + outline: Option>, + /// Document px per proxy px, and the proxy size. + k: usize, + pw: usize, + ph: usize, + /// Per proxy pixel: 0 not sampled, 1 sampled, 2 fill area. + state: Vec, + image_tex: Option, + overlay_tex: Option, + overlay_dirty: bool, + zoom: f32, + center: [f64; 2], + hover: Option<[f64; 2]>, + preview: Preview, + pub error: Option, +} + +/// The plan of the current state (without strokes when `strokes` is false). +fn plan_params(w: &CafWorkspace, strokes: bool) -> Value { + let o = &w.opts; + let mut p = json!({ + "sampling": o.sampling, + "sampleAllLayers": o.sample_all, + "colorAdaptation": o.color_adaptation, + "rotationAdaptation": o.rotation, + "scale": o.scale, + "mirror": o.mirror, + "output": o.output, + "layer": w.layer.0, + "fillEdits": w.fill_edits, + }); + if strokes && !w.strokes.is_empty() { + p["samplingStrokes"] = Value::Array(w.strokes.iter().map(stroke_json).collect()); + } + p +} + +impl CafWorkspace { + pub fn describe(&self) -> Value { + let sampled = self.state.iter().filter(|s| **s == 1).count(); + json!({ + "layer": self.layer.0, + "tool": self.tool.name(), + "brushSize": self.brush_size, + "brushMode": if self.brush_add { "add" } else { "subtract" }, + "lassoMode": LASSO_OPS[self.lasso_op], + "expandBy": self.expand_by, + "strokes": self.strokes.len() + usize::from(self.cur.is_some()), + "fillEdits": self.fill_edits.len(), + "undo": self.undo.len(), + "redo": self.redo.len(), + "window": [self.base.window.x0, self.base.window.y0, self.base.window.width(), self.base.window.height()], + "fillArea": self.base.hole.iter().filter(|h| **h).count(), + "sampledProxyPixels": sampled, + "proxyScale": self.k, + "settings": self.opts.to_json(), + "previewReady": self.preview.tex.is_some() && self.preview.shown == self.preview.want, + "previewError": self.preview.error, + "error": self.error, + }) + } + + /// The sampling state of document pixel `(x, y)` from the base and the strokes so far. + fn sampled_at(&self, x: f64, y: f64) -> bool { + let b = &self.base; + let (xi, yi) = (x.floor() as i32, y.floor() as i32); + if b.rect.contains(xi, yi) { + let i = (yi - b.rect.y0) as usize * b.rect.width() as usize + (xi - b.rect.x0) as usize; + if b.hole[i] { + return false; + } + } + let mut on = b.rect.contains(xi, yi) && b.base[(yi - b.rect.y0) as usize * b.rect.width() as usize + (xi - b.rect.x0) as usize]; + for s in self.strokes.iter().chain(self.cur.iter()) { + if covers(s, x, y) { + on = s.add; + } + } + on + } + + fn in_hole(&self, x: f64, y: f64) -> bool { + let b = &self.base; + let (xi, yi) = (x.floor() as i32, y.floor() as i32); + b.rect.contains(xi, yi) && b.hole[(yi - b.rect.y0) as usize * b.rect.width() as usize + (xi - b.rect.x0) as usize] + } + + /// Recompute the proxy's per-pixel state inside proxy rect `r` (x0, y0, x1, y1). + fn restate(&mut self, r: [usize; 4]) { + let k = self.k as f64; + for py in r[1]..r[3].min(self.ph) { + for px in r[0]..r[2].min(self.pw) { + let (x, y) = (f64::from(self.canvas.x0) + (px as f64 + 0.5) * k, f64::from(self.canvas.y0) + (py as f64 + 0.5) * k); + let v = if self.in_hole(x, y) { 2 } else { u8::from(self.sampled_at(x, y)) }; + self.state[py * self.pw + px] = v; + } + } + self.overlay_dirty = true; + } + + fn restate_all(&mut self) { + self.restate([0, 0, self.pw, self.ph]); + } + + /// The proxy pixels a stroke segment touches. + fn proxy_rect(&self, r: Rect) -> [usize; 4] { + let k = self.k as i32; + let c = self.canvas; + let f = |v: i32, o: i32| ((v - o).max(0) / k) as usize; + [f(r.x0, c.x0), f(r.y0, c.y0), f(r.x1, c.x0) + 2, f(r.y1, c.y0) + 2] + } + + /// The preview is out of date. + fn stale(&mut self) { + self.preview.want += 1; + } + + fn begin_stroke(&mut self, p: [f64; 2], alt: bool) { + let add = self.brush_add != alt; + let s = SamplingStroke { add, size: self.brush_size, points: vec![p] }; + let r = self.proxy_rect(s.bounds()); + self.cur = Some(s); + self.restate(r); + } + + fn extend_stroke(&mut self, p: [f64; 2]) { + let Some(s) = self.cur.as_mut() else { return }; + if s.points.last().is_some_and(|l| l[0] == p[0] && l[1] == p[1]) { + return; + } + let last = *s.points.last().unwrap_or(&p); + s.points.push(p); + let seg = SamplingStroke { add: s.add, size: s.size, points: vec![last, p] }; + let r = self.proxy_rect(seg.bounds()); + self.restate(r); + } + + fn end_stroke(&mut self) { + if let Some(s) = self.cur.take() { + self.strokes.push(s.clone()); + self.push(Step::Stroke(s)); + self.stale(); + } + } + + fn push(&mut self, s: Step) { + self.redo.clear(); + if self.undo.len() == UNDO_LIMIT { + self.undo.remove(0); + } + self.undo.push(s); + } +} + +/// Does stroke `s` cover document point `(x, y)` (a pixel centre)? +fn covers(s: &SamplingStroke, x: f64, y: f64) -> bool { + let r2 = (s.size / 2.0).max(0.5).powi(2); + let near = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + t * dx - x, a[1] + t * dy - y); + qx * qx + qy * qy <= r2 + }; + match s.points.as_slice() { + [] => false, + [p] => near(*p, *p), + pts => pts.windows(2).any(|w| near(w[0], w[1])), + } +} + +/// The plan without strokes: the window, fill area and the option's area. Custom starts empty +/// (the window and fill area are those of Rectangular). +fn compute_base(app: &PhotocraftApp, w: &CafWorkspace) -> Result { + let st = app.session.active().ok_or("no document")?; + let mut p = plan_params(w, false); + let custom = w.opts.sampling == "custom"; + if custom { + p["sampling"] = json!("rectangular"); + } + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan(&st.doc, w.layer, &p, CMD).map_err(|e| e.to_string())?; + let base = if custom { vec![false; plan.base.len()] } else { plan.base }; + Ok(Base { window: plan.window, rect: plan.rect, hole: plan.hole, base }) +} + +/// Unit edges between the fill area and the rest, in document px. +fn outline(b: &Base) -> Option> { + let (w, h) = (b.rect.width() as usize, b.rect.height() as usize); + let (ox, oy) = (b.rect.x0 as f32, b.rect.y0 as f32); + let at = |x: i64, y: i64| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && b.hole[y as usize * w + x as usize]; + let mut out = Vec::new(); + for y in 0..h as i64 { + for x in 0..w as i64 { + if !at(x, y) { + continue; + } + let (fx, fy) = (ox + x as f32, oy + y as f32); + if !at(x - 1, y) { + out.push([[fx, fy], [fx, fy + 1.0]]); + } + if !at(x + 1, y) { + out.push([[fx + 1.0, fy], [fx + 1.0, fy + 1.0]]); + } + if !at(x, y - 1) { + out.push([[fx, fy], [fx + 1.0, fy]]); + } + if !at(x, y + 1) { + out.push([[fx, fy + 1.0], [fx + 1.0, fy + 1.0]]); + } + if out.len() > MAX_OUTLINE_EDGES { + return None; + } + } + } + Some(out) +} + +/// Recompute the base after a change of the fill area, the sampling option or the document. +fn rebase(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_ref() else { return }; + let r = compute_base(app, w); + let Some(w) = app.distort.caf.as_mut() else { return }; + match r { + Ok(b) => { + w.outline = outline(&b); + w.base = b; + w.error = None; + } + Err(e) => w.error = Some(e), + } + w.restate_all(); + w.stale(); +} + +/// Opens the workspace on the active layer and its selection. +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + if !app.session.is_enabled(CMD) { + return Err("Content-Aware Fill needs a selection on a pixel layer".into()); + } + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no active layer")?; + let canvas = st.doc.bounds(); + let revision = st.revision; + let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let k = (canvas.width().max(canvas.height()) as usize).div_ceil(PROXY_SIDE).max(1); + let (pw, ph) = ((canvas.width() as usize).div_ceil(k), (canvas.height() as usize).div_ceil(k)); + let mut opts = CafOpts::default(); + if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { + let _ = opts.apply(saved); + } + let mut w = CafWorkspace { + layer, + layer_name: name, + canvas, + revision, + opts, + tool: CafTool::Brush, + brush_add: false, + brush_size: 20.0, + lasso_op: 0, + expand_by: 3.0, + strokes: Vec::new(), + fill_edits: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + cur: None, + lasso: None, + base: Base { window: Rect::EMPTY, rect: Rect::EMPTY, hole: Vec::new(), base: Vec::new() }, + outline: None, + k, + pw, + ph, + state: vec![0; pw * ph], + image_tex: None, + overlay_tex: None, + overlay_dirty: true, + zoom: 0.0, + center: [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0], + hover: None, + preview: Preview::default(), + error: None, + }; + w.brush_add = w.opts.sampling == "custom"; + let base = compute_base(app, &w)?; + w.brush_size = photocraft_algo::content_aware::default_brush_size(bounds(&base)); + w.outline = outline(&base); + w.base = base; + w.restate_all(); + w.stale(); + app.distort.caf = Some(w); + Ok(()) +} + +/// Bounds of the fill area. +fn bounds(b: &Base) -> Rect { + let w = b.rect.width() as usize; + b.hole.iter().enumerate().filter(|(_, h)| **h).fold(Rect::EMPTY, |r, (i, _)| { + let (x, y) = (b.rect.x0 + (i % w) as i32, b.rect.y0 + (i / w) as i32); + r.union(&Rect::new(x, y, x + 1, y + 1)) + }) +} + +fn remember(app: &mut PhotocraftApp, opts: &CafOpts) { + let v = opts.to_json(); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), v)); +} + +/// Apply: fill with the current state (one history step); the workspace stays open. +pub fn apply(app: &mut PhotocraftApp) -> Result { + let Some(w) = app.distort.caf.as_mut() else { return Err("Content-Aware Fill is not open".into()) }; + w.end_stroke(); + let p = plan_params(w, true); + let opts = w.opts.clone(); + remember(app, &opts); + let r = app.run(CMD, p); + if r.is_ok() { + refresh(app); + } + r +} + +/// OK: fill and close. +pub fn commit(app: &mut PhotocraftApp) { + let ok = apply(app); + if let Err(e) = ok { + app.ui.status = e; + app.ui.status_error = true; + } + close(app); +} + +/// Cancel (button, Esc): closes; what was applied stays. The settings are kept. +pub fn cancel(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.caf.as_ref() { + let opts = w.opts.clone(); + remember(app, &opts); + } + close(app); +} + +fn close(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.caf.take() { + cancel_preview(w); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn cancel_preview(w: CafWorkspace) { + if let Some((_, _, job)) = w.preview.running { + job.cancel(); + } +} + +#[cfg(target_arch = "wasm32")] +fn cancel_preview(_: CafWorkspace) {} + +/// The document changed under the workspace (an Apply): new textures, a new base. +fn refresh(app: &mut PhotocraftApp) { + let rev = app.session.active().map(|s| s.revision); + if let Some(w) = app.distort.caf.as_mut() { + w.image_tex = None; + w.revision = rev.unwrap_or(w.revision); + } + rebase(app); +} + +fn undo(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + let Some(step) = w.undo.pop() else { return }; + let rebuild = match &step { + Step::Stroke(_) => { + w.strokes.pop(); + w.redo.push(step); + w.restate_all(); + w.stale(); + false + } + Step::Fill(_) => { + w.fill_edits.pop(); + w.redo.push(step); + true + } + Step::State { sampling, strokes, fill_edits } => { + let now = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: w.fill_edits.clone() }; + w.opts.sampling = sampling.clone(); + w.strokes = strokes.clone(); + w.fill_edits = fill_edits.clone(); + w.brush_add = w.opts.sampling == "custom"; + w.redo.push(now); + true + } + }; + if rebuild { + rebase(app); + } +} + +fn redo(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + let Some(step) = w.redo.pop() else { return }; + let rebuild = match &step { + Step::Stroke(s) => { + w.strokes.push(s.clone()); + w.undo.push(step); + w.restate_all(); + w.stale(); + false + } + Step::Fill(e) => { + w.fill_edits.push(e.clone()); + w.undo.push(step); + true + } + Step::State { sampling, strokes, fill_edits } => { + let now = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: w.fill_edits.clone() }; + w.opts.sampling = sampling.clone(); + w.strokes = strokes.clone(); + w.fill_edits = fill_edits.clone(); + w.brush_add = w.opts.sampling == "custom"; + w.undo.push(now); + true + } + }; + if rebuild { + rebase(app); + } +} + +/// A lasso edit of the fill area (or Expand / Contract), undoable. +fn fill_edit(app: &mut PhotocraftApp, edit: Value) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + w.fill_edits.push(edit.clone()); + w.push(Step::Fill(edit)); + rebase(app); + // An edit that empties the fill area is refused (the plan says so): undo it. + if app.distort.caf.as_ref().is_some_and(|w| w.error.is_some()) { + let msg = app.distort.caf.as_ref().and_then(|w| w.error.clone()); + undo(app); + if let Some(w) = app.distort.caf.as_mut() { + w.redo.clear(); + w.error = msg; + } + } +} + +/// A new sampling option (Auto, Rectangular, Custom): the brush strokes start over and the brush +/// takes the option's mode (Custom adds, the others subtract). +fn set_sampling(app: &mut PhotocraftApp, sampling: &str) { + let Some(w) = app.distort.caf.as_mut() else { return }; + if w.opts.sampling == sampling && w.strokes.is_empty() { + return; + } + w.end_stroke(); + let prev = Step::State { sampling: w.opts.sampling.clone(), strokes: std::mem::take(&mut w.strokes), fill_edits: w.fill_edits.clone() }; + w.push(prev); + w.opts.sampling = sampling.to_string(); + w.brush_add = sampling == "custom"; + rebase(app); +} + +/// Reset buttons: the sampling options, the fill settings, or everything. +fn reset(app: &mut PhotocraftApp, what: &str) { + let Some(w) = app.distort.caf.as_mut() else { return }; + let d = CafOpts::default(); + match what { + "fill" => { + w.opts.color_adaptation = d.color_adaptation; + w.opts.rotation = d.rotation; + w.opts.scale = d.scale; + w.opts.mirror = d.mirror; + w.stale(); + } + "sampling" => { + w.opts.sample_all = false; + set_sampling(app, "auto"); + } + "lasso" => { + if !w.fill_edits.is_empty() { + let prev = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: std::mem::take(&mut w.fill_edits) }; + w.push(prev); + rebase(app); + } + } + _ => { + let prev = + Step::State { sampling: w.opts.sampling.clone(), strokes: std::mem::take(&mut w.strokes), fill_edits: std::mem::take(&mut w.fill_edits) }; + w.push(prev); + w.opts = CafOpts::default(); + w.brush_add = false; + rebase(app); + } + } +} + +/// Control channel: `edit.contentAwareFill {"ui": {...}}` while the workspace is open. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + let flag = |k: &str| ui.get(k).and_then(Value::as_bool) == Some(true); + if flag("cancel") { + cancel(app); + return Ok(json!({"cancelled": true})); + } + let before = app.distort.caf.as_ref().ok_or("Content-Aware Fill is not open")?.opts.clone(); + { + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + let mut o = w.opts.clone(); + o.apply(ui)?; + // The sampling option goes through `set_sampling` (it resets the strokes). + let sampling = std::mem::replace(&mut o.sampling, w.opts.sampling.clone()); + w.opts = o; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + w.tool = CafTool::from_name(t).ok_or(format!("bad tool `{t}` (brush|lasso|hand|zoom)"))?; + } + if let Some(n) = ui.get("brushSize").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.brush_size = n.clamp(1.0, 5000.0); + } + if let Some(m) = ui.get("brushMode").and_then(Value::as_str) { + w.brush_add = m == "add"; + } + if let Some(m) = ui.get("lassoMode").and_then(Value::as_str) { + w.lasso_op = LASSO_OPS.iter().position(|o| *o == m).ok_or(format!("bad lassoMode `{m}`"))?; + } + if let Some(n) = ui.get("expandBy").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.expand_by = (n as f32).clamp(1.0, 100.0); + } + if sampling != w.opts.sampling { + set_sampling(app, &sampling); + } + } + let after = app.distort.caf.as_ref().map(|w| w.opts.clone()); + if after.as_ref().is_some_and(|a| a.sample_all != before.sample_all) { + rebase(app); + } else if after.as_ref().is_some_and(|a| *a != before) + && let Some(w) = app.distort.caf.as_mut() + { + w.stale(); + w.restate_all(); + } + if let Some(s) = ui.get("stroke") { + let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + if let Some(n) = s.get("size").and_then(Value::as_f64) { + w.brush_size = n.clamp(1.0, 5000.0); + } + let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if let Some(first) = pts.first() { + w.begin_stroke(*first, alt); + for p in &pts[1..] { + w.extend_stroke(*p); + } + w.end_stroke(); + } + } + if let Some(l) = ui.get("lasso") { + let pts: Vec<[f64; 2]> = serde_json::from_value(l.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad lasso points: {e}"))?; + let op = match l.get("op").and_then(Value::as_str) { + Some(o) if LASSO_OPS.contains(&o) => o.to_string(), + Some(o) => return Err(format!("bad lasso op `{o}`")), + None => LASSO_OPS[app.distort.caf.as_ref().map_or(0, |w| w.lasso_op)].to_string(), + }; + fill_edit(app, json!({"op": op, "points": pts})); + } + for k in ["expand", "contract"] { + if let Some(n) = ui.get(k).and_then(Value::as_f64) { + fill_edit(app, json!({"op": k, "by": n.clamp(1.0, 100.0)})); + } + } + if let Some(r) = ui.get("reset").and_then(Value::as_str) { + reset(app, r); + } + if flag("undo") { + undo(app); + } + if flag("redo") { + redo(app); + } + if flag("apply") { + apply(app)?; + } + if flag("waitPreview") { + wait_preview(app)?; + } + // OK last, with the settings given alongside. + if flag("commit") { + let r = apply(app); + close(app); + return r.map(|v| json!({"committed": true, "result": v})); + } + Ok(app.distort.caf.as_ref().map(CafWorkspace::describe).unwrap_or(Value::Null)) +} + +/// Computes the preview now, on this thread (tests and the control channel). +fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + let (doc, layer, p, want) = { + let w = app.distort.caf.as_ref().ok_or("Content-Aware Fill is not open")?; + let st = app.session.active().ok_or("no document")?; + (st.doc.clone(), w.layer, plan_params(w, true), w.preview.want) + }; + let r = compute_preview(&doc, layer, &p, &photocraft_raster::Interrupt::NONE); + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + match r { + Ok((rect, img)) => { + w.preview.error = None; + w.preview.shown = want; + w.preview.tex = None; + w.preview.pending_image = Some((rect, img)); + Ok(()) + } + Err(e) => { + w.preview.error = Some(e.clone()); + Err(e) + } + } +} + +/// The filled rectangle as an image (what Apply would write there). +fn compute_preview(doc: &photocraft_doc::Document, layer: LayerId, p: &Value, ctl: &photocraft_raster::Interrupt) -> Result<(Rect, egui::ColorImage), String> { + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan(doc, layer, p, CMD).map_err(|e| e.to_string())?; + let (w, h) = (plan.rect.width() as usize, plan.rect.height() as usize); + let n = plan.fmt.channels(); + let filled = + photocraft_algo::content_aware::fill_with(w, h, n, &plan.img, &plan.hole, &plan.source, &plan.opts, ctl).map_err(|_| "cancelled".to_string())?; + let mut px = Vec::with_capacity(w * h); + for i in 0..w * h { + let src = if plan.hole[i] { &filled[i * n..(i + 1) * n] } else { &plan.layer_px[i * n..(i + 1) * n] }; + let mut c = photocraft_raster::to_rgba(&plan.fmt, src); + if plan.hole[i] { + // A partly selected pixel shows the fill by its coverage. + let k = plan.cover[i]; + let under = photocraft_raster::to_rgba(&plan.fmt, &plan.layer_px[i * n..(i + 1) * n]); + for j in 0..4 { + c[j] = under[j] + (c[j] - under[j]) * k; + } + } + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + px.push(Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))); + } + Ok((plan.rect, egui::ColorImage::new([w, h], px))) +} + +/// Starts a background preview when the shown one is out of date (and the pointer is up), and +/// collects a finished one. +#[cfg(not(target_arch = "wasm32"))] +fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context, dragging: bool) { + let doc = app.session.active().map(|s| s.doc.clone()); + let Some(w) = app.distort.caf.as_mut() else { return }; + let pv = &mut w.preview; + if let Some((generation, rx, _)) = &pv.running { + match rx.try_recv() { + Ok(r) => { + let generation = *generation; + pv.running = None; + if generation == pv.want { + match r { + Ok(img) => { + pv.pending_image = Some(img); + pv.shown = generation; + pv.error = None; + } + Err(e) if e != "cancelled" => pv.error = Some(e), + Err(_) => {} + } + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Superseded: stop it, a new one starts when it has gone. + if *generation != pv.want + && let Some((_, _, job)) = &pv.running + { + job.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + return; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => pv.running = None, + } + } + if pv.running.is_some() || pv.shown == pv.want || dragging { + return; + } + let Some(doc) = doc else { return }; + let (layer, p, generation) = (w.layer, plan_params(w, true), w.preview.want); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let job = photocraft_engine::jobs::JobCtx::new(); + let work = job.clone(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("caf-preview".into()).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work.stage(0.0, 1.0, "", |ctl| compute_preview(&doc, layer, &p, ctl)))) + .unwrap_or_else(|_| Err("the preview failed".into())); + let _ = tx.send(r); + repaint.request_repaint(); + }); + match spawned { + Ok(_) => w.preview.running = Some((generation, rx, job)), + Err(e) => w.preview.error = Some(format!("could not start the preview: {e}")), + } +} + +/// The web build has no threads: the preview is computed when the pointer comes up. +#[cfg(target_arch = "wasm32")] +fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context, dragging: bool) { + let stale = app.distort.caf.as_ref().is_some_and(|w| w.preview.shown != w.preview.want); + if stale && !dragging { + let _ = wait_preview(app); + } +} + +/// Pointer in document coordinates (from the view or the control channel). +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + let Some(w) = app.distort.caf.as_mut() else { return }; + match (w.tool, ev) { + (CafTool::Brush, ToolEvent::Down { x, y, .. }) => w.begin_stroke([x, y], mods.alt), + (CafTool::Brush, ToolEvent::Move { x, y, .. }) => { + w.hover = Some([x, y]); + w.extend_stroke([x, y]); + } + (CafTool::Brush, ToolEvent::Up { x, y }) => { + w.extend_stroke([x, y]); + w.end_stroke(); + } + (CafTool::Lasso, ToolEvent::Down { x, y, .. }) => { + // Shift adds, Alt subtracts, both intersect (as Photoshop's selection tools). + let op = match (mods.shift, mods.alt) { + (true, true) => 3, + (true, false) => 1, + (false, true) => 2, + _ => w.lasso_op, + }; + w.lasso = Some((op, vec![[x, y]])); + } + (CafTool::Lasso, ToolEvent::Move { x, y, .. }) => { + if let Some((_, pts)) = &mut w.lasso + && pts.last().is_none_or(|l| l[0] != x || l[1] != y) + { + pts.push([x, y]); + } + } + (CafTool::Lasso, ToolEvent::Up { .. }) => { + if let Some((op, pts)) = w.lasso.take() + && pts.len() >= 3 + { + fill_edit(app, json!({"op": LASSO_OPS[op], "points": pts})); + } + } + (CafTool::Zoom, ToolEvent::Down { x, y, .. }) => { + let f = if mods.alt { 0.5 } else { 2.0 }; + w.zoom = (w.zoom * f).clamp(0.01, 32.0); + w.center = [x, y]; + } + _ => {} + } +} + +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + redo(app); + return; + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + undo(app); + return; + } + let Some(w) = app.distort.caf.as_mut() else { return }; + // Ctrl+H hides the overlay (Hide Extras). + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { + w.opts.show_overlay = !w.opts.show_overlay; + w.overlay_dirty = true; + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { + w.brush_size = (w.brush_size * 0.9).max(1.0).round(); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { + w.brush_size = (w.brush_size * 1.1).min(5000.0).round().max(w.brush_size + 1.0); + } + for (k, t) in [(egui::Key::B, CafTool::Brush), (egui::Key::L, CafTool::Lasso), (egui::Key::H, CafTool::Hand), (egui::Key::Z, CafTool::Zoom)] { + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)) { + w.tool = t; + } + } +} + +fn tool_icon(t: CafTool) -> &'static str { + match t { + CafTool::Brush => "brush", + CafTool::Lasso => "lasso", + CafTool::Hand => "hand", + CafTool::Zoom => "zoom-in", + } +} + +fn tool_tip(t: CafTool) -> String { + match t { + CafTool::Brush => format!("{} (B)", tl!("Sampling Brush Tool")), + CafTool::Lasso => format!("{} (L)", tl!("Lasso Tool")), + CafTool::Hand => format!("{} (H)", tl!("Hand Tool")), + CafTool::Zoom => format!("{} (Z)", tl!("Zoom Tool")), + } +} + +/// A view: screen px per document px and the document point at the centre of `area`. +#[derive(Clone, Copy)] +struct View { + area: ERect, + zoom: f32, + center: [f64; 2], +} + +impl View { + fn to_screen(self, p: [f64; 2]) -> Pos2 { + let c = self.area.center(); + pos2(c.x + ((p[0] - self.center[0]) as f32) * self.zoom, c.y + ((p[1] - self.center[1]) as f32) * self.zoom) + } + fn to_doc(self, s: Pos2) -> [f64; 2] { + let c = self.area.center(); + [self.center[0] + f64::from((s.x - c.x) / self.zoom), self.center[1] + f64::from((s.y - c.y) / self.zoom)] + } + fn rect(self, r: Rect) -> ERect { + ERect::from_min_max(self.to_screen([f64::from(r.x0), f64::from(r.y0)]), self.to_screen([f64::from(r.x1), f64::from(r.y1)])) + } +} + +/// Upload what changed: the document proxy, the overlay, a finished preview. +fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { + let need_image = app.distort.caf.as_ref().is_some_and(|w| w.image_tex.is_none()); + if need_image { + let img = app.distort.caf.as_ref().and_then(|w| { + let st = app.session.active()?; + let surf = st.doc.layer(w.layer)?.surface()?; + Some(crate::distort_ui::surface_image(surf, w.canvas, PROXY_SIDE)) + }); + if let (Some(w), Some(img)) = (app.distort.caf.as_mut(), img) { + w.image_tex = Some(ctx.load_texture("caf-image", img, egui::TextureOptions::LINEAR)); + } + } + let Some(w) = app.distort.caf.as_mut() else { return }; + if w.overlay_dirty || w.overlay_tex.is_none() { + w.overlay_dirty = false; + let [r, g, b] = w.opts.color; + let a = (w.opts.opacity / 100.0 * 255.0).round() as u8; + let tint = Color32::from_rgba_unmultiplied(r, g, b, a); + let px: Vec = w + .state + .iter() + .map(|s| match (*s, w.opts.show_overlay, w.opts.excluded) { + (_, false, _) | (2, _, _) => Color32::TRANSPARENT, + (1, true, false) | (0, true, true) => tint, + _ => Color32::TRANSPARENT, + }) + .collect(); + let img = egui::ColorImage::new([w.pw, w.ph], px); + match &mut w.overlay_tex { + Some(t) => t.set(img, egui::TextureOptions::NEAREST), + None => w.overlay_tex = Some(ctx.load_texture("caf-overlay", img, egui::TextureOptions::NEAREST)), + } + } + if let Some((rect, img)) = w.preview.pending_image.take() { + w.preview.tex = Some((ctx.load_texture("caf-preview", img, egui::TextureOptions::LINEAR), rect)); + } +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + // An Apply (or anything else) changed the document: refresh. + let rev = app.session.active().map(|s| s.revision); + if app.distort.caf.as_ref().is_some_and(|w| Some(w.revision) != rev) { + refresh(app); + } + upload(app, ctx); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut action: Option<&str> = None; + let mut events: Vec = Vec::new(); + let mut mods = egui::Modifiers::NONE; + let mut new_sampling: Option<&str> = None; + let mut fill_btn: Option = None; + let mut reset_what: Option<&str> = None; + let mut rebase_needed = false; + let mut dragging = false; + egui::Area::new(egui::Id::new("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let Some(w) = app.distort.caf.as_mut() else { return }; + let before = w.opts.clone(); + let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); + let painter = ui.painter().clone(); + painter.rect_filled(full, 0.0, t.chrome); + // Title strip. + let title = ERect::from_min_size(full.min, vec2(full.width(), 30.0)); + painter.rect_filled(title, 0.0, t.dock); + painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator)); + let pct = if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 }; + painter.text( + title.center(), + Align2::CENTER_CENTER, + format!("{} ({}, {:.0}%)", tl!("Content-Aware Fill…").trim_end_matches('…'), w.layer_name, pct), + FontId::proportional(13.0), + t.text, + ); + // Options bar. + let bar = ERect::from_min_size(pos2(full.left(), title.bottom()), vec2(full.width(), BAR_H)); + painter.rect_filled(bar, 0.0, t.dock); + painter.line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.separator)); + let mut bui = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(LEFT_W + 8.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + match w.tool { + CafTool::Brush => { + if crate::icons::button(&mut bui, "plus", 26.0, w.brush_add, tl!("Add")).clicked() { + w.brush_add = true; + } + if crate::icons::button(&mut bui, "minus", 26.0, !w.brush_add, tl!("Subtract")).clicked() { + w.brush_add = false; + } + bui.add_space(8.0); + bui.label(tl!("Size:")); + let mut size = w.brush_size as f32; + if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + w.brush_size = f64::from(size); + } + } + CafTool::Lasso => { + // The selection tools' mode buttons (the lasso here edits the fill area). + let tips = [ + tl!("New selection").to_string(), + crate::i18n::fmt(tl!("Add to selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift"))]), + crate::i18n::fmt(tl!("Subtract from selection ({key})"), &[("key", &crate::shortcuts::pretty("Alt"))]), + crate::i18n::fmt(tl!("Intersect with selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift+Alt"))]), + ]; + for (i, (icon, tip)) in ["square", "plus", "minus", "squares-subtract"].into_iter().zip(tips.iter()).enumerate() { + if crate::icons::button(&mut bui, icon, 26.0, w.lasso_op == i, tip).clicked() { + w.lasso_op = i; + } + } + bui.add_space(8.0); + if widgets::secondary_button(&mut bui, tl!("Expand"), 70.0).clicked() { + fill_btn = Some(json!({"op": "expand", "by": w.expand_by})); + } + if widgets::secondary_button(&mut bui, tl!("Contract"), 70.0).clicked() { + fill_btn = Some(json!({"op": "contract", "by": w.expand_by})); + } + widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0); + if crate::icons::button(&mut bui, "undo-2", 26.0, false, tl!("Reset")).clicked() { + reset_what = Some("lasso"); + } + } + _ => {} + } + let body = ERect::from_min_max(pos2(full.left(), bar.bottom()), full.max); + // Left tool strip. + let left = ERect::from_min_size(pos2(full.left(), title.bottom()), vec2(LEFT_W, full.height() - title.height())); + painter.rect_filled(left, 0.0, t.dock); + painter.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0)))); + strip.spacing_mut().item_spacing.y = 4.0; + for tool in [CafTool::Brush, CafTool::Lasso, CafTool::Hand, CafTool::Zoom] { + if crate::icons::button(&mut strip, tool_icon(tool), 34.0, w.tool == tool, &tool_tip(tool)).clicked() { + w.tool = tool; + } + } + // Right: the Content-Aware Fill panel. + let right = ERect::from_min_size(pos2(body.right() - PANEL_W, body.top()), vec2(PANEL_W, body.height())); + painter.rect_filled(right, 0.0, t.dock); + painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); + let mut props = + ui.new_child(egui::UiBuilder::new().max_rect(ERect::from_min_max(right.min + vec2(14.0, 12.0), pos2(right.right() - 14.0, right.bottom() - 60.0)))); + egui::ScrollArea::vertical().id_salt("caf-props").show(&mut props, |ui| { + ui.spacing_mut().item_spacing.y = 6.0; + egui::CollapsingHeader::new(tl!("Sampling Area Overlay")).id_salt("caf-overlay").default_open(false).show(ui, |ui| { + widgets::checkbox(ui, &mut w.opts.show_overlay, tl!("Show Sampling Area")); + widgets::slider_row(ui, tl!("Opacity"), &mut w.opts.opacity, 0.0..=100.0, "%", None); + ui.horizontal(|ui| { + ui.label(tl!("Color")); + widgets::color_edit_button_srgb(ui, &mut w.opts.color); + ui.add_space(8.0); + ui.label(tl!("Indicates")); + widgets::dropdown(ui, "caf-indicates", &mut w.opts.excluded, &[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))], 120.0); + }); + }); + widgets::hairline(ui); + ui.horizontal(|ui| { + widgets::section_label(ui, tl!("Sampling Area Options")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 22.0, false, tl!("Reset")).clicked() { + reset_what = Some("sampling"); + } + }); + }); + ui.horizontal(|ui| { + for (k, l) in [("auto", tl!("Auto")), ("rectangular", tl!("Rectangular")), ("custom", tl!("Custom"))] { + if ui.selectable_label(w.opts.sampling == k, l).clicked() && w.opts.sampling != k { + new_sampling = Some(k); + } + } + }); + if widgets::checkbox(ui, &mut w.opts.sample_all, tl!("Sample All Layers")).changed() { + rebase_needed = true; + } + widgets::hairline(ui); + ui.horizontal(|ui| { + widgets::section_label(ui, tl!("Fill Settings")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 22.0, false, tl!("Reset")).clicked() { + reset_what = Some("fill"); + } + }); + }); + ui.horizontal(|ui| { + ui.label(tl!("Color Adaptation")); + let o = [ + ("none".to_string(), tl!("None")), + ("default".to_string(), tl!("Default")), + ("high".to_string(), tl!("High")), + ("veryHigh".to_string(), tl!("Very High")), + ]; + widgets::dropdown(ui, "caf-color", &mut w.opts.color_adaptation, &o, 110.0); + }); + ui.horizontal(|ui| { + ui.label(tl!("Rotation Adaptation")); + let o = [ + ("none".to_string(), tl!("None")), + ("low".to_string(), tl!("Low")), + ("medium".to_string(), tl!("Medium")), + ("high".to_string(), tl!("High")), + ("full".to_string(), tl!("Full")), + ]; + widgets::dropdown(ui, "caf-rotation", &mut w.opts.rotation, &o, 110.0); + }); + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut w.opts.scale, tl!("Scale")); + widgets::checkbox(ui, &mut w.opts.mirror, tl!("Mirror")); + }); + widgets::hairline(ui); + widgets::section_label(ui, tl!("Output Settings")); + ui.horizontal(|ui| { + ui.label(tl!("Output To")); + let o = + [("current".to_string(), tl!("Current Layer")), ("new".to_string(), tl!("New Layer")), ("duplicate".to_string(), tl!("Duplicate Layer"))]; + widgets::dropdown(ui, "caf-output", &mut w.opts.output, &o, 140.0); + }); + if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { + ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); + } + }); + // Footer: reset all, OK, Apply, Cancel. + let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 48.0), vec2(PANEL_W - 20.0, 32.0)); + let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); + if let Some(role) = widgets::dialog_buttons( + &mut fb, + &[ + widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 70.0), + widgets::DialogButton::new(widgets::ButtonRole::Apply, tl!("Apply"), 70.0), + widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 64.0), + ], + ) { + action = Some(match role { + widgets::ButtonRole::Default => "ok", + widgets::ButtonRole::Cancel => "cancel", + _ => "apply", + }); + } + if crate::icons::button(&mut fb, "undo-2", 24.0, false, tl!("Reset")).clicked() { + reset_what = Some("all"); + } + // The Preview panel. + let pv_rect = ERect::from_min_size(pos2(right.left() - PREVIEW_W, body.top()), vec2(PREVIEW_W, body.height())); + painter.rect_filled(pv_rect, 0.0, t.dock); + painter.line_segment([pv_rect.left_top(), pv_rect.left_bottom()], Stroke::new(1.0, t.separator)); + painter.text(pv_rect.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, tl!("Preview"), FontId::proportional(12.0), t.text); + let pv_area = ERect::from_min_max(pv_rect.min + vec2(0.0, 30.0), pv_rect.max - vec2(0.0, 44.0)); + painter.rect_filled(pv_area, 0.0, t.canvas); + if w.preview.zoom <= 0.0 { + // Opens framed on the sampling window, as Photoshop's. + let win = w.base.window; + if !win.is_empty() { + w.preview.zoom = (pv_area.width() / win.width().max(win.height()) as f32).clamp(0.01, 32.0); + w.preview.center = [f64::from(win.x0 + win.x1) / 2.0, f64::from(win.y0 + win.y1) / 2.0]; + } + } + let pv = View { area: pv_area, zoom: w.preview.zoom.max(0.01), center: w.preview.center }; + let pclip = painter.with_clip_rect(pv_area); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let img_rect = pv.rect(w.canvas); + widgets::checker(&pclip, img_rect.intersect(pv_area), 8.0); + if let Some(tex) = &w.image_tex { + let pr = ERect::from_min_size(img_rect.min, vec2((w.pw * w.k) as f32 * pv.zoom, (w.ph * w.k) as f32 * pv.zoom)); + pclip.image(tex.id(), pr, uv, Color32::WHITE); + } + if let Some((tex, r)) = &w.preview.tex { + pclip.image(tex.id(), pv.rect(*r), uv, Color32::WHITE); + } + if w.preview.shown != w.preview.want { + // Being recomputed: a spinner in the corner. + let sp = ERect::from_min_size(pv_area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + let presp = ui.interact(pv_area, egui::Id::new("caf-preview-view"), Sense::click_and_drag()); + if presp.dragged() { + let d = presp.drag_delta(); + w.preview.center = [w.preview.center[0] - f64::from(d.x / pv.zoom), w.preview.center[1] - f64::from(d.y / pv.zoom)]; + } + if presp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 { + w.preview.zoom = (w.preview.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + } + } + let zrow = ERect::from_min_size(pos2(pv_rect.left() + 10.0, pv_rect.bottom() - 38.0), vec2(PREVIEW_W - 20.0, 30.0)); + let mut zr = ui.new_child(egui::UiBuilder::new().max_rect(zrow).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut pct = (w.preview.zoom * 100.0).round(); + if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).changed() { + w.preview.zoom = pct / 100.0; + } + let mut lz = pct.max(1.0).ln(); + if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).changed() { + w.preview.zoom = lz.exp() / 100.0; + } + // The document view. + let area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(pv_rect.left(), body.bottom())); + painter.rect_filled(area, 0.0, t.canvas); + let (cw, chh) = (w.canvas.width() as f32, w.canvas.height() as f32); + if w.zoom <= 0.0 { + w.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / chh).clamp(0.01, 4.0); + } + let resp = ui.interact(area, egui::Id::new("caf-view"), Sense::click_and_drag()); + if resp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 + && let Some(hp) = resp.hover_pos() + { + let v = View { area, zoom: w.zoom, center: w.center }; + let before = v.to_doc(hp); + w.zoom = (w.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + let after = View { area, zoom: w.zoom, center: w.center }.to_doc(hp); + w.center = [w.center[0] + before[0] - after[0], w.center[1] + before[1] - after[1]]; + } + } + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + widgets::checker(&clip, img.intersect(area), 8.0); + let pr = ERect::from_min_size(img.min, vec2((w.pw * w.k) as f32 * v.zoom, (w.ph * w.k) as f32 * v.zoom)); + if let Some(tex) = &w.image_tex { + clip.image(tex.id(), pr, uv, Color32::WHITE); + } + if let Some(tex) = &w.overlay_tex { + clip.image(tex.id(), pr, uv, Color32::WHITE); + } + // The fill area's outline (marching ants: black dashes on white). + let phase = (ui.input(|i| i.time) * 8.0) as usize; + match &w.outline { + Some(edges) => { + for (i, e) in edges.iter().enumerate() { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + let _ = i; + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + } + None => { + let b = v.rect(bounds(&w.base)); + clip.rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + if let Some((_, pts)) = &w.lasso + && pts.len() > 1 + { + let line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + if let Some(hp) = resp.hover_pos() { + if w.tool == CafTool::Brush { + let r = w.brush_size as f32 * 0.5 * v.zoom; + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + } + w.hover = Some(v.to_doc(hp)); + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); + // Mouse → tools (Space, the middle button or the Hand pan). + let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == CafTool::Hand; + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if let Some(pp) = resp.interact_pointer_pos() { + let q = v.to_doc(pp); + mods = ui.input(|i| i.modifiers); + let active = w.cur.is_some() || w.lasso.is_some(); + if resp.drag_started() || (resp.is_pointer_button_down_on() && !active) { + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } else if resp.dragged() || resp.is_pointer_button_down_on() { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } + dragging = true; + } + if (resp.drag_stopped() || resp.clicked()) && !pan { + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + dragging = false; + } + // Settings changed in the panel: the overlay and the preview follow. + if w.opts != before { + w.overlay_dirty = true; + w.stale(); + } + }); + if let Some(s) = new_sampling { + set_sampling(app, s); + } + if let Some(r) = reset_what { + reset(app, r); + } + if let Some(e) = fill_btn { + fill_edit(app, e); + } + if rebase_needed { + rebase(app); + } + for ev in events { + pointer(app, ev, mods); + } + drive_preview(app, ctx, dragging); + match action { + Some("ok") => commit(app), + Some("cancel") => cancel(app), + Some("apply") => { + if let Err(e) = apply(app) { + app.ui.status = e; + app.ui.status_error = true; + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured layer (a checker of two colours over a gradient) with a 20 px square selected. + fn app_with_selection() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 200, "height": 160, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + let s = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..160 { + for x in 0..200 { + let v = ((x / 6 + y / 6) % 2) as f32 * 0.5 + x as f32 / 400.0; + s.write_pixel(x, y, &[v, 0.3, 1.0 - v, 1.0]); + } + } + // A red blemish to remove. + for y in 72..88 { + for x in 92..108 { + s.write_pixel(x, y, &[1.0, 0.0, 0.0, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 90, "y": 70, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + app + } + + fn px(app: &PhotocraftApp, layer: LayerId, x: i32, y: i32) -> Vec { + app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().pixel(x, y) + } + + fn ws(app: &PhotocraftApp) -> &CafWorkspace { + app.distort.caf.as_ref().unwrap() + } + + /// Photoshop opens the workspace with the Sampling Brush subtracting at a tenth of the + /// window's side, Auto sampling, output to a new layer. + #[test] + fn opens_like_photoshop() { + let mut app = app_with_selection(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + open(&mut app).unwrap(); + let d = ws(&app).describe(); + assert_eq!(d["window"], json!([0, 0, 200, 160]), "a 200 px window, clipped to the 200 × 160 canvas"); + assert_eq!((d["tool"].as_str(), d["brushMode"].as_str(), d["brushSize"].as_f64()), (Some("brush"), Some("subtract"), Some(20.0))); + assert_eq!((d["settings"]["sampling"].as_str(), d["settings"]["output"].as_str()), (Some("auto"), Some("new"))); + assert_eq!(d["fillArea"], json!(400)); + assert!(d["sampledProxyPixels"].as_u64().unwrap() > 1000); + } + + #[test] + fn brush_strokes_change_the_sampling_area_and_undo() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + let all = ws(&app).describe()["sampledProxyPixels"].as_u64().unwrap(); + assert_eq!(all, 200 * 160 - 400, "Rectangular: the whole window but the fill area"); + // Subtracting (the default): a 10 px stroke takes about 10 × 60 px away. + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 20.0], [80.0, 20.0]], "size": 10}})).unwrap(); + let less = d["sampledProxyPixels"].as_u64().unwrap(); + assert!((all - less).abs_diff(10 * 60 + 79) < 120, "{all} → {less}"); + // Undo brings it back; redo takes it again. + assert_eq!(control(&mut app, &json!({"undo": true})).unwrap()["sampledProxyPixels"].as_u64(), Some(all)); + assert_eq!(control(&mut app, &json!({"redo": true})).unwrap()["sampledProxyPixels"].as_u64(), Some(less)); + // Custom starts empty and the brush adds; Alt reverses it. + let d = control(&mut app, &json!({"sampling": "custom"})).unwrap(); + assert_eq!((d["sampledProxyPixels"].as_u64(), d["brushMode"].as_str(), d["strokes"].as_u64()), (Some(0), Some("add"), Some(0))); + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 120.0], [60.0, 120.0]], "size": 8}})).unwrap(); + assert!(d["sampledProxyPixels"].as_u64().unwrap() > 300); + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 120.0], [60.0, 120.0]], "size": 8, "alt": true}})).unwrap(); + assert_eq!(d["sampledProxyPixels"].as_u64(), Some(0)); + // Undoing the change of option restores Rectangular with its stroke. + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"undo": true})).unwrap(); + let d = control(&mut app, &json!({"undo": true})).unwrap(); + assert_eq!((d["settings"]["sampling"].as_str(), d["strokes"].as_u64(), d["brushMode"].as_str()), (Some("rectangular"), Some(1), Some("subtract"))); + } + + #[test] + fn the_lasso_edits_the_fill_area_and_the_window_follows() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let d = control(&mut app, &json!({"lasso": {"op": "add", "points": [[130.0, 70.0], [150.0, 70.0], [150.0, 90.0], [130.0, 90.0]]}})).unwrap(); + assert_eq!(d["fillArea"], json!(800)); + let d = control(&mut app, &json!({"expand": 2})).unwrap(); + assert!(d["fillArea"].as_u64().unwrap() > 900); + let d = control(&mut app, &json!({"undo": true, "lassoMode": "subtract"})).unwrap(); + assert_eq!((d["fillArea"].as_u64(), d["lassoMode"].as_str()), (Some(800), Some("subtract"))); + // Subtracting everything is refused: the fill area can't be empty. + let d = control(&mut app, &json!({"lasso": {"points": [[0.0, 0.0], [200.0, 0.0], [200.0, 160.0], [0.0, 160.0]]}})).unwrap(); + assert_eq!(d["fillArea"], json!(800)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("empty")), "{d}"); + // The reset button goes back to the selection. + let d = control(&mut app, &json!({"reset": "lasso"})).unwrap(); + assert_eq!(d["fillArea"], json!(400)); + } + + #[test] + fn apply_fills_and_stays_open_ok_closes_cancel_keeps_what_was_applied() { + let mut app = app_with_selection(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + open(&mut app).unwrap(); + // The preview is what Apply writes. + control(&mut app, &json!({"output": "current", "colorAdaptation": "none", "waitPreview": true})).unwrap(); + let (rect, img) = ws(&app).preview.pending_image.clone().unwrap(); + control(&mut app, &json!({"apply": true})).unwrap(); + assert!(app.distort.caf.is_some(), "Apply keeps the workspace open"); + let after = px(&app, layer, 100, 80); + assert!(after[0] < 0.9 || after[1] > 0.1, "the blemish is filled: {after:?}"); + let i = ((80 - rect.y0) * rect.width() as i32 + (100 - rect.x0)) as usize; + let shown = img.pixels[i]; + assert!( + (f32::from(shown.r()) / 255.0 - after[0]).abs() < 0.01 && (f32::from(shown.b()) / 255.0 - after[2]).abs() < 0.01, + "preview {shown:?} vs {after:?}" + ); + let steps = app.session.active().unwrap().history.past_len(); + cancel(&mut app); + assert!(app.distort.caf.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "Cancel keeps the applied fill"); + // OK with New Layer: a new layer holds the fill; the original stays. + app.session.execute("edit.undo", json!({})).unwrap(); + open(&mut app).unwrap(); + control(&mut app, &json!({"output": "new", "commit": true})).unwrap(); + assert!(app.distort.caf.is_none()); + assert_eq!(px(&app, layer, 100, 80)[0], 1.0, "the original layer keeps its blemish"); + let top = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(top, layer); + assert!(px(&app, top, 100, 80)[3] > 0.99, "the new layer holds the fill"); + } + + #[test] + fn settings_are_remembered_and_the_menu_opens_the_workspace() { + let ctx = egui::Context::default(); + let mut app = app_with_selection(); + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap(); + assert!(r["contentAwareFill"]["window"].is_array(), "{r}"); + let set = json!({"opacity": 30, "color": [200, 40, 40], "excluded": true, "colorAdaptation": "high", "rotationAdaptation": "low", "mirror": true, "output": "duplicate"}); + crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": set})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": {"cancel": true}})).unwrap(); + open(&mut app).unwrap(); + let mut want = CafOpts::default(); + want.apply(&set).unwrap(); + assert_eq!(ws(&app).opts, want); + // Bad values are errors; the engine command with params still runs directly. + assert!(control(&mut app, &json!({"sampling": "everywhere"})).is_err()); + assert!(control(&mut app, &json!({"tool": "pencil"})).is_err()); + cancel(&mut app); + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + assert_eq!(r["filled"], json!(400)); + assert!(app.distort.caf.is_none()); + } +} diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index b3bda3a7f5..295876ac59 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -21,6 +21,8 @@ use crate::canvas::{ToolEvent, ViewXform}; #[derive(Default)] pub struct Distort { pub liquify: Option, + /// Edit › Content-Aware Fill… (caf_ui). + pub caf: Option, pub puppet: Option, pub perspective: Option, /// Filter Gallery dialog (gallery_ui). @@ -29,13 +31,14 @@ pub struct Distort { impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() + self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() } /// Short description for `ui.inspect`. pub fn describe(&self) -> Value { json!({ "liquify": self.liquify.as_ref().map(|d| d.describe()), + "contentAwareFill": self.caf.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), "gallery": self.gallery.as_ref().map(|g| g.describe()), @@ -106,6 +109,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "filter.filterGallery" if ui.is_some() && app.distort.gallery.is_some() => Some(crate::gallery_ui::control(app, ui.unwrap_or(&Value::Null))), "filter.liquify" if empty => Some(crate::liquify_ui::open(app, ctx).map(|_| json!({"liquify": app.distort.describe()["liquify"]}))), "filter.liquify" if ui.is_some() && app.distort.liquify.is_some() => Some(crate::liquify_ui::control(app, ui.unwrap_or(&Value::Null))), + "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } @@ -124,6 +128,10 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val /// Pointer events (document coordinates) while a mode is active. True when consumed. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { + if app.distort.caf.is_some() { + crate::caf_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.is_some() { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -153,6 +161,14 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.caf.is_some() { + if enter(ctx) { + crate::caf_ui::commit(app); + } else if esc(ctx) { + crate::caf_ui::cancel(app); + } + return true; + } if app.distort.liquify.is_some() { if enter(ctx) { crate::liquify_ui::commit(app); @@ -205,8 +221,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { false } -/// Full-window dialogs (Liquify, Filter Gallery). +/// Full-window dialogs (Liquify, Content-Aware Fill, Filter Gallery). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + if app.distort.caf.is_some() { + crate::caf_ui::show(app, ctx); + } if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d15b3c27ac..2c63ec20fe 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2293,3 +2293,24 @@ fontWeight Italic Kurzíva Cycle Orientation Přepnout orientaci Set the overlay options for the Crop tool Nastavit volby překryvu pro nástroj Oříznutí Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+klik převzalo pracovní prostředí, které pomocí Alt+tažení přesouvá okna. Nastavte jeho klávesu pro přesun oken na Super, nebo zamkněte Alt v nabídce Okno › Modifikační klávesy. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Nástroj Štětec vzorkování + Sampling Area Overlay Překrytí oblasti vzorkování + Show Sampling Area Zobrazit oblast vzorkování + Indicates Označuje + Sampling Area Oblast vzorkování + Excluded Area Vyloučená oblast + Sampling Area Options Volby oblasti vzorkování + Rectangular Obdélníková + Fill Settings Nastavení výplně + Rotation Adaptation Přizpůsobení otočení + Very High Velmi vysoká + High Vysoká + Low Nízká + Full Úplná + Mirror Zrcadlit + Output Settings Nastavení výstupu + Output To Výstup do + New Layer Nová vrstva + Duplicate Layer Duplikovat vrstvu + Contract Zúžit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6e05025b50..9e5e2d36f6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2331,3 +2331,24 @@ fontWeight Italic Kursiv Cycle Orientation Ausrichtung wechseln Set the overlay options for the Crop tool Überlagerungsoptionen für das Freistellungswerkzeug festlegen Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Der Alt-Klick ging an die Arbeitsumgebung, die Fenster mit Alt-Ziehen verschiebt. Stelle dort die Taste zum Verschieben von Fenstern auf Super oder raste Alt unter Fenster › Modifikatortasten ein. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Aufnahmepinsel-Werkzeug + Sampling Area Overlay Überlagerung des Aufnahmebereichs + Show Sampling Area Aufnahmebereich einblenden + Indicates Kennzeichnet + Sampling Area Aufnahmebereich + Excluded Area Ausgeschlossener Bereich + Sampling Area Options Optionen für Aufnahmebereich + Rectangular Rechteckig + Fill Settings Füllungseinstellungen + Rotation Adaptation Drehungsanpassung + Very High Sehr hoch + High Hoch + Low Niedrig + Full Voll + Mirror Spiegeln + Output Settings Ausgabeeinstellungen + Output To Ausgabe in + New Layer Neue Ebene + Duplicate Layer Ebene duplizieren + Contract Verkleinern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2ba8fcf1e3..4a50144984 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3402,3 +3402,19 @@ fontWeight Thin Πολύ λεπτά Cycle Orientation Εναλλαγή προσανατολισμού Set the overlay options for the Crop tool Ορισμός επιλογών επικάλυψης για το εργαλείο Περικοπή Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Το Alt-κλικ πήγε στο περιβάλλον εργασίας, που μετακινεί παράθυρα με Alt-σύρσιμο. Ορίστε το πλήκτρο μετακίνησης παραθύρων του σε Super ή κλειδώστε το Alt στο Παράθυρο › Πλήκτρα τροποποίησης. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Εργαλείο πινέλου δειγματοληψίας + Sampling Area Overlay Επικάλυψη περιοχής δειγματοληψίας + Show Sampling Area Εμφάνιση περιοχής δειγματοληψίας + Indicates Υποδεικνύει + Sampling Area Περιοχή δειγματοληψίας + Excluded Area Εξαιρούμενη περιοχή + Sampling Area Options Επιλογές περιοχής δειγματοληψίας + Rectangular Ορθογώνια + Fill Settings Ρυθμίσεις γεμίσματος + Rotation Adaptation Προσαρμογή περιστροφής + Very High Πολύ υψηλή + Full Πλήρης + Mirror Κατοπτρισμός + Output Settings Ρυθμίσεις εξόδου + Output To Έξοδος σε diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d47e2c87b..1ecc83c53c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2361,3 +2361,24 @@ fontWeight Italic Cursiva Cycle Orientation Alternar orientación Set the overlay options for the Crop tool Definir las opciones de superposición de la herramienta Recortar Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. El Alt+clic lo recibió el escritorio, que mueve ventanas con Alt+arrastrar. Cambia su tecla para mover ventanas a Super o bloquea Alt en Ventana › Teclas modificadoras. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Herramienta Pincel de muestreo + Sampling Area Overlay Superposición del área de muestreo + Show Sampling Area Mostrar área de muestreo + Indicates Indica + Sampling Area Área de muestreo + Excluded Area Área excluida + Sampling Area Options Opciones del área de muestreo + Rectangular Rectangular + Fill Settings Ajustes de relleno + Rotation Adaptation Adaptación de rotación + Very High Muy alta + High Alta + Low Baja + Full Completa + Mirror Reflejar + Output Settings Ajustes de salida + Output To Salida a + New Layer Capa nueva + Duplicate Layer Duplicar capa + Contract Contraer diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d726d11edf..3257ac2bee 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2714,3 +2714,22 @@ fontWeight Italic Italique Cycle Orientation Changer d’orientation Set the overlay options for the Crop tool Définir les options de superposition de l’outil Recadrage Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-clic a été intercepté par le bureau, qui déplace les fenêtres avec Alt-glisser. Réglez sa touche de déplacement des fenêtres sur Super, ou verrouillez Alt dans Fenêtre › Touches de modification. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Outil Pinceau d’échantillonnage + Sampling Area Overlay Incrustation de la zone d’échantillonnage + Show Sampling Area Afficher la zone d’échantillonnage + Indicates Indique + Sampling Area Zone d’échantillonnage + Excluded Area Zone exclue + Sampling Area Options Options de la zone d’échantillonnage + Rectangular Rectangulaire + Fill Settings Paramètres de remplissage + Rotation Adaptation Adaptation de la rotation + Very High Très élevée + Full Totale + Mirror Miroir + Output Settings Paramètres de sortie + Output To Sortie vers + New Layer Nouveau calque + Duplicate Layer Dupliquer le calque + Contract Contracter diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6a710ec17c..ef1db6a19d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2701,3 +2701,22 @@ fontWeight Italic Miring Cycle Orientation Ganti Orientasi Set the overlay options for the Crop tool Atur opsi overlay untuk alat Crop Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik diambil oleh desktop, yang memindahkan jendela dengan Alt-seret. Atur tombol pemindah jendelanya ke Super, atau kunci Alt di Jendela › Tombol Pengubah. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Alat Kuas Pengambilan Sampel + Sampling Area Overlay Hamparan Area Pengambilan Sampel + Show Sampling Area Tampilkan Area Pengambilan Sampel + Indicates Menandai + Sampling Area Area Pengambilan Sampel + Excluded Area Area yang Dikecualikan + Sampling Area Options Opsi Area Pengambilan Sampel + Rectangular Persegi Panjang + Fill Settings Pengaturan Isian + Rotation Adaptation Adaptasi Rotasi + Very High Sangat Tinggi + Full Penuh + Mirror Cermin + Output Settings Pengaturan Keluaran + Output To Keluaran Ke + New Layer Layer Baru + Duplicate Layer Duplikat Layer + Contract Ciutkan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0013199736..876356b0a5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2362,3 +2362,24 @@ fontWeight Italic Corsivo Cycle Orientation Cambia orientamento Set the overlay options for the Crop tool Imposta le opzioni di sovrapposizione dello strumento Taglierina Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-click è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Strumento Pennello di campionamento + Sampling Area Overlay Sovrapposizione area di campionamento + Show Sampling Area Mostra area di campionamento + Indicates Indica + Sampling Area Area di campionamento + Excluded Area Area esclusa + Sampling Area Options Opzioni area di campionamento + Rectangular Rettangolare + Fill Settings Impostazioni di riempimento + Rotation Adaptation Adattamento rotazione + Very High Molto alta + High Alta + Low Bassa + Full Completa + Mirror Rifletti + Output Settings Impostazioni di output + Output To Output in + New Layer Nuovo livello + Duplicate Layer Duplica livello + Contract Contrai diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 75266fd882..8da1396115 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3317,3 +3317,19 @@ fontWeight Italic 斜体 Cycle Orientation 方向を切り替え Set the overlay options for the Crop tool 切り抜きツールのオーバーレイオプションを設定 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+クリックはデスクトップに取られました(Alt+ドラッグでウィンドウを移動する設定です)。デスクトップのウィンドウ移動キーを Super に変更するか、ウィンドウ › 修飾キーで Alt を固定してください。 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool サンプリングブラシツール + Sampling Area Overlay サンプリング領域のオーバーレイ + Show Sampling Area サンプリング領域を表示 + Indicates 表示対象 + Sampling Area サンプリング領域 + Excluded Area 除外された領域 + Sampling Area Options サンプリング領域オプション + Rectangular 長方形 + Fill Settings 塗りつぶし設定 + Rotation Adaptation 回転への適応 + Very High 非常に高い + Full フル + Mirror ミラー + Output Settings 出力設定 + Output To 出力先 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5bd32ca914..294c3c098a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2871,3 +2871,19 @@ fontWeight Italic 기울임 Cycle Orientation 방향 순환 Set the overlay options for the Crop tool 자르기 도구의 오버레이 옵션 설정 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 클릭이 데스크톱으로 전달되었습니다. 데스크톱이 Alt 드래그로 창을 이동합니다. 창 이동 키를 Super로 바꾸거나 창 › 보조 키에서 Alt를 고정하세요. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 샘플링 브러시 도구 + Sampling Area Overlay 샘플링 영역 오버레이 + Show Sampling Area 샘플링 영역 표시 + Indicates 표시 대상 + Sampling Area 샘플링 영역 + Excluded Area 제외 영역 + Sampling Area Options 샘플링 영역 옵션 + Rectangular 사각형 + Fill Settings 채우기 설정 + Rotation Adaptation 회전 적응 + Very High 매우 높음 + Full 전체 + Mirror 미러 + Output Settings 출력 설정 + Output To 출력 위치 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 120b8a599e..aae4b74670 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3477,3 +3477,19 @@ cameraRaw Whites Witten Cycle Orientation Oriëntatie wisselen Set the overlay options for the Crop tool Overlay-opties voor het gereedschap Uitsnijden instellen Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. De Alt-klik ging naar het bureaublad, dat vensters verplaatst met Alt-slepen. Stel daar de toets voor het verplaatsen van vensters in op Super, of vergrendel Alt via Venster › Wijzigingstoetsen. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Gereedschap Monsterpenseel + Sampling Area Overlay Overlay van monstergebied + Show Sampling Area Monstergebied tonen + Indicates Geeft aan + Sampling Area Monstergebied + Excluded Area Uitgesloten gebied + Sampling Area Options Opties voor monstergebied + Rectangular Rechthoekig + Fill Settings Vulinstellingen + Rotation Adaptation Rotatieaanpassing + Very High Zeer hoog + Full Volledig + Mirror Spiegelen + Output Settings Uitvoerinstellingen + Output To Uitvoer naar diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f480c9a603..14ff02ccbc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3315,3 +3315,19 @@ cameraRaw Roughness Chropowatość Cycle Orientation Przełącz orientację Set the overlay options for the Crop tool Ustaw opcje nakładki narzędzia Kadrowanie Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+kliknięcie przejęło środowisko pulpitu, które przesuwa okna przez Alt+przeciąganie. Ustaw w nim klawisz przesuwania okien na Super lub zablokuj Alt w Okno › Klawisze modyfikujące. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Narzędzie Pędzel próbkowania + Sampling Area Overlay Nakładka obszaru próbkowania + Show Sampling Area Pokaż obszar próbkowania + Indicates Wskazuje + Sampling Area Obszar próbkowania + Excluded Area Obszar wykluczony + Sampling Area Options Opcje obszaru próbkowania + Rectangular Prostokątny + Fill Settings Ustawienia wypełnienia + Rotation Adaptation Dopasowanie obrotu + Very High Bardzo wysoka + Full Pełna + Mirror Odbicie lustrzane + Output Settings Ustawienia wyjścia + Output To Wyjście do diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e6a6e27db2..754085df30 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2721,3 +2721,22 @@ fontWeight Italic Itálico Cycle Orientation Alternar orientação Set the overlay options for the Crop tool Definir as opções de sobreposição da ferramenta Corte demarcado Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. O Alt-clique foi para o desktop, que move janelas com Alt-arrastar. Mude a tecla de mover janelas dele para Super ou trave o Alt em Janela › Teclas modificadoras. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Ferramenta Pincel de amostragem + Sampling Area Overlay Sobreposição da área de amostragem + Show Sampling Area Mostrar área de amostragem + Indicates Indica + Sampling Area Área de amostragem + Excluded Area Área excluída + Sampling Area Options Opções da área de amostragem + Rectangular Retangular + Fill Settings Configurações de preenchimento + Rotation Adaptation Adaptação de rotação + Very High Muito alta + Full Completa + Mirror Espelhar + Output Settings Configurações de saída + Output To Saída para + New Layer Nova camada + Duplicate Layer Duplicar camada + Contract Contrair diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f6baef259..c4fb9f5c1e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2567,3 +2567,26 @@ fontWeight Italic Курсив Cycle Orientation Сменить ориентацию Set the overlay options for the Crop tool Задать параметры перекрытия для инструмента «Рамка» Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+щелчок перехватило окружение рабочего стола: оно перемещает окна через Alt+перетаскивание. Назначьте для перемещения окон клавишу Super или зафиксируйте Alt в меню Окно › Клавиши-модификаторы. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Кисть области выборки + Sampling Area Overlay Наложение области выборки + Show Sampling Area Показать область выборки + Indicates Обозначает + Sampling Area Область выборки + Excluded Area Исключённая область + Sampling Area Options Параметры области выборки + Rectangular Прямоугольная + Fill Settings Параметры заливки + Rotation Adaptation Адаптация поворота + Very High Очень высокая + Full Полная + Mirror Отражение + Output Settings Параметры вывода + Output To Вывод в + New Layer Новый слой + Duplicate Layer Дубликат слоя + Contract Сжать + New selection Новое выделение + Add to selection ({key}) Добавить к выделению ({key}) + Subtract from selection ({key}) Вычесть из выделения ({key}) + Intersect with selection ({key}) Пересечь с выделением ({key}) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0e3ed88ef1..3a76bc3aeb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3402,3 +3402,19 @@ cameraRaw Masking Маскування Cycle Orientation Змінити орієнтацію Set the overlay options for the Crop tool Задати параметри накладання для інструмента «Рамка» Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-клік перехопило робоче середовище, яке переміщує вікна через Alt-перетягування. Призначте для переміщення вікон клавішу Super або зафіксуйте Alt у меню Вікно › Клавіші-модифікатори. +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Пензель області вибірки + Sampling Area Overlay Накладання області вибірки + Show Sampling Area Показати область вибірки + Indicates Позначає + Sampling Area Область вибірки + Excluded Area Виключена область + Sampling Area Options Параметри області вибірки + Rectangular Прямокутна + Fill Settings Параметри заливки + Rotation Adaptation Адаптація повороту + Very High Дуже висока + Full Повна + Mirror Віддзеркалення + Output Settings Параметри виводу + Output To Вивести в diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9c04f9fb90..2f348406b2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2301,3 +2301,24 @@ fontWeight Italic 斜体 Cycle Orientation 循环切换取向 Set the overlay options for the Crop tool 设置裁剪工具的叠加选项 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 单击被桌面环境截获了,它用 Alt 拖动来移动窗口。请把桌面的窗口移动键改为 Super,或在 窗口 › 修饰键 中锁定 Alt。 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 取样画笔工具 + Sampling Area Overlay 取样区域叠加 + Show Sampling Area 显示取样区域 + Indicates 指示 + Sampling Area 取样区域 + Excluded Area 排除区域 + Sampling Area Options 取样区域选项 + Rectangular 矩形 + Fill Settings 填充设置 + Rotation Adaptation 旋转适应 + Very High 非常高 + High 高 + Low 低 + Full 完全 + Mirror 镜像 + Output Settings 输出设置 + Output To 输出到 + New Layer 新建图层 + Duplicate Layer 复制图层 + Contract 收缩 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7c6e52bf5e..133f024baf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2270,3 +2270,24 @@ fontWeight Italic 斜體 Cycle Orientation 循環切換方向 Set the overlay options for the Crop tool 設定裁切工具的覆蓋選項 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 按一下被桌面環境攔截了,它用 Alt 拖曳來移動視窗。請把桌面的視窗移動鍵改為 Super,或在 視窗 › 輔助鍵 中鎖定 Alt。 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 取樣筆刷工具 + Sampling Area Overlay 取樣區域覆蓋 + Show Sampling Area 顯示取樣區域 + Indicates 指示 + Sampling Area 取樣區域 + Excluded Area 排除區域 + Sampling Area Options 取樣區域選項 + Rectangular 矩形 + Fill Settings 填色設定 + Rotation Adaptation 旋轉調整 + Very High 非常高 + High 高 + Low 低 + Full 完全 + Mirror 鏡像 + Output Settings 輸出設定 + Output To 輸出至 + New Layer 新增圖層 + Duplicate Layer 複製圖層 + Contract 縮減 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1da4fb55f9..81d1242895 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +pub mod caf_ui; mod camera_raw_detail_ui; mod camera_raw_preview_ui; mod camera_raw_scope_ui; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index ce41558143..b0f2a7eedc 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -614,19 +614,9 @@ pub fn invoke(app: &mut PhotocraftApp, _ctx: &egui::Context, id: &str, params: & } dialog(d) } - "edit.contentAwareFill" => { - if !app.session.is_enabled(id) { - return Some(Err("Content-Aware Fill needs a selection on a pixel layer".into())); - } - let d = crate::filter_dialog::open(app, id); - if let Some(dm) = d.and_then(|d| app.ui.dialog_mut(d)) { - dm.fields.insert("__preview".into(), json!(true)); - for k in ["margin", "seed", "channel"] { - dm.fields.remove(k); - } - } - dialog(d) - } + // Photoshop's Content-Aware Fill workspace (caf_ui); the engine command with params + // still runs directly. + "edit.contentAwareFill" => Some(crate::caf_ui::open(app).map(|_| json!({"contentAwareFill": app.distort.describe()["contentAwareFill"]}))), "edit.contentAwareScale" => { let st = app.session.active()?; let b = match &st.doc.selection { @@ -2285,9 +2275,11 @@ mod tests { assert!(note.starts_with("Monitor profile in use: sRGB") && note.contains("fallback"), "{note}"); assert!(crate::menus::invoke(&mut app, &ctx, "edit.fade", json!({})).is_err()); app.run("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 8})).unwrap(); - let d = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap()["dialog"].as_u64().unwrap(); - assert_eq!(app.ui.dialogs.iter().find(|x| x.id == d).unwrap().fields["__preview"], true); - crate::dialogs::confirm(&mut app, d).unwrap(); + // Content-Aware Fill opens its workspace (caf_ui); OK fills. + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap(); + assert!(r["contentAwareFill"]["window"].is_array(), "{r}"); + crate::caf_ui::commit(&mut app); + assert!(app.distort.caf.is_none()); let d = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareScale", json!({})).unwrap()["dialog"].as_u64().unwrap(); assert_eq!(app.ui.dialogs.iter().find(|x| x.id == d).unwrap().fields["width"], 8); let d = crate::menus::invoke(&mut app, &ctx, "edit.presets.presetManager", json!({})).unwrap()["dialog"].as_u64().unwrap(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d303b02970..f90dcec2fe 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -263,11 +263,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.liquify.is_some() && !ctx.text_edit_focused() { crate::liquify_ui::keys(app, ctx); } + // The Content-Aware Fill workspace likewise. + if app.distort.caf.is_some() && !ctx.text_edit_focused() { + crate::caf_ui::keys(app, ctx); + } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); // Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a // focused text field keeps its keys. - let liquify_focus = app.distort.liquify.is_some() && focus != Focus::None; + let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some()) && focus != Focus::None; if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() { if focus != Focus::Text { nav_keys(app, ctx); From 2507f07ec203b7bc795ed0a98adca4e3be73901f Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:11:15 +0200 Subject: [PATCH 0004/1452] Format: one rustfmt change main needs Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/prefs_ui.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 0a5771e597..319d2e8492 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1009,10 +1009,8 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option Date: Sun, 11 Oct 2026 05:26:15 +0700 Subject: [PATCH 0005/1452] fix(toolbar): port drag-release flyout selection onto upstream toolbar --- crates/ui-egui/src/panels.rs | 854 +++++++++++++++++++++++++---------- 1 file changed, 612 insertions(+), 242 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b2bb91c73f..4b7e58588f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -24,7 +24,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count], ], &[ - &[Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], + &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], &[Tool::Brush, Tool::Pencil, Tool::MixerBrush], &[Tool::CloneStamp, Tool::PatternStamp], &[Tool::HistoryBrush], @@ -92,10 +92,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; let sections = visible_sections(&app.session.prefs().toolbar.hidden); - // Two columns when the header chevron asks for them, or when one column doesn't fit. - let slots: usize = sections.iter().map(Vec::len).sum(); - let double = - app.ui.panels.toolbar_double || toolbar_needs_double(slots, sections.len(), bx, t.pro, t.round_chips, ui.available_rect_before_wrap().height()); + // Two columns when the header chevron asks for them; a column that doesn't fit scrolls. + let double = app.ui.panels.toolbar_double; let w = if double { w1 + bx + 2.0 } else { w1 }; egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show( ui, @@ -131,189 +129,199 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } - // Pro has no group dividers, so its two columns fill every row across groups. - let sections: Vec)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections }; - for (si, section) in sections.iter().enumerate() { - // Photoshop 2026 draws one uninterrupted column (no group dividers). - if si > 0 && !t.pro { - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); - ui.add_space(4.0); - } - let rows: Vec<&[(usize, Vec)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; - for row in rows { - ui.horizontal(|ui| { - for (slot_index, slot) in row.iter() { - let key = egui::Id::new(("tool-slot", *slot_index)); - let tool = slot_tool(ui, app.ui.tool, slot, key); - let sel = slot.contains(&app.ui.tool); - let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; - let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip); - if slot.len() > 1 { - let r = resp.rect; - let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]; - ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); - } - if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { - app.ui.tool = tool; - } - // Double-clicking the Hand tool fits the image on screen (Photoshop). - if resp.double_clicked() && tool == Tool::Hand { - app.ui.tool = tool; - // With no document open there is nothing to fit. - let _ = app.run("view.fitOnScreen", json!({})); - } - // Right-click, long-press, or drag off the tool button opens its flyout (Photoshop). - let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if slot.len() > 1 - && let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); - let drag_open = tool_flyout_drag_open(resp.dragged(), resp.hovered()); - if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { - ui.data_mut(|d| { - d.insert_temp(flyout_id, (key, resp.rect)); - if long_press || drag_open { - // Suppress the origin button's click when released after opening. - d.insert_temp(held_id, key); - } - }); - } - if slot.len() > 1 && long_press { - ui.ctx().request_repaint(); - } - if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) - && open_key == key - { - let area = egui::Area::new(key.with("flyout")) - .order(egui::Order::Foreground) - .fixed_pos(anchor.right_top() + vec2(6.0, 0.0)) - .show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let label_w = slot - .iter() - .map(|it| { - ui.painter().layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text).size().x - }) - .fold(0.0f32, f32::max); - let fw = (label_w + 42.0 + 40.0).max(180.0); - for &item in slot.iter() { - let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); - if ir.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - if item == tool { - ui.painter().rect_filled( - Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), - 0.0, + egui::ScrollArea::vertical().id_salt("toolbar-columns").show(ui, |ui| { + // Pro has no group dividers, so its two columns fill every row across groups. + let sections: Vec)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections }; + for (si, section) in sections.iter().enumerate() { + // Photoshop 2026 draws one uninterrupted column (no group dividers). + if si > 0 && !t.pro { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); + ui.add_space(4.0); + } + let rows: Vec<&[(usize, Vec)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; + for row in rows { + ui.horizontal(|ui| { + for (slot_index, slot) in row.iter() { + let key = egui::Id::new(("tool-slot", *slot_index)); + let tool = slot_tool(ui, app.ui.tool, slot, key); + let sel = slot.contains(&app.ui.tool); + let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; + let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip); + if slot.len() > 1 { + paint_toolbar_flyout_indicator(ui, &t, &resp); + } + if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { + app.ui.tool = tool; + } + // Double-clicking the Hand tool fits the image on screen (Photoshop). + if resp.double_clicked() && tool == Tool::Hand { + app.ui.tool = tool; + // With no document open there is nothing to fit. + let _ = app.run("view.fitOnScreen", json!({})); + } + // Double-clicking the Zoom tool zooms to 100% (Photoshop). + if resp.double_clicked() && tool == Tool::Zoom { + app.ui.tool = tool; + // With no document open there is nothing to zoom. + let _ = app.run("view.actualPixels", json!({})); + } + // Right-click, long-press, or drag off the tool button opens its flyout (Photoshop). + let held_for = + resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if slot.len() > 1 + && let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); + let drag_open = tool_flyout_drag_open(resp.dragged(), resp.hovered()); + if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { + ui.data_mut(|d| { + d.insert_temp(flyout_id, (key, resp.rect)); + if long_press || drag_open { + d.insert_temp(held_id, key); + } + }); + } + if slot.len() > 1 && long_press { + ui.ctx().request_repaint(); + } + if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) + && open_key == key + { + let area = egui::Area::new(key.with("flyout")) + .order(egui::Order::Foreground) + .fixed_pos(anchor.right_top() + vec2(6.0, 0.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let label_w = slot + .iter() + .map(|it| { + ui.painter() + .layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text) + .size() + .x + }) + .fold(0.0f32, f32::max); + let fw = (label_w + 42.0 + 40.0).max(180.0); + for &item in slot.iter() { + let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); + if ir.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + if item == tool { + ui.painter().rect_filled( + Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), + 0.0, + t.text, + ); + } + icons::paint( + ui, + Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), + icons::tool_icon(item), + 14.0, + t.icon, + ); + ui.painter().text( + pos2(r.left() + 42.0, r.center().y), + Align2::LEFT_CENTER, + tl!(item.label()), + egui::FontId::proportional(12.5), t.text, ); - } - icons::paint( - ui, - Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), - icons::tool_icon(item), - 14.0, - t.icon, - ); - ui.painter().text( - pos2(r.left() + 42.0, r.center().y), - Align2::LEFT_CENTER, - tl!(item.label()), - egui::FontId::proportional(12.5), - t.text, - ); - if item.key() != '\0' { - ui.painter().text( - pos2(r.right() - 8.0, r.center().y), - Align2::RIGHT_CENTER, - item.key().to_string(), - egui::FontId::proportional(12.0), - t.text_dim, + if item.key() != '\0' { + ui.painter().text( + pos2(r.right() - 8.0, r.center().y), + Align2::RIGHT_CENTER, + item.key().to_string(), + egui::FontId::proportional(12.0), + t.text_dim, + ); + } + let release_from_origin = tool_flyout_release( + r, + ui.input(|i| i.pointer.hover_pos()), + ui.input(|i| i.pointer.primary_released()), + ui.data(|d| d.get_temp::(held_id)) == Some(key), ); + if ir.clicked() || release_from_origin { + app.ui.tool = item; + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } } - let release_from_origin = tool_flyout_release( - r, - ui.input(|i| i.pointer.hover_pos()), - ui.input(|i| i.pointer.primary_released()), - ui.data(|d| d.get_temp::(held_id)) == Some(key), - ); - if ir.clicked() || release_from_origin { - app.ui.tool = item; - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } - } + }); }); - }); - let held_release = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) == Some(key); - let clicked_outside = ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered(); - if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + let held_release = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) == Some(key); + let clicked_outside = ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered(); + if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } } } + }); + } + } + let ctx = ui.ctx().clone(); + if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { + let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + } + ui.add_space(if t.pro { 8.0 } else { 14.0 }); + color_chips(app, ui, double); + if t.pro { + ui.add_space(8.0); + + let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; + + ui.with_layout(layout, |ui| { + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button( + ui, + "square-dashed", + bx, + quick_mask, + if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + ) + .clicked() + { + let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); } + let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); + paint_toolbar_flyout_indicator(ui, &t, &sm); + + if sm.clicked() { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + // Right-click (or long-press) lists the modes, like Photoshop's flyout. + egui::Popup::context_menu(&sm).show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", tl!("Standard Screen Mode")), + ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), + ("view.screenMode.fullScreen", tl!("Full Screen Mode")), + ] { + let on = crate::view_cmds::checked(app, id).unwrap_or(false); + if ui.add(egui::Button::selectable(on, label)).clicked() { + let _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); + } + } + }); }); } - } - let ctx = ui.ctx().clone(); - if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { - let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); - } - ui.add_space(if t.pro { 8.0 } else { 14.0 }); - color_chips(app, ui, double); - if t.pro { - ui.add_space(8.0); - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button( - ui, - "square-dashed", - bx, - quick_mask, - if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, - ) - .clicked() - { - let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); - } - let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); - if sm.clicked() { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - // Right-click (or long-press) lists the modes, like Photoshop's flyout. - egui::Popup::context_menu(&sm).show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [ - ("view.screenMode.standard", tl!("Standard Screen Mode")), - ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), - ("view.screenMode.fullScreen", tl!("Full Screen Mode")), - ] { - let on = crate::view_cmds::checked(app, id).unwrap_or(false); - if ui.add(egui::Button::selectable(on, label)).clicked() { - let _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } - } - }); - } + }); }, ); } -/// Does the toolbar need two columns? Height of one column (header, tool slots, Edit Toolbar, -/// colour chips, Quick Mask and Screen Mode) against the height the toolbar gets. -pub fn toolbar_needs_double(slots: usize, sections: usize, bx: f32, pro: bool, studio: bool, avail_h: f32) -> bool { - let pitch = bx + 3.0; - let needed = if pro { - // margins + header + slots + "…" + gap + colour chips + gap + 2 buttons - 16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + chips_height(true, false, false) + 8.0 + 2.0 * pitch - } else { - 16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + chips_height(false, studio, false) - }; - needed > avail_h +/// Paints a triangle on the bottom right corner of an icon button to indicate that a toolbar item has a submenu. +pub fn paint_toolbar_flyout_indicator(ui: &mut egui::Ui, tokens: &Tokens, resp: &egui::Response) { + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, tokens.text_faint, Stroke::NONE)); } fn c32(c: [f32; 4]) -> Color32 { @@ -823,6 +831,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); } + if tool == Tool::CloneStamp { + let before = app.session.tools.brush.clone(); + let mut brush = before.clone(); + let width = if t.pro { 62.0 } else { 66.0 }; + percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width); + percent_field(ui, tl!("Flow"), &mut brush.flow, 1.0..=100.0, width); + crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &brush); + widgets::vline(ui, 22.0); + } if crate::eraser_ui::options_bar(app, ui, tool) || crate::retouch_ui::options_bar(app, ui, tool) || crate::vector_ui::options_bar(app, ui, tool) @@ -836,24 +853,38 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let b = &mut brush; match app.ui.tool { Tool::Brush | Tool::Eraser if t.pro => { - brush_preset_chip(ui, b, &mut app.ui); - crate::brush_picker::settings_toggle(app, ui); + // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). + let block = crate::eraser_ui::block_mode(app, app.ui.tool); + ui.add_enabled_ui(!block, |ui| { + brush_preset_chip(ui, b, &mut app.ui); + crate::brush_picker::settings_toggle(app, ui); + }); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Mode")); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { - b.mode = mode; - } - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { - b.pressure_opacity = !b.pressure_opacity; - } - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); - let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); - if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { - b.build_up = !b.build_up; + // The Eraser has no blend mode: its Mode is Brush, Pencil or Block (#2662). + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } else { + opt_label(ui, tl!("Mode")); + let mut mode = b.mode; + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { + b.mode = mode; + } } + ui.add_enabled_ui(!block, |ui| { + percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); + if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { + b.pressure_opacity = !b.pressure_opacity; + } + }); + // A Pencil- or Block-mode Eraser is always full flow, without build-up (Photoshop). + ui.add_enabled_ui(!block && !crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| { + percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); + let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); + if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { + b.build_up = !b.build_up; + } + }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 58.0); smoothing_options(ui, b); @@ -861,7 +892,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() { b.pressure_size = !b.pressure_size; } - crate::symmetry_ui::menu(app, ui); } // Pencil: Photoshop's options (no hardness or flow: the pencil is always hard). Tool::Pencil => { @@ -889,12 +919,21 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). + let block = crate::eraser_ui::block_mode(app, app.ui.tool); + ui.add_enabled_ui(!block, |ui| { + opt_label(ui, tl!("Size")); + widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + }); widgets::vline(ui, 22.0); - percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0); - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0); - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0); + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } + // A Pencil-mode Eraser is always hard and at full flow (Photoshop). + let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); + ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); + ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)); + ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 66.0); widgets::vline(ui, 22.0); @@ -1084,23 +1123,34 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::gradient_ui::options_changed(app, &before); } Tool::Crop if t.pro => { - let o = &mut app.ui.tool_options; - let ratios: Vec<(String, &str)> = crate::chrome_ui::CROP_RATIOS.iter().map(|(k, l)| (k.to_string(), *l)).collect(); - widgets::dropdown(ui, "crop-ratio", &mut o.crop_ratio, &ratios, 120.0); - // Custom ratio fields (Photoshop shows the preset's numbers here). - let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); - let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); - let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); - let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); - if swap { - std::mem::swap(&mut rw, &mut rh); + let presets = crate::crop_size::dropdown_options(); + if widgets::dropdown(ui, "crop-ratio", &mut app.ui.tool_options.crop_ratio, &presets, 140.0) { + // A size preset or Front Image fills W x H x Resolution (#2443). + crate::crop_size::chosen(app); } - if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { - o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); + let o = &mut app.ui.tool_options; + if o.crop_ratio == crate::crop_size::WHR { + crate::crop_size::fields(o, ui); + } else { + // Custom ratio fields (Photoshop shows the preset's numbers here). + let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); + let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); + let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); + let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); + if swap { + std::mem::swap(&mut rw, &mut rh); + } + if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { + o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); + } } widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() { - o.crop_ratio.clear(); + if o.crop_ratio == crate::crop_size::WHR { + crate::crop_size::clear(o); + } else { + o.crop_ratio.clear(); + } } crate::crop_straighten::options_button(&mut app.crop.straighten, ui); crate::crop_overlay::options_button(o, ui); @@ -1274,6 +1324,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. _ => {} } + // Painting symmetry is independent of visual theme. Keep the options-bar + // action available for Brush, Pencil and Eraser in every palette (#2659). + if matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) { + crate::symmetry_ui::menu(app, ui); + } crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush); }); }); @@ -1297,6 +1352,36 @@ fn label(ui: &mut egui::Ui, s: &str) { ui.label(RichText::new(s).color(t.text_dim)); } +/// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without +/// hitting the small number field. The unique drag ID coalesces every frame into one undo step. +fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32) -> widgets::PopupFieldResponse { + let t = Tokens::get(ui.ctx()); + let resp = + ui.add(egui::Label::new(RichText::new(text).color(t.text_dim)).sense(Sense::click_and_drag())).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let key = resp.id.with("percent-label-drag"); + if resp.drag_started() { + let stamp = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| d.insert_temp(key, (*percent, stamp))); + } + let mut out = widgets::PopupFieldResponse::default(); + if resp.dragged() + && let Some((start, stamp)) = ui.data(|d| d.get_temp::<(f32, u64)>(key)) + && let (Some(origin), Some(pos)) = (ui.input(|i| i.pointer.press_origin()), resp.interact_pointer_pos()) + { + // Match DragValue's 0.5 percentage-point per screen point; do not jump on press. + let next = (start + (pos.x - origin.x) * 0.5).clamp(0.0, 100.0); + if next.is_finite() && next != *percent { + *percent = next; + out.changed = true; + out.drag = Some(stamp); + } + } + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::<(f32, u64)>(key)); + } + out +} + /// Width of `s` as [`label`] draws it (body text). fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { let font = egui::TextStyle::Body.resolve(ui.style()); @@ -1584,11 +1669,26 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; app.ui.views[idx].fit_pending = false; } + // The Navigator has its own zoom target: an unmodified wheel over the preview + // should zoom even when the document canvas is configured to scroll on wheel. + // Use the canvas wheel classifier to honour one ×1.1 step per notch without + // applying egui's smoothed wheel tail a second time. + if resp.hovered() + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true) + { + let view = &mut app.ui.views[idx]; + let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); + if next != view.zoom { + view.zoom = next; + view.fit_pending = false; + } + } // Visible-area rectangle. let v = app.ui.views[idx].clone(); let canvas = app.last_canvas_rect; let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6); - let vw = canvas.width() / point_zoom * s; + // Pixel aspect correction shows fewer document columns across the same canvas width. + let vw = canvas.width() / point_zoom / app.ui.view.display_aspect() * s; let vh = canvas.height() / point_zoom * s; let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); @@ -1617,6 +1717,43 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +#[cfg(test)] +mod navigator_wheel_tests { + use super::*; + use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase}; + + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) { + let mut events = vec![Event::PointerMoved(pointer)]; + if wheel != 0.0 { + events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, wheel), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + } + let mut out = ctx + .run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }, |ui| navigator(app, ui)); + out.textures_delta.clear(); + } + + #[test] + fn navigator_wheel_changes_zoom_only_when_over_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + crate::wheel_nav::configure(&ctx); + frame(&mut app, &ctx, pos2(50.0, 55.0), 0.0); + let center = app.ui.views[0].center; + frame(&mut app, &ctx, pos2(50.0, 55.0), 1.0); + assert!((app.ui.views[0].zoom - 1.1).abs() < 1e-4, "one wheel notch zooms once"); + assert_eq!(app.ui.views[0].center, center, "Navigator wheel must not pan"); + frame(&mut app, &ctx, pos2(50.0, 55.0), -1.0); + assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4); + frame(&mut app, &ctx, pos2(50.0, 400.0), 1.0); + assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged"); + } +} + fn empty(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(6.0); @@ -1635,12 +1772,14 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut s = hsva0.s * 100.0; let mut v = hsva0.v * 100.0; let hue = widgets::hue_stops(); - let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", Some(&hue)).changed(); + // Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the tracks are the plain ones. + let dynamic = app.session.prefs().interface.dynamic_color_sliders; + let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", dynamic.then_some(&hue)).changed(); let sat_stops = [egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from); - changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", Some(&sat_stops)).changed(); + changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", dynamic.then_some(&sat_stops)).changed(); let val_stops = [Color32::BLACK, Color32::from(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))]; - changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", Some(&val_stops)).changed(); + changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", dynamic.then_some(&val_stops)).changed(); let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0); // Only an edit counts: the h/s/v round trip isn't exact, so comparing values would rewrite // the foreground (and recolour selected type) every frame. @@ -1710,6 +1849,18 @@ fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, direction.signum() * velocity * dt.clamp(0.0, 0.05) } +/// Fit blend mode + Opacity into the available Layers-panel width (#2309). +/// Preserve the normal translated label when there is room; on a narrow dock, +/// keep the field interactive and give the dropdown the remaining width. +fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool) { + let full_right = label_width + LAYER_PCT_W + 2.0 * gap + 16.0; + // The label shows while the blend dropdown keeps a usable width (at the default dock width + // with an English label it does; a long translated label on a narrow dock gives way). + let show_label = available >= full_right + 80.0; + let reserved = LAYER_PCT_W + gap + if show_label { label_width + gap + 16.0 } else { 0.0 }; + ((available - reserved).max(40.0), show_label) +} + fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A new active layer opens its parent groups and is scrolled into view (#152). let reveal = crate::layer_reveal::track(app, ui.ctx()); @@ -1759,12 +1910,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { let mut m = l.blend; - // Leave room for the Opacity label and field: a translated label ("Непрозрачность:") - // can be much wider than the English one, and must not slide under the dropdown. + // Reserve space for the numeric field first. On a narrow dock, hide the + // redundant visible Opacity label rather than force the dropdown beyond + // the panel's right edge (#2309). The numeric field keeps its name. let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; - let right = (body_text_width(ui, opacity_label) + LAYER_PCT_W + 2.0 * ui.spacing().item_spacing.x + 16.0).max(150.0); - let w = ui.available_width() - right; - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), w.max(100.0)); + let (blend_width, show_opacity_label) = + opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); + let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); @@ -1773,11 +1925,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = l.opacity * 100.0; - let r = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); - if r.changed { - actions.push(pct_action(l, "opacity", o, r.drag)); + let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); + // On a narrow dock the (scrubby) label yields its room to the controls (#2309). + let scrub = if show_opacity_label { scrub_pct_label(ui, opacity_label, &mut o) } else { widgets::PopupFieldResponse::default() }; + if field.changed || scrub.changed { + actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); } - label(ui, opacity_label); }); }); }); @@ -1822,11 +1975,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_enabled_ui(!bg, |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut f = l.fill_opacity * 100.0; - let r = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); - if r.changed { - actions.push(pct_action(l, "fill", f, r.drag)); + let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); + let scrub = scrub_pct_label(ui, fill_label, &mut f); + if field.changed || scrub.changed { + actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); } - label(ui, fill_label); }) }); }); @@ -1862,6 +2015,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let filter = app.ui.layer_filter.clone(); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); + // The drawn rows, top to bottom (#1721): the space between them and below the last + // one is a drop zone too. + let mut drawn: Vec<(LayerId, Rect)> = Vec::new(); for &(depth, l) in &rows { // Select › Isolate Layers. if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { @@ -1882,7 +2038,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; let top = ui.cursor().top(); - layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions); + drawn.push((l.id, layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions))); if reveal == Some(l.id) { crate::layer_reveal::scroll_to_row(ui, top); } @@ -1891,6 +2047,17 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions); } + // #1721: the list takes drops everywhere, not only on a row (Photoshop): the space + // between two rows lands above the lower one, the empty space below the bottom-most + // row below it. Both copy with ⌥ held, move without. + for w in drawn.windows(2) { + let gap = Rect::from_min_max(pos2(w[0].1.left(), w[0].1.bottom()), pos2(w[0].1.right(), w[1].1.top())); + layer_drop_zone(app, &ctx, ui, gap, (w[1].0, w[1].1), "above", &mut actions); + } + if let Some(&(last, last_rect)) = drawn.last() { + let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); + layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); + } // ⌥-click the line between two layers: clip / release the upper one (#967). crate::clip_line_ui::show(ui, &doc, &mut actions); // A rename whose row is gone (deleted, filtered out, inside a closed group) ends, @@ -1925,7 +2092,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }; let trash = icons::button(ui, "trash", 26.0, false, tl!(label)); if trash.clicked() { - actions.push((delete.into(), json!({}))); + // Like Photoshop, ⌥/Alt-click deletes without asking. + let confirm = delete == "layer.delete" && !ui.input(|i| i.modifiers.alt); + let params = if confirm { json!({"__trash": true}) } else { json!({}) }; + actions.push((delete.into(), params)); } actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); // A click follows the selected thumbnail; name that action for screen readers even @@ -1938,7 +2108,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); if group.clicked() { - actions.push(("layer.new.group".into(), json!({}))); + // Like Photoshop a plain click adds an empty group; Shift-click groups the selected + // layers, the same as Layer › Group Layers (#2561). + let shift = ui.input(|i| i.modifiers.shift); + let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; + actions.push((cmd.into(), json!({}))); } actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); let adj = footer_menu_button(ui, "adjustment-layer", 26.0, tl!("Create new fill or adjustment layer")); @@ -1997,11 +2171,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; + app.sync_mask_targets(); continue; } if id == "ui.vectorMaskTarget" { app.ui.vector_mask_target = p.as_bool().unwrap_or(false); app.ui.mask_target = false; + app.sync_mask_targets(); continue; } if p.is_null() { @@ -2147,7 +2323,7 @@ fn layer_row( depth: usize, row: RowSel, actions: &mut Vec<(String, Value)>, -) { +) -> Rect { let selected = row.selected; let t = Tokens::get(ctx); // Photoshop's default (medium) thumbnails: 32 pt rows. @@ -2164,7 +2340,7 @@ fn layer_row( // otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their // disclosure triangle is a widget (accessibility, scroll-to). if !ui.is_rect_visible(rect) && !l.is_group() && !resp.context_menu_opened() && crate::layer_row_ui::renaming(ctx) != Some(l.id.0) { - return; + return rect; } let painter = ui.painter_at(rect.expand(1.0)); if t.pro { @@ -2333,6 +2509,13 @@ fn layer_row( // One rename at a time: starting this one commits any other (#314). actions.push(done); } + } else if let Some(kind) = pos.and_then(|p| masks.hit(p)) { + if kind == crate::mask_thumbs_ui::MaskKind::Pixel { + actions.push(("ui.maskTarget".into(), json!(true))); + } + if let Some((cmd, val)) = crate::mask_thumbs_ui::double_click_command(app, kind) { + actions.push((cmd.into(), val)); + } } else if pos.and_then(|p| masks.hit(p)).is_none() { let id = match &l.content { LayerContent::Adjustment(_) | LayerContent::Fill(_) if on(thumb) => "layer.layerContentOptions", @@ -2352,6 +2535,7 @@ fn layer_row( } // Right-click context menu. resp.context_menu(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); // Right-clicking inside a multi-selection keeps it and acts on every selected layer. let on_set = row.multi && selected; if crate::layer_menu_ui::show(app, ui, l, on_set, actions) @@ -2360,6 +2544,7 @@ fn layer_row( actions.push(done); } }); + rect } /// Screen rect and UVs for a layer thumbnail: the document-shaped part of the square cell and of @@ -2519,12 +2704,13 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } let Some(st) = app.session.active() else { return }; let Some(id) = st.active_layer else { return }; - let Some(layer) = st.doc.layer(id) else { return }; - // The floating card appears for adjustment and fill layers (their controls live here). - if !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { + let doc = st.doc.clone(); + let Some(layer) = doc.layer(id) else { return }; + // Pixel-mask controls also belong here when their thumbnail is targeted. + let mask_target = crate::mask_props_ui::targeted(app, layer); + if !mask_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { return; } - let layer = layer.clone(); let t = Tokens::get(ctx); let canvas = app.last_canvas_rect; let width = 320.0; @@ -2583,10 +2769,12 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.add_space(8.0); // Per-layer ids, so text still being typed for one layer can't commit to the next. ui.push_id(id, |ui| { - if let LayerContent::Adjustment(adj) = &layer.content { + if mask_target { + crate::mask_props_ui::properties(app, ui, layer); + } else if let LayerContent::Adjustment(adj) = &layer.content { adjustment_controls(app, ui, id, adj); } else { - layer_controls(app, ui, &layer); + layer_controls(app, ui, layer); } }); }); @@ -2658,6 +2846,14 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) { empty(ui, tl!("No properties")); return; }; + let doc = st.doc.clone(); + if let Some(layer) = st.active_layer.and_then(|id| doc.layer(id)) + && crate::mask_props_ui::targeted(app, layer) + { + crate::props_layout::header(ui, layer); + crate::mask_props_ui::properties(app, ui, layer); + return; + } // Photoshop shows the Document properties when nothing or the Background layer is selected. if crate::doc_props_ui::shows_document(&st.doc, st.active_layer) { crate::doc_props_ui::properties(app, ui); @@ -2665,7 +2861,6 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let Some(id) = st.active_layer else { return }; // Borrowed from the document snapshot: cloning the layer every frame copied whole groups. - let doc = st.doc.clone(); let Some(layer) = doc.layer(id) else { return }; // Header: kind icon, layer name and kind (#155); sections below draw their own separators. crate::props_layout::header(ui, layer); @@ -2764,6 +2959,18 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) { } } +/// Where the dock tells the Color field how tall its group body is. +pub(crate) fn color_field_fill_id() -> egui::Id { + egui::Id::new("color-field-fill-height") +} + +/// Height of the Color field: the group body's height (when docked) less the hex/RGB readout, +/// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. +fn color_field_height(avail: Option, width: f32) -> f32 { + let reserve = 32.0 + if width < 280.0 { 26.0 } else { 0.0 }; + avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(120.0, 2000.0)) +} + /// Photoshop Color panel: saturation/brightness field + hue strip, drawn as shaded meshes. fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -2777,7 +2984,9 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let w = ui.available_width(); let strip_w = 14.0; - let h = 120.0; + // Photoshop's Color panel field fills the panel: take the height the dock offers, less the + // hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt. + let h = color_field_height(ui.data(|d| d.get_temp::(color_field_fill_id())), w); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover()); @@ -3042,6 +3251,31 @@ fn layer_drag_and_drop( if copy { ctx.set_cursor_icon(egui::CursorIcon::Copy); } + // #1721: with ⌥ held the row also takes the drop onto itself — the only drop target a + // single-layer document has. The copy lands above or below it (never into a group's own + // centre). A multi-selection is left alone: the engine copies it beside each of its own + // layers, not onto one of them. + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + let in_multi = selected.len() > 1 && selected.contains(&l.id); + if copy + && !in_multi + && let Some(p) = pointer.filter(|p| rect.contains(*p)) + { + let position = if (p.y - rect.top()) / rect.height() < 0.5 { "above" } else { "below" }; + let painter = ui.painter(); + if position == "above" { + painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent)); + } else { + painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); + } + if released { + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + if let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } + actions.push(("layer.moveTo".into(), payload)); + } + } return; } let Some(p) = pointer else { return }; @@ -3078,6 +3312,42 @@ fn layer_drag_and_drop( } } +/// #1721: the drop zones no row owns — the space between two rows and the empty space below the +/// bottom-most one. The drop lands on `target` at `position` (`above` or `below` its row): a copy +/// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it +/// was grabbed from is left alone: the engine can only copy it beside each of its own layers. +fn layer_drop_zone( + app: &PhotocraftApp, + ctx: &egui::Context, + ui: &egui::Ui, + zone: Rect, + target: (LayerId, Rect), + position: &'static str, + actions: &mut Vec<(String, Value)>, +) { + let Some(dragged) = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))) else { return }; + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + if target.0 == LayerId(dragged) && selected.len() > 1 && selected.contains(&target.0) { + return; + } + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if !zone.contains(p) { + return; + } + let t = Tokens::get(ctx); + let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] }; + ui.painter().line_segment(line, Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + let mut payload = layer_drop_payload(dragged, target.0, position, &selected); + if ctx.input(|i| i.modifiers.alt) + && let Some(o) = payload.as_object_mut() + { + o.insert("copy".into(), json!(true)); + } + actions.push(("layer.moveTo".into(), payload)); + } +} + /// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects" /// row, all of them). type FxDrag = (u64, Option); @@ -3211,6 +3481,27 @@ fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 8.0; } +#[cfg(test)] +mod symmetry_theme_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn brush_pencil_and_eraser_expose_symmetry_menu_in_every_theme() { + for kind in crate::theme::ThemeKind::ALL { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(4); + assert!(h.query_by_label("Set painting symmetry options").is_some(), "missing symmetry menu in {:?} for {:?}", kind, tool); + } + } + } +} + #[cfg(test)] mod color_tests { use super::*; @@ -3396,6 +3687,17 @@ mod color_tests { assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]); } + /// The Color field fills the dock group, never shrinks below 120 pt, and ignores a bad height. + #[test] + fn color_field_height_fills_the_group() { + assert_eq!(color_field_height(None, 300.0), 120.0); + assert_eq!(color_field_height(Some(500.0), 300.0), 468.0); + assert_eq!(color_field_height(Some(500.0), 200.0), 442.0); + assert_eq!(color_field_height(Some(60.0), 300.0), 120.0); + assert_eq!(color_field_height(Some(f32::NAN), 300.0), 120.0); + assert_eq!(color_field_height(Some(1.0e9), 300.0), 2000.0); + } + fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a frame apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state( @@ -3698,6 +4000,8 @@ mod type_flyout_tests { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); assert!(j.contains(&Tool::RedEye), "{j:?}"); assert_eq!(j.last(), Some(&Tool::RedEye)); + // Remove leads the flyout, so the slot shows it until another J tool is picked. + assert_eq!(j.first(), Some(&Tool::Remove)); } #[test] @@ -3884,16 +4188,20 @@ mod toolbar_tests { } } - /// The Hand tool's toolbar button: tool buttons have no label, so find it by slot order. - fn hand_button(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Pos2 { + /// A tool's toolbar button: tool buttons have no label, so find it by slot order. + fn tool_button(h: &egui_kittest::Harness<'_, PhotocraftApp>, tool: Tool) -> egui::Pos2 { let size = egui::Vec2::splat(if Tokens::get(&h.ctx).pro { 30.0 } else { 36.0 }); let buttons: Vec = h.ctx.viewport(|v| { v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).map(|w| w.rect).collect() }); - let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).unwrap(); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap(); buttons[index].center() } + fn hand_button(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Pos2 { + tool_button(h, Tool::Hand) + } + fn toolbar_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a few frames apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).with_step_dt(1.0 / 60.0).build_ui_state( @@ -3942,6 +4250,41 @@ mod toolbar_tests { assert_eq!(h.state().ui.tool, Tool::Hand); assert!(h.state().session.active().is_none()); } + + /// Double-clicking the Zoom tool zooms to 100% (Photoshop); a single click only picks the tool. + #[test] + fn double_clicking_the_zoom_tool_zooms_to_actual_pixels() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.views[0].fit_pending = true; + app.ui.views[0].zoom = 0.25; + let mut h = toolbar_harness(app); + let p = tool_button(&h, Tool::Zoom); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().ui.views[0].zoom, 0.25, "a single click doesn't zoom"); + // Past the double-click window, so the next two clicks are a fresh double-click. + h.run_steps(40); + click(&mut h, p); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().ui.views[0].zoom, 1.0, "a double-click zooms to 100%"); + assert!(!h.state().ui.views[0].fit_pending, "100% replaces a pending fit"); + } + + /// With no document open the double-click still picks the tool and doesn't panic. + #[test] + fn double_clicking_the_zoom_tool_without_a_document_is_harmless() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Move; + let mut h = toolbar_harness(app); + let p = tool_button(&h, Tool::Zoom); + click(&mut h, p); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert!(h.state().session.active().is_none()); + } } #[cfg(test)] @@ -4101,6 +4444,33 @@ mod group_drag_selection_tests { } } +#[cfg(test)] +mod opacity_row_layout_tests { + use super::*; + + #[test] + fn opacity_label_yields_to_controls_in_narrow_layers_panel() { + let gap = 2.0; + let label = 62.0; + let (wide, visible) = opacity_row_layout(360.0, label, gap); + assert!(visible); + assert!(wide >= 100.0); + // The default dock width keeps the English label (the Layers dock at its 250 px minimum). + assert!(opacity_row_layout(255.0, 46.0, 8.0).1); + let (medium, visible) = opacity_row_layout(180.0, label, gap); + assert!(!visible, "the long label must not overlap the blend field"); + assert!(medium + LAYER_PCT_W + gap <= 180.0); + let (narrow, visible) = opacity_row_layout(135.0, label, gap); + assert!(!visible); + assert!(narrow >= 40.0); + assert!(narrow + LAYER_PCT_W + gap <= 135.0); + } +} + +#[cfg(test)] +#[path = "mask_props_ui_tests.rs"] +mod mask_properties_tests; + #[cfg(test)] mod tool_flyout_gesture_tests { use super::*; From 6e0b3330c9419da72501f0eefce54cb8ffd51733 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Sun, 11 Oct 2026 02:06:56 +0300 Subject: [PATCH 0006/1452] feat(ui): bring Photoshop-style Layers Panel options and scalable Smart Filters Add a functional Layers flyout and Layers Panel Options dialog, configurable thumbnail sizes and bounds, mask/effect/copy preferences, and canvas reorder previews. Scale smart filter and layer effect rows with layer thumbnail size, and add regression coverage and translations. Known limitation: Animation Options remains disabled because frame-property unification controls are not implemented. --- crates/engine/src/commands.rs | 27 +- crates/engine/src/jobs.rs | 22 ++ crates/engine/src/layer_multi_cmds.rs | 181 ++++++++- crates/engine/src/pattern_cmds.rs | 3 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/clip_line_ui/tests.rs | 2 +- crates/ui-egui/src/dock.rs | 11 +- crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/de.tsv | 20 + crates/ui-egui/src/i18n/el.tsv | 16 + crates/ui-egui/src/i18n/es.tsv | 20 + crates/ui-egui/src/i18n/fr.tsv | 18 + crates/ui-egui/src/i18n/id.tsv | 18 + crates/ui-egui/src/i18n/it.tsv | 20 + crates/ui-egui/src/i18n/ja.tsv | 16 + crates/ui-egui/src/i18n/ko.tsv | 19 + crates/ui-egui/src/i18n/nl.tsv | 16 + crates/ui-egui/src/i18n/pl.tsv | 16 + crates/ui-egui/src/i18n/pt-br.tsv | 18 + crates/ui-egui/src/i18n/ru.tsv | 18 + crates/ui-egui/src/i18n/uk.tsv | 16 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/layer_row_ui.rs | 56 ++- crates/ui-egui/src/layer_row_ui/tests.rs | 29 ++ crates/ui-egui/src/layers_panel_ui.rs | 469 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 44 ++- crates/ui-egui/src/mask_thumbs_ui.rs | 6 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels.rs | 104 +++-- crates/ui-egui/src/smart_ui.rs | 68 +++- crates/ui-egui/src/state.rs | 70 ++++ 32 files changed, 1322 insertions(+), 70 deletions(-) create mode 100644 crates/ui-egui/src/layers_panel_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e7d086d3b6..c53a0e80ef 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -245,12 +245,18 @@ pub(crate) fn selection_mask(doc: &Document) -> Option { doc.selection.as_ref().map(|sel| LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }) } +/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands +/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults. +pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool { + s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback) +} + /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the /// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -261,7 +267,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => l.mask = Some(selection_mask(doc).unwrap_or_else(LayerMask::reveal_all)), + None => l.mask = selection_mask(doc).or_else(|| default_mask.then(LayerMask::reveal_all)), } Ok(()) } @@ -275,7 +281,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); - mask_new_layer(doc, &mut l, p, cmd)?; + mask_new_layer(doc, &mut l, p, cmd, true)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -560,8 +566,9 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; + let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let nid = s.edit("Duplicate Layer", |doc, active| { - let dup = layer_copy(doc, id)?; + let dup = layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -771,10 +778,11 @@ fn build() -> Vec { has_doc, |s, p| { let c = color_param(p, "color", s.tools.foreground); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -795,6 +803,7 @@ fn build() -> Vec { let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -805,7 +814,7 @@ fn build() -> Vec { ) .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -1349,12 +1358,14 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { /// A copy of layer `id` as Layer › Duplicate Layer makes it, not yet in the document: a fresh id, /// "… copy" appended to the name, and a copy of the Background layer is an ordinary, unlocked /// layer (Photoshop). -pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result { +pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result { let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?; // A copy of the Background layer is an ordinary, unlocked layer (Photoshop). let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id); let mut dup = src.duplicate(); - dup.name = doc.copy_name(&dup.name); + if add_copy { + dup.name = doc.copy_name(&dup.name); + } if from_background { dup.locks = Default::default(); } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9b612e3c4c..9aebb68d39 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -635,6 +635,17 @@ impl Session { if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { return Err(EngineError::Other(why.into())); } + // This UI-only disclosure preference applies to both `execute` and `start`: + // desktop UI normally calls `start`, even for synchronous Layer Style commands. + let new_fx_target = if id.starts_with("layer.layerStyle.") { + self.active().and_then(|st| { + let target = params.get("layer").and_then(Value::as_u64).map(LayerId).or(st.active_layer)?; + (st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target)) + }) + } else { + None + }; + let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true); self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); self.jobs.spawn = background; @@ -662,6 +673,17 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { + if let Some((doc_id, layer_id)) = new_fx_target + && let Some(st) = self.active_mut() + && st.doc.id == doc_id + && st.doc.layer(layer_id).is_some_and(|l| !l.effects.items.is_empty()) + { + if expand_new_fx { + st.fx_collapsed.retain(|id| *id != layer_id); + } else if !st.fx_collapsed.contains(&layer_id) { + st.fx_collapsed.push(layer_id); + } + } self.after_command(id, params, spec.journal); Ok(Started::Done(v)) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9408a32b12..cf7231259a 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -877,6 +877,66 @@ fn distribute(s: &mut Session, kind: &str) -> Result { /// Reorder one or multiple layers to a single destination in one undoable edit. /// /// Explicit `layers` are ordered according to the source document, not caller ordering. +/// A transient snapshot for the Layers panel's "Preview on canvas when reordering layers". +/// Uses the same source ordering and insertion semantics as `move_to`, but never edits history. +/// Invalid/self-referential hover targets return None (the committed image stays visible). +pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option { + let target = LayerId(p.get("target")?.as_u64()?); + let position = p.get("position")?.as_str()?; + if !matches!(position, "above" | "below" | "into") { + return None; + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let ids = if let Some(ids) = p.get("layers") { + ids.as_array()?.iter().map(|v| v.as_u64().map(LayerId)).collect::>>()? + } else { + vec![LayerId(p.get("layer")?.as_u64()?)] + }; + if ids.is_empty() || doc.layer(target).is_none() { + return None; + } + let moved_ids = top_level(doc, &ids); + if moved_ids.is_empty() || moved_ids.iter().any(|id| doc.layer(*id).is_none()) { + return None; + } + if moved_ids.len() == 1 && moved_ids[0] == target && !copy { + return None; + } + let target_path = doc.path_of(target)?; + for id in &moved_ids { + let path = doc.path_of(*id)?; + if target_path.starts_with(&path) && !(copy && *id == target && position != "into") { + return None; + } + } + if position == "into" && !doc.layer(target)?.is_group() { + return None; + } + let mut preview = doc.clone(); + let mut moved = Vec::with_capacity(moved_ids.len()); + for &id in &moved_ids { + if copy { + let mut layer = preview.layer(id)?.duplicate(); + if add_copy { + layer.name = preview.copy_name(&layer.name); + } + moved.push(layer); + } else { + moved.push(preview.remove(id)?); + } + } + let path = preview.path_of(target)?; + if position == "into" { + preview.layer_at_mut(&path)?.children_mut()?.extend(moved); + } else { + let (&idx, parent) = path.split_last()?; + let siblings = if parent.is_empty() { &mut preview.layers } else { preview.layer_at_mut(parent)?.children_mut()? }; + siblings.splice((idx + usize::from(position == "above"))..(idx + usize::from(position == "above")), moved); + } + check_group_depth(&preview, "Preview Reorder Layers").ok()?; + Some(preview) +} + /// Selected descendants of a moved group remain inside their parent; only top-level /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { @@ -934,10 +994,11 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } let original_active = st.active_layer; + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| { let mut moved = Vec::with_capacity(moved_ids.len()); for &id in &moved_ids { - moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); + moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); } let copies: Vec = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() }; // Look up the destination AFTER removing every source: its indices may have shifted. @@ -1194,13 +1255,14 @@ pub fn delete_selected(s: &mut Session) -> Result { pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); let old_active = s.active().and_then(|d| d.active_layer); + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); let mut new_active = None; for id in top_level(doc, &sel) { // Match the single-layer duplicate path: Background copies become // ordinary layers, while normal copies retain their original locks. - let dup = crate::commands::layer_copy(doc, id)?; + let dup = crate::commands::layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -2256,3 +2318,118 @@ mod tests { assert_eq!(v["layers"][0]["selected"], true); } } + +#[cfg(test)] +mod layers_panel_parity_tests { + use super::*; + + fn new_session() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); + let ids = (0..3).map(|n| LayerId(s.execute("layer.new.layer", json!({"name": format!("Test {n}")})).unwrap()["layer"].as_u64().unwrap())).collect(); + (s, ids) + } + + fn option(s: &mut Session, key: &str, enabled: bool) { + s.prefs.edit(|p| { + let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({})); + if let Some(o) = val.as_object_mut() { + o.insert(key.to_owned(), json!(enabled)); + } + }); + } + + #[test] + fn preview_matches_real_move_and_never_edits_document_or_history() { + let (mut s, ids) = new_session(); + let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "above"}); + let before = s.active().unwrap().doc.clone(); + let undo_before = s.active().unwrap().history.entries().len(); + let candidate = preview_move(&before, ¶ms, true).unwrap(); + assert_eq!(*before, *s.active().unwrap().doc); + assert_eq!(s.active().unwrap().history.entries().len(), undo_before); + s.execute("layer.moveTo", params).unwrap(); + assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order"); + } + + #[test] + fn preview_copy_and_no_copy_suffix_option_match_committed_layers() { + let (mut s, ids) = new_session(); + option(&mut s, "addCopyToCopiedLayersAndGroups", false); + let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "below", "copy": true}); + let before = s.active().unwrap().doc.clone(); + let candidate = preview_move(&before, ¶ms, false).unwrap(); + s.execute("layer.moveTo", params).unwrap(); + let actual = &s.active().unwrap().doc; + assert_eq!(candidate.layers.iter().map(|l| l.name.as_str()).collect::>(), actual.layers.iter().map(|l| l.name.as_str()).collect::>()); + assert_eq!(actual.layer_count(), before.layer_count() + 1); + assert_eq!(actual.layers.iter().filter(|l| l.name == "Test 0").count(), 2); + let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap(); + let duplicate = LayerId(d["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(duplicate).unwrap().name, "Test 1"); + option(&mut s, "addCopyToCopiedLayersAndGroups", true); + let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap(); + assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy")); + } + + #[test] + fn preview_rejects_invalid_drop_unchanged() { + let (mut s, ids) = new_session(); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), + json!({"layer": ids[1].0, "target": 999999, "position": "below"}), + json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), + json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), + ] { + assert!(preview_move(&before, &p, true).is_none(), "{p}"); + } + assert_eq!(*before, *s.active().unwrap().doc); + // Multi-layer preview and commit follow the same stack order regardless of params order. + let p = json!({"layers": [ids[2].0, ids[0].0], "target": ids[1].0, "position": "below"}); + let candidate = preview_move(&before, &p, true).unwrap(); + s.execute("layer.moveTo", p).unwrap(); + assert_eq!(candidate, *s.active().unwrap().doc); + } + + #[test] + fn expand_new_effects_applies_to_ui_start_dispatch_too() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let target = ids[0]; + let started = s.start("layer.layerStyle.dropShadow", json!({"layer": target.0})).unwrap(); + assert!(matches!(started, crate::jobs::Started::Done(_))); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + } + + #[test] + fn fill_layers_respect_use_default_masks_option() { + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); + option(&mut s, "useDefaultMasksOnFillLayers", true); + let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); + } + + #[test] + fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let target = ids[0]; + let history = s.active().unwrap().history.entries().len(); + s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 2})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), history + 1); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + option(&mut s, "expandNewEffects", true); + let other = ids[1]; + s.execute("layer.layerStyle.stroke", json!({"layer": other.0, "size": 2})).unwrap(); + assert!(!s.active().unwrap().fx_collapsed.contains(&other)); + // Editing the existing effect must not unexpectedly change its disclosure. + s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap(); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + } +} diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 2cf3cf144a..449f0540b9 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -308,11 +308,12 @@ pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; let (scale, angle, link, phase) = placement(p); + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Pattern Fill Layer", |doc, active| { ensure_in_doc(doc, &pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); - crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f321faf07..197b212ab6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -903,6 +903,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::patch_preview::display_doc(app, idx) { return shown; } + // Live Layers-panel reorder previews the actual composite before mouse release. + if let Some(shown) = crate::layers_panel_ui::display_reorder(app, idx) { + return shown; + } // A blend mode hovered in the Layers panel. if let Some(shown) = crate::blend_preview::display_doc(app, idx) { return shown; diff --git a/crates/ui-egui/src/clip_line_ui/tests.rs b/crates/ui-egui/src/clip_line_ui/tests.rs index ba89495402..aeab35f23c 100644 --- a/crates/ui-egui/src/clip_line_ui/tests.rs +++ b/crates/ui-egui/src/clip_line_ui/tests.rs @@ -84,7 +84,7 @@ fn the_clip_cursor_shows_only_with_alt_over_a_line() { } fn row(layer: LayerId, top: f32) -> RowRects { - RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), name: None, indicators: Vec::new() } + RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), thumbnail: None, name: None, indicators: Vec::new() } } fn named(doc: &Document, name: &str) -> LayerId { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 00c483b93f..00e50e5768 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -558,6 +558,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.close(); } ui.separator(); + if ui.button(tl!("Close")).clicked() { + if let Some(&tab) = indices.get(sel) { + actions.push(Action::CloseTab(g, tab)); + } + ui.close(); + } if ui.button(tl!("Close Tab Group")).clicked() { actions.push(Action::Close(g)); ui.close(); @@ -694,7 +700,7 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) /// What `prefs.panelLayout` holds: the live layout and open panels. fn snapshot(app: &PhotocraftApp) -> Value { - json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open}) + json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "layersPanelOptions": app.ui.layers_panel_options, "timelineOpen": app.ui.timeline.open}) } /// Remember the layout in the preferences once the user lets go of the mouse (Workspace › @@ -737,6 +743,9 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) { app.ui.dock = d; } + if let Some(opts) = v.get("layersPanelOptions").and_then(|o| serde_json::from_value(o.clone()).ok()) { + app.ui.layers_panel_options = opts; + } app.ui.timeline.open = v.get("timelineOpen").and_then(Value::as_bool).unwrap_or(false); if !app.ui.timeline.open { app.ui.timeline.playing = false; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 335ae28734..49795c72dd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2435,3 +2435,24 @@ fontWeight Italic Kurzíva Front Image Přední obraz Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení + Animation Options Možnosti animace + Entire Document Celý dokument + Filter Options Možnosti filtrování + Layer Bounds Hranice vrstvy + Layers Panel Options Možnosti panelu Vrstvy + Lock Layers Zamknout vrstvy + New Artboard… Nové kreslicí plátno… + New Group Nová skupina + New Group… Nová skupina… + New Layer Nová vrstva + New Layer… Nová vrstva… + Panel Options… Možnosti panelu… + Prevent Auto-Nesting Zabránit automatickému vnoření + Show Layers Zobrazit vrstvy + Thumbnail Contents Obsah miniatur + Thumbnail Size Velikost miniatur + Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně + Expand New Effects Rozbalovat nové efekty + Add “copy” to Copied Layers and Groups Přidávat „kopie“ ke kopírovaným vrstvám a skupinám + Preview on canvas when reordering layers Zobrazovat náhled na plátně při změně pořadí vrstev + Show layer mask badges Zobrazovat odznaky masek vrstev diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9a2be43ae4..4eeb8c263e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2473,3 +2473,23 @@ fontWeight Italic Kursiv Front Image Vorderes Bild Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen + Animation Options Animationsoptionen + Entire Document Gesamtes Dokument + Filter Options Filteroptionen + Layer Bounds Ebenenbegrenzungen + Layers Panel Options Optionen des Ebenenbedienfelds + Lock Layers Ebenen sperren + New Artboard… Neue Zeichenfläche… + New Group… Neue Gruppe… + New Layer Neue Ebene + New Layer… Neue Ebene… + Panel Options… Bedienfeldoptionen… + Prevent Auto-Nesting Automatisches Verschachteln verhindern + Show Layers Ebenen einblenden + Thumbnail Contents Miniaturinhalt + Thumbnail Size Miniaturgröße + Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden + Expand New Effects Neue Effekte aufklappen + Add “copy” to Copied Layers and Groups „Kopie“ zu kopierten Ebenen und Gruppen hinzufügen + Preview on canvas when reordering layers Vorschau auf der Arbeitsfläche beim Neuanordnen von Ebenen + Show layer mask badges Ebenenmasken-Abzeichen anzeigen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 58df5ad70b..7fc1e028c1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3543,3 +3543,19 @@ fontWeight Thin Πολύ λεπτά Front Image Μπροστινή εικόνα Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις + Animation Options Επιλογές κίνησης + Entire Document Ολόκληρο έγγραφο + Filter Options Επιλογές φίλτρου + Layer Bounds Όρια επιπέδου + Layers Panel Options Επιλογές πίνακα επιπέδων + New Artboard… Νέος πίνακας σχεδίασης… + New Group… Νέα ομάδα… + Prevent Auto-Nesting Αποτροπή αυτόματης ένθεσης + Show Layers Εμφάνιση επιπέδων + Thumbnail Contents Περιεχόμενο μικρογραφιών + Thumbnail Size Μέγεθος μικρογραφιών + Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος + Expand New Effects Ανάπτυξη νέων εφέ + Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες + Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων + Show layer mask badges Εμφάνιση σημάτων μασκών επιπέδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4815e5b244..a85cdb1ae4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2514,3 +2514,23 @@ fontWeight Italic Cursiva Front Image Imagen frontal Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan + Animation Options Opciones de animación + Entire Document Documento entero + Filter Options Opciones de filtro + Layer Bounds Límites de capa + Layers Panel Options Opciones del panel Capas + Lock Layers Bloquear capas + New Artboard… Nueva mesa de trabajo… + New Group… Nuevo grupo… + New Layer Nueva capa + New Layer… Nueva capa… + Panel Options… Opciones de panel… + Prevent Auto-Nesting Impedir anidación automática + Show Layers Mostrar capas + Thumbnail Contents Contenido de miniaturas + Thumbnail Size Tamaño de miniaturas + Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno + Expand New Effects Expandir nuevos efectos + Add “copy” to Copied Layers and Groups Añadir «copia» a las capas y grupos duplicados + Preview on canvas when reordering layers Previsualizar en el lienzo al reordenar capas + Show layer mask badges Mostrar insignias de máscaras de capa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 21f7232759..859713e9fe 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2853,3 +2853,21 @@ fontWeight Italic Italique Front Image Image de premier plan Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant + Animation Options Options d’animation + Entire Document Document entier + Filter Options Options de filtrage + Layer Bounds Limites du calque + Layers Panel Options Options du panneau Calques + Lock Layers Verrouiller les calques + New Artboard… Nouveau plan de travail… + New Group… Nouveau groupe… + New Layer Nouveau calque + Prevent Auto-Nesting Empêcher l’imbrication automatique + Show Layers Afficher les calques + Thumbnail Contents Contenu des vignettes + Thumbnail Size Taille des vignettes + Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage + Expand New Effects Développer les nouveaux effets + Add “copy” to Copied Layers and Groups Ajouter « copie » aux calques et groupes dupliqués + Preview on canvas when reordering layers Prévisualiser sur la zone de travail lors du réagencement des calques + Show layer mask badges Afficher les badges des masques de fusion diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index efd0962b32..425ffb7038 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2840,3 +2840,21 @@ fontWeight Italic Miring Front Image Gambar Depan Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok + Animation Options Opsi Animasi + Entire Document Seluruh Dokumen + Filter Options Opsi Filter + Layer Bounds Batas Lapisan + Layers Panel Options Opsi Panel Lapisan + Lock Layers Kunci Lapisan + New Artboard… Artboard Baru… + New Group… Grup Baru… + New Layer Lapisan Baru + Prevent Auto-Nesting Cegah Pengelompokan Otomatis + Show Layers Tampilkan Lapisan + Thumbnail Contents Isi Gambar Mini + Thumbnail Size Ukuran Gambar Mini + Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian + Expand New Effects Perluas Efek Baru + Add “copy” to Copied Layers and Groups Tambahkan “salinan” ke Lapisan dan Grup yang Disalin + Preview on canvas when reordering layers Pratinjau pada kanvas saat mengurutkan ulang lapisan + Show layer mask badges Tampilkan Lencana Masker Lapisan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c7bb027e79..b5462bd381 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2504,3 +2504,23 @@ fontWeight Italic Corsivo Front Image Immagine in primo piano Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente + Animation Options Opzioni animazione + Entire Document Documento intero + Filter Options Opzioni filtro + Layer Bounds Limiti livello + Layers Panel Options Opzioni pannello Livelli + Lock Layers Blocca livelli + New Artboard… Nuova tavola da disegno… + New Group… Nuovo gruppo… + New Layer Nuovo livello + New Layer… Nuovo livello… + Panel Options… Opzioni pannello… + Prevent Auto-Nesting Impedisci annidamento automatico + Show Layers Mostra livelli + Thumbnail Contents Contenuto miniature + Thumbnail Size Dimensione miniature + Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento + Expand New Effects Espandi nuovi effetti + Add “copy” to Copied Layers and Groups Aggiungi «copia» ai livelli e ai gruppi duplicati + Preview on canvas when reordering layers Anteprima sulla tela durante il riordino dei livelli + Show layer mask badges Mostra distintivi delle maschere di livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a9dd16e1c..ecf28e8a57 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3454,3 +3454,19 @@ fontWeight Italic 斜体 Front Image 前面の画像 Search settings… 設定を検索… No matching settings 一致する設定はありません + Animation Options アニメーションオプション + Entire Document ドキュメント全体 + Filter Options フィルターオプション + Layer Bounds レイヤーの境界 + Layers Panel Options レイヤーパネルオプション + New Artboard… 新規アートボード… + New Group… 新規グループ… + Prevent Auto-Nesting 自動的な入れ子を防ぐ + Show Layers レイヤーを表示 + Thumbnail Contents サムネールの内容 + Thumbnail Size サムネールのサイズ + Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 + Expand New Effects 新しい効果を展開 + Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 + Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー + Show layer mask badges レイヤーマスクのバッジを表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991fc3328e..f109bfdc3f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3013,3 +3013,22 @@ fontWeight Italic 기울임 Front Image 전면 이미지 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 + Animation Options 애니메이션 옵션 + Entire Document 전체 문서 + Filter Options 필터 옵션 + Layer Bounds 레이어 경계 + Layers Panel Options 레이어 패널 옵션 + New Artboard… 새 아트보드… + New Group 새 그룹 + New Group… 새 그룹… + New Layer… 새 레이어… + Panel Options… 패널 옵션… + Prevent Auto-Nesting 자동 중첩 방지 + Show Layers 레이어 표시 + Thumbnail Contents 축소판 내용 + Thumbnail Size 축소판 크기 + Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 + Expand New Effects 새 효과 펼치기 + Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 + Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 + Show layer mask badges 레이어 마스크 배지 표시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4836d7e8b3..d7ec6cab69 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3613,3 +3613,19 @@ cameraRaw Whites Witten Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer + Animation Options Animatieopties + Entire Document Hele document + Filter Options Filteropties + Layer Bounds Laaggrenzen + Layers Panel Options Opties van het deelvenster Lagen + New Artboard… Nieuw tekengebied… + New Group… Nieuwe groep… + Prevent Auto-Nesting Automatisch nesten voorkomen + Show Layers Lagen tonen + Thumbnail Contents Inhoud van miniaturen + Thumbnail Size Grootte van miniaturen + Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen + Expand New Effects Nieuwe effecten uitvouwen + Add “copy” to Copied Layers and Groups „kopie” toevoegen aan gekopieerde lagen en groepen + Preview on canvas when reordering layers Voorvertoning op canvas bij herschikken van lagen + Show layer mask badges Badges voor laagmaskers tonen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f33ce9990e..d44d16c670 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3454,3 +3454,19 @@ cameraRaw Roughness Chropowatość Front Image Obraz na wierzchu Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień + Animation Options Opcje animacji + Entire Document Cały dokument + Filter Options Opcje filtrowania + Layer Bounds Granice warstwy + Layers Panel Options Opcje panelu Warstwy + New Artboard… Nowy obszar roboczy… + New Group… Nowa grupa… + Prevent Auto-Nesting Zapobiegaj automatycznemu zagnieżdżaniu + Show Layers Pokaż warstwy + Thumbnail Contents Zawartość miniaturek + Thumbnail Size Rozmiar miniaturek + Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia + Expand New Effects Rozwijaj nowe efekty + Add “copy” to Copied Layers and Groups Dodawaj „kopia” do skopiowanych warstw i grup + Preview on canvas when reordering layers Podgląd na obszarze roboczym podczas zmiany kolejności warstw + Show layer mask badges Pokaż odznaki masek warstw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 11b69ca234..cb133561fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2861,3 +2861,21 @@ fontWeight Italic Itálico Front Image Imagem frontal Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada + Animation Options Opções de animação + Entire Document Documento inteiro + Filter Options Opções de filtro + Layer Bounds Limites da camada + Layers Panel Options Opções do painel Camadas + Lock Layers Bloquear camadas + New Artboard… Nova prancheta… + New Group… Novo grupo… + New Layer Nova camada + Prevent Auto-Nesting Impedir aninhamento automático + Show Layers Mostrar camadas + Thumbnail Contents Conteúdo das miniaturas + Thumbnail Size Tamanho das miniaturas + Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento + Expand New Effects Expandir novos efeitos + Add “copy” to Copied Layers and Groups Adicionar “cópia” às camadas e grupos copiados + Preview on canvas when reordering layers Pré-visualizar na tela ao reordenar camadas + Show layer mask badges Mostrar emblemas de máscaras de camada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7163992a15..f069459064 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2719,3 +2719,21 @@ fontWeight Italic Курсив Swaps height and width Меняет местами высоту и ширину Search settings… Поиск настроек… No matching settings Нет подходящих настроек + Animation Options Параметры анимации + Entire Document Весь документ + Filter Options Параметры фильтра + Layer Bounds Границы слоя + Layers Panel Options Параметры панели «Слои» + Lock Layers Блокировка слоёв + New Artboard… Новая монтажная область… + New Group… Новая группа… + New Layer Новый слой + Prevent Auto-Nesting Запретить автоматическое вложение + Show Layers Показать слои + Thumbnail Contents Содержимое миниатюр + Thumbnail Size Размер миниатюр + Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок + Expand New Effects Раскрывать новые эффекты + Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам + Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв + Show layer mask badges Показывать значки типов масок слоя diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ca10067a0..fe7f3c9835 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3541,3 +3541,19 @@ cameraRaw Masking Маскування Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана + Animation Options Параметри анімації + Entire Document Увесь документ + Filter Options Параметри фільтрів + Layer Bounds Межі шару + Layers Panel Options Параметри панелі «Шари» + New Artboard… Нова монтажна область… + New Group… Нова група… + Prevent Auto-Nesting Заборонити автоматичне вкладення + Show Layers Показати шари + Thumbnail Contents Вміст мініатюр + Thumbnail Size Розмір мініатюр + Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки + Expand New Effects Розгортати нові ефекти + Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп + Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів + Show layer mask badges Показувати позначки масок шарів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a39ab4cfce..6cb6d5696f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2443,3 +2443,24 @@ fontWeight Italic 斜体 Front Image 前面的图像 Search settings… 搜索设置… No matching settings 没有匹配的设置 + Animation Options 动画选项 + Entire Document 整个文档 + Filter Options 过滤选项 + Layer Bounds 图层边界 + Layers Panel Options 图层面板选项 + Lock Layers 锁定图层 + New Artboard… 新建画板… + New Group 新建组 + New Group… 新建组… + New Layer 新建图层 + New Layer… 新建图层… + Panel Options… 面板选项… + Prevent Auto-Nesting 防止自动嵌套 + Show Layers 显示图层 + Thumbnail Contents 缩略图内容 + Thumbnail Size 缩略图大小 + Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 + Expand New Effects 展开新效果 + Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” + Preview on canvas when reordering layers 重新排列图层时在画布上预览 + Show layer mask badges 显示图层蒙版标记 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4454a7d61b..0e6ff387fe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2412,3 +2412,24 @@ fontWeight Italic 斜體 Front Image 前面的影像 Search settings… 搜尋設定… No matching settings 沒有符合的設定 + Animation Options 動畫選項 + Entire Document 整份文件 + Filter Options 篩選選項 + Layer Bounds 圖層邊界 + Layers Panel Options 圖層面板選項 + Lock Layers 鎖定圖層 + New Artboard… 新增工作區域… + New Group 新增群組 + New Group… 新增群組… + New Layer 新增圖層 + New Layer… 新增圖層… + Panel Options… 面板選項… + Prevent Auto-Nesting 防止自動巢狀 + Show Layers 顯示圖層 + Thumbnail Contents 縮圖內容 + Thumbnail Size 縮圖大小 + Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 + Expand New Effects 展開新效果 + Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 + Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 + Show layer mask badges 顯示圖層遮色片標記 diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index d265690597..67afffb1cc 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -42,6 +42,8 @@ pub enum Indicator { pub struct RowRects { pub layer: u64, pub row: Rect, + /// Visible layer thumbnail; None when thumbnails are disabled. + pub thumbnail: Option, /// The name as painted (after truncation); `None` when there was no room for it. pub name: Option, pub indicators: Vec<(Indicator, Rect)>, @@ -193,10 +195,58 @@ pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: F Some(r) } -/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout). +/// Photoshop-style Layers flyout commands. The dock owns tab-group controls, not this menu. pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { - let can = app.session.is_enabled("layer.setExpanded"); - if ui.add_enabled(can, egui::Button::new(tl!("Collapse All Groups"))).clicked() { + use crate::layers_panel_ui::run_menu_command; + use crate::state::LayersPanelDialog; + + let has_doc = app.session.active().is_some(); + if ui.add_enabled(has_doc, egui::Button::new(tl!("New Layer…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewLayer(String::new())); + ui.close(); + } + if ui.add_enabled(has_doc, egui::Button::new(tl!("New Group…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup(String::new())); + ui.close(); + } + ui.separator(); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + ui.separator(); + if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); + ui.close(); + } + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); + if any_hidden { + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + } else { + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + } + ui.separator(); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + ui.separator(); + ui.menu_button(tl!("Filter Options"), |ui| { + let mut show = app.ui.layers_panel_options.show_filters; + if ui.checkbox(&mut show, tl!("Show")).changed() { + app.ui.layers_panel_options.show_filters = show; + crate::layers_panel_ui::persist_options(app); + ui.close(); + } + }); + // Animation Options control Photoshop's frame-unification buttons. PhotoCraft's Layers + // panel does not expose those buttons; do not offer nonfunctional toggles. + ui.add_enabled_ui(false, |ui| { + ui.menu_button(tl!("Animation Options"), |_ui| {}); + }); + if ui.button(tl!("Panel Options…")).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::PanelOptions(app.ui.layers_panel_options.clone())); + ui.close(); + } + ui.separator(); + // Preserve PhotoCraft's existing group disclosure action when expanding the flyout. + let can_collapse = app.session.is_enabled("layer.setExpanded"); + if ui.add_enabled(can_collapse, egui::Button::new(tl!("Collapse All Groups"))).clicked() { if let Err(e) = app.run("layer.setExpanded", json!({"all": true, "expanded": false})) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 791095c1db..28a8e23d36 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -534,3 +534,32 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { let effects = open[0].fields["effects"].as_array().unwrap(); assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}"); } + +/// Integration regression: a display preference must resize the *actual painted* +/// thumbnails and row hitboxes, not only its Preview dialog. +#[test] +fn panel_thumbnail_sizes_change_real_row_geometry() { + use crate::state::LayerThumbnailSize; + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Photo"})).unwrap(); + session.execute("edit.fill", json!({"color": "#9acb32"})).unwrap(); + let layer = session.active().unwrap().active_layer.unwrap(); + let mut h = harness(session, 1.0, "promedium", 290.0); + for (size, expected_thumb, expected_row) in [ + (LayerThumbnailSize::None, 0.0, 23.0), + (LayerThumbnailSize::Small, 18.0, 32.0), + (LayerThumbnailSize::Medium, 24.0, 32.0), + (LayerThumbnailSize::Large, 48.0, 56.0), + ] { + h.state_mut().ui.layers_panel_options.thumbnail_size = size; + h.run_steps(5); + let found = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).expect("visible layer row"); + assert!((found.row.height() - expected_row).abs() < 0.1, "{size:?}: row {}", found.row.height()); + match found.thumbnail { + Some(rect) => assert!((rect.width() - expected_thumb).abs() < 0.1, "{size:?}: thumbnail {}", rect.width()), + None => assert_eq!(expected_thumb, 0.0, "{size:?} should display a thumbnail"), + } + assert!(found.name.is_some(), "{size:?}: layer name still visible"); + } +} diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs new file mode 100644 index 0000000000..181f60adf1 --- /dev/null +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -0,0 +1,469 @@ +//! Layers panel flyout and display preferences, observed from Photoshop's panel menu. +//! UI options do not edit document pixels or add history steps. +use egui::{self, Context, Ui}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions}; + +const OPTIONS_PREF: &str = "ui.layersPanelOptions"; + +/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. +pub fn persist_options(app: &mut PhotocraftApp) { + if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) { + app.session.prefs.edit(|p| { + p.dialogs.insert(OPTIONS_PREF.into(), value); + }); + } +} + +/// Restore independently from a saved workspace. Older preferences retain the defaults. +pub fn restore_options(app: &mut PhotocraftApp) { + if let Some(opts) = app.session.prefs().dialogs.get(OPTIONS_PREF) + && let Ok(value) = serde_json::from_value(opts.clone()) + { + app.ui.layers_panel_options = value; + } +} + +/// The real menu command dispatcher handles rename's in-place editor and other shell routing. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { + if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } +} + +/// Layer Bounds excludes fully transparent pixels and retains the layer's aspect ratio; +/// fully blank/non-raster layers fall back to document space. +pub fn thumbnail_region(app: &mut PhotocraftApp, doc: &Document, layer: &Layer, mode: LayerThumbnailContents) -> Rect { + if mode == LayerThumbnailContents::LayerBounds + && let Some(surface) = layer.surface() + { + let bounds = app.cached_bounds(layer.id.0, surface).intersect(&doc.bounds()); + if !bounds.is_empty() { + return bounds; + } + } + doc.bounds() +} + +/// Preview cache recomputes only when hovering a new target, not each pointer frame. +/// Compositor sees it as a temporary document and never commits it to history. +pub(crate) struct ReorderPreview { + pub source: photocraft_doc::DocId, + pub revision: u64, + pub signature: u64, + pub key: u64, + pub document: std::sync::Arc, +} + +pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Value) { + if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + app.reorder_preview = None; + return; + } + // Mark the destination as hovered even if the cached preview already matches. + app.reorder_preview_hit = true; + let Some(st) = app.session.active() else { return }; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + payload.to_string().hash(&mut hasher); + st.doc.id.0.hash(&mut hasher); + st.revision.hash(&mut hasher); + let signature = hasher.finish(); + if app.reorder_preview.as_ref().is_some_and(|cache| cache.signature == signature) { + return; + } + let (source, revision) = (st.doc.id, st.revision); + let add_copy = app.ui.layers_panel_options.add_copy_to_copied_layers_and_groups; + let preview = photocraft_engine::layer_multi_cmds::preview_move(&st.doc, payload, add_copy); + app.reorder_preview = preview.map(|document| ReorderPreview { + source, + revision, + signature, + key: (1u64 << 60) | (signature & ((1u64 << 60) - 1)), + document: std::sync::Arc::new(document), + }); +} + +pub(crate) fn display_reorder(app: &PhotocraftApp, index: usize) -> Option<(std::sync::Arc, u64)> { + if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + return None; + } + let st = app.session.documents().get(index)?; + let cached = app.reorder_preview.as_ref()?; + (cached.source == st.doc.id && cached.revision == st.revision).then(|| (cached.document.clone(), cached.key)) +} + +/// Photoshop shows the same flower at three resolutions. This example is original, +/// vector-painted artwork, not an extracted Adobe asset, and it is independent of +/// whichever layer happens to be selected (including adjustment layers). +fn paint_flower_example(ui: &Ui, rect: egui::Rect) { + use egui::{Color32, Rect as UiRect, Shape, Stroke, pos2, vec2}; + let painter = ui.painter().with_clip_rect(rect); + let side = rect.width(); + let point = |x: f32, y: f32| pos2(rect.min.x + x * side, rect.min.y + y * side); + // A monochrome miniature landscape: light sky, shaded hills and a dark meadow. + for n in 0..22 { + let y0 = n as f32 / 22.0; + let y1 = (n + 1) as f32 / 22.0; + let f = n as f32 / 22.0; + painter.rect_filled(UiRect::from_min_max(point(0.0, y0), point(1.0, y1)), 0.0, Color32::from_gray((119.0 + f * 30.0) as u8)); + } + painter.add(Shape::convex_polygon( + vec![point(0.0, 0.60), point(0.22, 0.36), point(0.46, 0.56), point(0.72, 0.33), point(1.0, 0.62)], + Color32::from_gray(80), + Stroke::NONE, + )); + painter.add(Shape::convex_polygon( + vec![point(0.0, 0.76), point(0.32, 0.60), point(0.57, 0.69), point(1.0, 0.51), point(1.0, 1.0), point(0.0, 1.0)], + Color32::from_gray(64), + Stroke::NONE, + )); + for n in 0..16 { + let x = ((n * 43 + 13) % 97) as f32 / 96.0; + let y = 0.78 + ((n * 11 % 19) as f32) / 100.0; + painter.line_segment([point(x, 1.0), point((x + 0.02).min(1.0), y)], Stroke::new(side * 0.01, Color32::from_gray(104))); + } + // A white daisy with layered petals and a dark central disc, in the same + // composition at all three sizes, so the controls genuinely preview scale. + let center = point(0.49, 0.57); + let unit = side * 0.34; + for n in 0..15 { + let theta = (n as f32 + 0.5) * std::f32::consts::TAU / 15.0; + let direction = vec2(theta.cos(), theta.sin()); + let normal = vec2(-theta.sin(), theta.cos()); + let mut outline = Vec::with_capacity(20); + for i in 0..=9 { + let t = i as f32 / 9.0; + let base = center + direction * unit * (0.22 + 0.83 * t); + outline.push(base + normal * (unit * 0.22 * (std::f32::consts::PI * t).sin())); + } + for i in (0..=9).rev() { + let t = i as f32 / 9.0; + let base = center + direction * unit * (0.22 + 0.83 * t); + outline.push(base - normal * (unit * 0.22 * (std::f32::consts::PI * t).sin())); + } + painter.add(Shape::convex_polygon( + outline, + if n % 3 == 0 { Color32::from_gray(213) } else { Color32::from_gray(238) }, + Stroke::new(0.3, Color32::from_gray(155)), + )); + } + painter.circle_filled(center, unit * 0.27, Color32::from_gray(33)); + painter.circle_filled(center - vec2(unit * 0.065, unit * 0.055), unit * 0.16, Color32::from_gray(55)); + for n in 0..16 { + let theta = (n as f32) * std::f32::consts::TAU / 16.0; + let r = unit * if n % 2 == 0 { 0.16 } else { 0.21 }; + painter.circle_filled(center + vec2(theta.cos(), theta.sin()) * r, (side * 0.008).max(0.3), Color32::from_gray(119)); + } + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::from_gray(92)), egui::StrokeKind::Inside); +} + +/// Returns both the actual thumbnail width and the height allocated by the Layers +/// row. Used by painting and by tests to keep the preference functional. +pub(crate) fn thumbnail_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32) { + let thumb: f32 = match size { + LayerThumbnailSize::None => 0.0, + LayerThumbnailSize::Small => 18.0, + LayerThumbnailSize::Medium => { + if pro { + 24.0 + } else { + 34.0 + } + } + LayerThumbnailSize::Large => 48.0, + }; + let row = if thumb == 0.0 { 23.0 } else { (thumb + 8.0).max(if pro { 32.0 } else { 40.0 }) }; + (thumb, row) +} + +/// Content column of the Photoshop-style dialog, with fixed-size radio/preview +/// slots rather than text labels that change the position of each thumbnail. +fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { + ui.group(|ui| { + ui.set_min_width(308.0); + ui.label(egui::RichText::new(tl!("Thumbnail Size")).strong()); + ui.add_space(7.0); + // The original Photoshop dialog has None with a label, then progressively + // larger previews, with the radio controls aligned vertically. + ui.horizontal(|ui| { + let r = ui.radio_value(&mut draft.thumbnail_size, LayerThumbnailSize::None, tl!("None")); + r.on_hover_text(tl!("None")); + }); + ui.add_space(3.0); + for (size, side, name) in [ + (LayerThumbnailSize::Small, 38.0, tl!("Small")), + (LayerThumbnailSize::Medium, 68.0, tl!("Medium")), + (LayerThumbnailSize::Large, 96.0, tl!("Large")), + ] { + ui.horizontal(|ui| { + // Empty radio captions are intentional: the sample itself conveys + // size. The accessible/hover label still names the setting. + let radio = ui.radio_value(&mut draft.thumbnail_size, size, ""); + radio.on_hover_text(name); + ui.add_space(8.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::click()); + paint_flower_example(ui, rect); + if response.clicked() { + draft.thumbnail_size = size; + } + response.on_hover_text(name); + }); + ui.add_space(5.0); + } + }); + ui.add_space(9.0); + ui.group(|ui| { + ui.set_min_width(308.0); + ui.label(egui::RichText::new(tl!("Thumbnail Contents")).strong()); + ui.add_space(10.0); + ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::LayerBounds, tl!("Layer Bounds")); + ui.add_space(4.0); + ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::EntireDocument, tl!("Entire Document")); + ui.add_space(4.0); + }); + ui.add_space(9.0); + ui.checkbox(&mut draft.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); + ui.add_space(3.0); + ui.checkbox(&mut draft.expand_new_effects, tl!("Expand New Effects")); + ui.add_space(3.0); + ui.checkbox(&mut draft.add_copy_to_copied_layers_and_groups, tl!("Add “copy” to Copied Layers and Groups")); + ui.add_space(3.0); + ui.checkbox(&mut draft.preview_on_canvas_when_reordering_layers, tl!("Preview on canvas when reordering layers")); + ui.add_space(3.0); + ui.checkbox(&mut draft.show_layer_mask_badges, tl!("Show layer mask badges")); +} + +pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { + let Some(mut dialog) = app.ui.layers_panel_dialog.take() else { return }; + let mut confirmed = false; + let mut cancelled = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let title = match &dialog { + LayersPanelDialog::PanelOptions(_) => tl!("Layers Panel Options"), + LayersPanelDialog::NewLayer(_) => tl!("New Layer"), + LayersPanelDialog::NewGroup(_) => tl!("New Group"), + LayersPanelDialog::LockLayers { .. } => tl!("Lock Layers"), + }; + egui::Modal::new(egui::Id::new("layers-panel-options-modal")).show(ctx, |ui| { + ui.set_width(if matches!(dialog, LayersPanelDialog::PanelOptions(_)) { 480.0 } else { 380.0 }); + ui.horizontal(|ui| { + ui.heading(title); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("×").on_hover_text(tl!("Cancel")).clicked() { + cancelled = true; + } + }); + }); + ui.separator(); + match &mut dialog { + LayersPanelDialog::PanelOptions(opts) => { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(345.0); + ui.vertical(|ui| options_body(ui, opts)); + }); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.set_min_width(74.0); + if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("OK"))).clicked() { + confirmed = true; + } + ui.add_space(6.0); + if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("Cancel"))).clicked() { + cancelled = true; + } + }); + }); + } + LayersPanelDialog::NewLayer(name) | LayersPanelDialog::NewGroup(name) => { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + } + LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => { + ui.checkbox(transparency, tl!("Transparency")); + ui.checkbox(pixels, tl!("Image")); + ui.checkbox(position, tl!("Position")); + ui.checkbox(artboard, tl!("Prevent Auto-Nesting")); + ui.checkbox(all, tl!("All")); + } + } + if !matches!(dialog, LayersPanelDialog::PanelOptions(_)) { + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button(tl!("OK")).clicked() { + confirmed = true; + } + if ui.button(tl!("Cancel")).clicked() { + cancelled = true; + } + }); + } + }); + if confirmed && !cancelled { + let r = match dialog { + LayersPanelDialog::PanelOptions(options) => { + app.ui.layers_panel_options = options; + persist_options(app); + Ok(()) + } + LayersPanelDialog::NewLayer(name) => { + app.run("layer.new.layer", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ()) + } + LayersPanelDialog::NewGroup(name) => { + app.run("layer.new.group", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ()) + } + LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => app + .run("layer.lockLayers", json!({"transparency": transparency, "pixels": pixels, "position": position, "artboard": artboard, "all": all})) + .map(|_| ()), + }; + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } + } else if !cancelled { + app.ui.layers_panel_dialog = Some(dialog); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_panel_options_match_medium_document_previews() { + let options = LayersPanelOptions::default(); + assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium); + assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument); + assert!(options.show_filters); + assert!(options.use_default_masks_on_fill_layers); + assert!(options.expand_new_effects); + assert!(options.add_copy_to_copied_layers_and_groups); + assert!(options.preview_on_canvas_when_reordering_layers); + assert!(!options.show_layer_mask_badges); + } + + #[test] + fn thumbnail_contents_crop_to_pixels_and_fall_back_for_blank_layers() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 100, "height": 60})).unwrap(); + let id = session.execute("layer.new.layer", json!({"name": "mark"})).unwrap()["layer"].as_u64().unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let doc = app.session.active().unwrap().doc.clone(); + let layer = doc.layer(photocraft_doc::LayerId(id)).unwrap(); + let original = thumbnail_region(&mut app, &doc, layer, LayerThumbnailContents::LayerBounds); + assert_eq!(original, doc.bounds(), "empty raster layers use document dimensions"); + app.run("select.rect", json!({"x": 70, "y": 10, "width": 5, "height": 12})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let updated = app.session.active().unwrap().doc.clone(); + let layer = updated.layer(photocraft_doc::LayerId(id)).unwrap(); + let cropped = thumbnail_region(&mut app, &updated, layer, LayerThumbnailContents::LayerBounds); + assert_eq!(cropped, Rect::new(70, 10, 75, 22)); + assert_eq!(thumbnail_region(&mut app, &updated, layer, LayerThumbnailContents::EntireDocument), updated.bounds()); + } + + #[test] + fn options_round_trip_without_a_pending_dialog() { + let mut ui = crate::state::UiState::default(); + ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + ui.layers_panel_options.thumbnail_contents = LayerThumbnailContents::LayerBounds; + ui.layers_panel_options.show_filters = false; + ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup("test".into())); + let json = serde_json::to_value(&ui).unwrap(); + assert!(json.get("layers_panel_dialog").is_none()); + let restored: crate::state::UiState = serde_json::from_value(json).unwrap(); + assert_eq!(restored.layers_panel_options, ui.layers_panel_options); + assert!(restored.layers_panel_dialog.is_none()); + } + + #[test] + fn disabling_default_fill_masks_never_targets_an_absent_mask() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; + persist_options(&mut app); + app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_none()); + assert!(!app.ui.mask_target && !app.ui.vector_mask_target); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; + persist_options(&mut app); + app.run("layer.newFillLayer.gradient", json!({})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_some()); + assert!(app.ui.mask_target); + } + + #[test] + fn prefs_options_save_and_restore_all_switches() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let saved = LayersPanelOptions { + thumbnail_size: LayerThumbnailSize::Large, + thumbnail_contents: LayerThumbnailContents::LayerBounds, + show_filters: false, + use_default_masks_on_fill_layers: false, + expand_new_effects: false, + add_copy_to_copied_layers_and_groups: false, + preview_on_canvas_when_reordering_layers: false, + show_layer_mask_badges: true, + }; + app.ui.layers_panel_options = saved.clone(); + persist_options(&mut app); + assert_eq!(app.session.prefs().dialogs[OPTIONS_PREF]["showLayerMaskBadges"], true); + app.ui.layers_panel_options = LayersPanelOptions::default(); + restore_options(&mut app); + assert_eq!(app.ui.layers_panel_options, saved); + } + + #[test] + fn flyout_flatten_routes_to_real_engine_and_undo_restores_layers() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 18, "height": 12})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Red pixels"})).unwrap(); + session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let count = app.session.active().unwrap().doc.layers.len(); + assert!(count >= 2); + // The flyout uses menus::invoke with this exact command id. + crate::menus::invoke(&mut app, &egui::Context::default(), "layer.flattenImage", json!({})).unwrap(); + let flattened = app.session.active().unwrap(); + assert_eq!(flattened.doc.layers.len(), 1); + assert_eq!(flattened.doc.layers[0].name, "Background"); + assert!(flattened.doc.layers[0].surface().is_some()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), count); + } + + #[test] + fn geometry_matches_four_real_panel_sizes() { + for pro in [false, true] { + let mut previous = -1.0; + for size in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let (thumb, row) = thumbnail_geometry(size, pro); + assert!(thumb > previous && row >= thumb, "{size:?}: {thumb}/{row}"); + previous = thumb; + } + } + assert_eq!(thumbnail_geometry(LayerThumbnailSize::None, true), (0.0, 23.0)); + assert_eq!(thumbnail_geometry(LayerThumbnailSize::Large, true), (48.0, 56.0)); + } + + #[test] + fn panel_options_missing_fields_deserialize_with_defaults() { + let options: LayersPanelOptions = serde_json::from_value(json!({})).unwrap(); + assert_eq!(options, LayersPanelOptions::default()); + let saved = serde_json::to_value(&options).unwrap(); + assert_eq!(saved["thumbnailSize"], "medium"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 012b4ac835..f395aaeeb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -92,6 +92,7 @@ pub mod layer_row_ui; pub mod layer_style; mod layer_transfer; pub mod layer_tree_ui; +pub mod layers_panel_ui; pub mod links; pub mod liquify_ui; pub mod magnetic_lasso_ui; @@ -425,6 +426,10 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, + /// Layers panel drag: temporary reordered document displayed until mouse release. + pub(crate) reorder_preview: Option, + /// Whether the currently dragged layer is over a valid destination this frame. + pub(crate) reorder_preview_hit: bool, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, pub(crate) shape_stroke_preview: Option, @@ -626,6 +631,8 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, blend_preview: None, + reorder_preview: None, + reorder_preview_hit: false, patch_preview: None, shape_stroke_preview: None, magnetic: Default::default(), @@ -715,6 +722,7 @@ impl PhotocraftApp { }; // Saved preferences are in place before the first frame; recovery starts in upkeep. prefs_ui::load(&mut app); + layers_panel_ui::restore_options(&mut app); // After the saved preferences, which say whether the set was already offered. kys_import::offer_import(&mut app); notices::wayland_file_drop_guidance(&mut app); @@ -843,10 +851,21 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { - // Adding a layer mask targets it, as in Photoshop (#2166). + if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { + // Adding an explicit mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; + } else if r.is_ok() && creates_adjustment_or_fill { + // Panel Options may suppress a fill layer's default mask. Never target a + // nonexistent mask; an active path creates a vector mask instead. + let masks = self + .session + .active() + .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) + .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) + .unwrap_or((false, false)); + self.ui.mask_target = masks.0; + self.ui.vector_mask_target = !masks.0 && masks.1; } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. @@ -1482,6 +1501,7 @@ impl eframe::App for PhotocraftApp { workspace_ui::windows(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + layers_panel_ui::show_dialog(self, &ctx); jobs_ui::dialog(self, &ctx); discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); @@ -1629,7 +1649,13 @@ impl PhotocraftApp { pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> egui::TextureId { // Key by content, not document revision: COW tiles change pointer only when their pixels // change, so unrelated edits (e.g. painting another layer) don't rebuild this thumbnail. - let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40; + let mode = self.ui.layers_panel_options.thumbnail_contents; + let region = layers_panel_ui::thumbnail_region(self, doc, layer, mode); + let rev = layer.surface().map_or(0, surface_fingerprint) + ^ (region.x0 as i64 as u64).rotate_left(9) + ^ (region.y0 as i64 as u64).rotate_left(21) + ^ (region.width() as u64).rotate_left(33) + ^ (region.height() as u64).rotate_left(45); let key = (layer.id, mask_thumbs_ui::THUMB_LAYER); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev @@ -1637,7 +1663,7 @@ impl PhotocraftApp { return tex.id(); } let mut px = [0.0f32; 8]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image_region(region, 64, |x, y| { let Some(s) = layer.surface() else { return [0.0; 4] }; let n = s.channels(); s.read_pixel(x, y, &mut px[..n]); @@ -1655,7 +1681,7 @@ impl PhotocraftApp { return tex.id(); } let mut v = [0.0f32; 1]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image_region(doc.bounds(), 64, |x, y| { mask.surface.read_pixel(x, y, &mut v); [v[0], v[0], v[0], 1.0] }); @@ -1694,9 +1720,9 @@ pub fn surface_fingerprint(s: &photocraft_raster::Surface) -> u64 { h } -/// Square thumbnail of the canvas area, letterboxed, sampling `f(x, y)` in document space. -fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { - let (w, h) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); +/// Sample a thumbnail from an arbitrary document-space region (Layer Bounds or Entire Document). +fn thumb_image_region(region: photocraft_geom::Rect, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { + let (w, h) = (region.width().max(1) as f32, region.height().max(1) as f32); let scale = w.max(h) / side as f32; let (ox, oy) = ((side as f32 - w / scale) / 2.0, (side as f32 - h / scale) / 2.0); let mut px = vec![egui::Color32::TRANSPARENT; side * side]; @@ -1707,7 +1733,7 @@ fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; if dx < 0.0 || dy < 0.0 || dx >= w || dy >= h { continue; } - let c = f(dx as i32, dy as i32); + let c = f(region.x0.saturating_add(dx as i32), region.y0.saturating_add(dy as i32)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; px[ty * side + tx] = egui::Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); } diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 562b2b7521..4e74a92ba3 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -143,6 +143,12 @@ pub fn paint( painter.line_segment([k.right_top(), k.left_bottom()], st); } } + if app.ui.layers_panel_options.show_layer_mask_badges { + // Distinguish pixel and vector masks without altering their thumbnails. + let badge = Rect::from_min_size(pos2(r.right() - 9.0, r.bottom() - 9.0), vec2(9.0, 9.0)); + painter.rect_filled(badge, 1.0, t.card); + crate::icons::paint(ui, badge, if kind == MaskKind::Vector { "pen-tool" } else { "circle-dot" }, 8.0, t.text); + } painter.rect_stroke( r, if t.pro { 0.0 } else { 4.0 }, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e2aa0f5f5..2e31032476 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,7 +13,7 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { if app.camera_raw.is_some() || crate::crop_ui::blocks(app, id) { return false; } - (app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) + (app.ui.dialogs.is_empty() && app.ui.layers_panel_dialog.is_none() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) } /// Top-level menus in Photoshop order. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2afb9fd44a..0eb2be8d7e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1857,7 +1857,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut actions: Vec<(String, Value)> = Vec::new(); let t = Tokens::get(ui.ctx()); - if t.pro { + app.reorder_preview_hit = false; + if !ui.input(|i| i.pointer.primary_down()) || !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + app.reorder_preview = None; + } + if t.pro && app.ui.layers_panel_options.show_filters { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); @@ -2050,6 +2054,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push(done); } }); + // Leaving the list or hovering an invalid target immediately cancels the temporary + // image. The release still uses the actual command, not the preview snapshot. + if !app.reorder_preview_hit { + app.reorder_preview = None; + } // A layer being dragged can also be dropped on the footer's Delete, New Layer and Group // buttons (#736); read the drag before it ends. let footer_drag = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))).map(|id| { @@ -2307,8 +2316,8 @@ fn layer_row( ) -> Rect { let selected = row.selected; let t = Tokens::get(ctx); - // Photoshop's default (medium) thumbnails: 32 pt rows. - let row_h = if t.pro { 32.0 } else { 46.0 }; + // The Layers Panel Options thumbnail size controls rows and both layer/mask thumbnails. + let (base, row_h) = crate::layers_panel_ui::thumbnail_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); @@ -2371,8 +2380,8 @@ fn layer_row( + if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 } + if l.clipped { 12.0 } else { 0.0 } + crate::layer_row_ui::reserved_width(l); - let ts = crate::mask_thumbs_ui::thumb_size(l, if t.pro { 24.0 } else { 34.0 }, rect.width() - others); - let fixed = others + ts + 6.0 + crate::mask_thumbs_ui::width(l, ts); + let ts = if base == 0.0 { 0.0 } else { crate::mask_thumbs_ui::thumb_size(l, base, rect.width() - others) }; + let fixed = others + if ts == 0.0 { 0.0 } else { ts + 6.0 + crate::mask_thumbs_ui::width(l, ts) }; x += 28.0 + crate::layer_row_ui::indent(depth, rect.width(), fixed); let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions); if l.clipped { @@ -2380,15 +2389,17 @@ fn layer_row( x += 12.0; } let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts)); - draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary); - x += ts + 6.0; - // Link chains and pixel / vector mask thumbnails (#153). - let masks = crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions); + if ts > 0.0 { + draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary); + x += ts + 6.0; + } + // With thumbnails disabled the labels and disclosure controls remain interactive. + let masks = if ts > 0.0 { crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions) } else { Default::default() }; let mask_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Pixel); let vector_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Vector); // Photoshop frames the targeted thumbnail (pixels, mask or vector mask) of the active layer // with corner brackets. - if row.primary { + if row.primary && ts > 0.0 { let target = if app.ui.vector_mask_target && vector_rect.is_some() { vector_rect } else if app.ui.mask_target { @@ -2415,7 +2426,7 @@ fn layer_row( // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. let italic = !t.pro && l.name == "Background" && l.locks.transparency; // Photoshop rows show only the name; the kind sub-label is a Studio-theme addition. - let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_)); + let is_pixel = t.pro || ts == 0.0 || matches!(l.content, LayerContent::Raster(_)); let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| { let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 }); let r = Rect::from_min_size(text_pos, galley.size()); @@ -2430,7 +2441,10 @@ fn layer_row( }; crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint); } - crate::layer_row_ui::record(ctx, crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, name: name_rect, indicators }); + crate::layer_row_ui::record( + ctx, + crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, thumbnail: (ts > 0.0).then_some(thumb), name: name_rect, indicators }, + ); // ⌘-click a layer, mask or vector-mask thumbnail loads its transparency / mask / path as a // selection (⇧ add, ⌥ subtract, ⇧⌥ intersect) instead of changing the layer selection. let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -2438,7 +2452,8 @@ fn layer_row( if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } - thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + (ts > 0.0 && thumb.expand(2.0).contains(p)) + .then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) }); // ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -2456,7 +2471,7 @@ fn layer_row( // defaults to its mask, but clicking its content thumbnail must leave the mask target. let pos = resp.interact_pointer_pos(); let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p)); - let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); + let on_thumb = ts > 0.0 && pos.is_some_and(|p| thumb.expand(2.0).contains(p)); let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector); // Clicking the layer thumbnail leaves mask view (#196). let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id); @@ -2499,9 +2514,9 @@ fn layer_row( } } else if pos.and_then(|p| masks.hit(p)).is_none() { let id = match &l.content { - LayerContent::Adjustment(_) | LayerContent::Fill(_) if on(thumb) => "layer.layerContentOptions", - LayerContent::Smart(_) if on(thumb) => "layer.smartObjects.editContents", - LayerContent::Text(_) if on(thumb) => "type.editText", + LayerContent::Adjustment(_) | LayerContent::Fill(_) if ts > 0.0 && on(thumb) => "layer.layerContentOptions", + LayerContent::Smart(_) if ts > 0.0 && on(thumb) => "layer.smartObjects.editContents", + LayerContent::Text(_) if ts > 0.0 && on(thumb) => "type.editText", _ => "layer.layerStyle.blendingOptions", }; if crate::menus::is_enabled(app, id) { @@ -2561,7 +2576,9 @@ fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, // Keep row layout and outside thumbnail decorations when the image is clipped. if ui.is_rect_visible(rect) { // The texture is a letterboxed square: draw only the part the document fills. - let (fitted, uv) = layer_thumb_fit(rect, doc.size.width, doc.size.height); + let mode = app.ui.layers_panel_options.thumbnail_contents; + let bounds = crate::layers_panel_ui::thumbnail_region(app, doc, l, mode); + let (fitted, uv) = layer_thumb_fit(rect, bounds.width().max(1), bounds.height().max(1)); widgets::checker(p, fitted, 5.0); let tex = app.layer_thumb(ctx, doc, l); p.image(tex, fitted, uv, Color32::WHITE); @@ -3207,7 +3224,7 @@ fn layer_drop_payload(dragged: u64, target: LayerId, position: &str, selected: & /// release the layers stay put and copies land there instead (Photoshop). /// Multi-layer moves are atomic (one undo step), using the engine's stable document order. fn layer_drag_and_drop( - app: &PhotocraftApp, + app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, l: &Layer, @@ -3249,12 +3266,15 @@ fn layer_drag_and_drop( } else { painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); } + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + if let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } if released { - let mut payload = layer_drop_payload(dragged, l.id, position, &selected); - if let Some(o) = payload.as_object_mut() { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); + } else if ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); } } return; @@ -3283,12 +3303,16 @@ fn layer_drag_and_drop( painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); } } + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + if copy && let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } + if !released && ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); + } if released { - let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); - let mut payload = layer_drop_payload(dragged, l.id, position, &selected); - if copy && let Some(o) = payload.as_object_mut() { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); } } @@ -3298,7 +3322,7 @@ fn layer_drag_and_drop( /// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it /// was grabbed from is left alone: the engine can only copy it beside each of its own layers. fn layer_drop_zone( - app: &PhotocraftApp, + app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, zone: Rect, @@ -3318,14 +3342,17 @@ fn layer_drop_zone( let t = Tokens::get(ctx); let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] }; ui.painter().line_segment(line, Stroke::new(2.0, t.accent)); + let mut payload = layer_drop_payload(dragged, target.0, position, &selected); + if ctx.input(|i| i.modifiers.alt) + && let Some(o) = payload.as_object_mut() + { + o.insert("copy".into(), json!(true)); + } if ctx.input(|i| i.pointer.any_released()) { - let mut payload = layer_drop_payload(dragged, target.0, position, &selected); - if ctx.input(|i| i.modifiers.alt) - && let Some(o) = payload.as_object_mut() - { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); + } else if ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); } } @@ -3387,6 +3414,7 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); + let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3394,7 +3422,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3411,7 +3439,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3433,7 +3461,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(11.5), + egui::FontId::proportional(font_size), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..89b70e927b 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,10 +1,11 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; +use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -32,6 +33,18 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } +/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column +/// remains independently clickable, while the filter mask and text keep their +/// own alignment and avoid overlap when the thumbnail is set to None. +pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { + let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); + let height = row.max(22.0); + let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; + let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; + let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; + (height, mask, eye, font) +} + /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -40,10 +53,11 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); + let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -61,7 +75,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -70,12 +84,24 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: }); } let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() { - let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); + if index.is_none() && mask_size > 0.0 { + // The Smart Filters mask scales along with the thumbnail size, not + // independently of the selected Layers panel display preference. + let sw = Rect::from_center_size(pos2(x + mask_size / 2.0, rect.center().y), vec2(mask_size, mask_size)); ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + let text_x = if index.is_none() && mask_size > 0.0 { x + mask_size + 7.0 } else { x }; + crate::layer_row_ui::label( + ui.painter(), + text_x, + rect.center().y, + rect.right() - crate::layer_row_ui::RIGHT_PAD, + &name, + egui::FontId::proportional(font_size), + if on { t.text_dim } else { t.text_faint }, + ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if let Some(i) = index && resp.double_clicked() { @@ -121,3 +147,33 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod layer_thumbnail_scaling_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { + for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); + session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); + assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); + let mut h = Harness::builder().with_size(vec2(1440.0, 950.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.state_mut().ui.layers_panel_options.thumbnail_size = setting; + h.run_steps(8); + let expected = smart_filter_row_geometry(setting, true).0; + for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask"] { + let rect = h.get_by_label(label).rect(); + assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); + } + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5e6021b668..4665db2d63 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -3,6 +3,68 @@ use serde::{Deserialize, Serialize}; +/// Photoshop-style Layers panel display preferences; part of serialized UI state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LayerThumbnailSize { + None, + Small, + #[default] + Medium, + Large, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LayerThumbnailContents { + LayerBounds, + #[default] + EntireDocument, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayersPanelOptions { + pub thumbnail_size: LayerThumbnailSize, + pub thumbnail_contents: LayerThumbnailContents, + pub show_filters: bool, + pub use_default_masks_on_fill_layers: bool, + pub expand_new_effects: bool, + pub add_copy_to_copied_layers_and_groups: bool, + pub preview_on_canvas_when_reordering_layers: bool, + pub show_layer_mask_badges: bool, +} + +impl LayersPanelOptions { + pub fn defaults() -> Self { + Self { + thumbnail_size: LayerThumbnailSize::Medium, + thumbnail_contents: LayerThumbnailContents::EntireDocument, + show_filters: true, + use_default_masks_on_fill_layers: true, + expand_new_effects: true, + add_copy_to_copied_layers_and_groups: true, + preview_on_canvas_when_reordering_layers: true, + show_layer_mask_badges: false, + } + } +} + +impl Default for LayersPanelOptions { + fn default() -> Self { + Self::defaults() + } +} + +/// Pending dialog data is transient: Cancel never changes persisted options. +#[derive(Clone, Debug, PartialEq)] +pub enum LayersPanelDialog { + PanelOptions(LayersPanelOptions), + NewLayer(String), + NewGroup(String), + LockLayers { transparency: bool, pixels: bool, position: bool, artboard: bool, all: bool }, +} + /// Shared selection and capture for a point curve; document parameters remain with the host. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -948,6 +1010,12 @@ pub struct UiState { /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Thumbnail dimensions, content cropping and filter-strip visibility. + #[serde(default)] + pub layers_panel_options: LayersPanelOptions, + /// Transient dialog; never restored with a workspace. + #[serde(skip)] + pub layers_panel_dialog: Option, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -1082,6 +1150,8 @@ impl Default for UiState { slices: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layers_panel_options: LayersPanelOptions::default(), + layers_panel_dialog: None, pen: None, stroke_editor: None, direct_selection: Default::default(), From f9f1d823cc14aa377707d4fc17134ea83b5e3db4 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:59:42 +0300 Subject: [PATCH 0007/1452] Make Smart Filter editors distinct and preserve alpha at partial strength --- crates/engine/src/fx_view_cmds.rs | 42 ++- crates/engine/src/smart_cmds.rs | 25 +- crates/engine/src/smart_cmds/tests.rs | 35 +++ crates/ui-egui/src/adjust_dialog.rs | 7 +- crates/ui-egui/src/adjust_preview.rs | 3 + crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/dialogs.rs | 12 +- crates/ui-egui/src/filter_dialog.rs | 70 ++++- crates/ui-egui/src/filter_preview_worker.rs | 7 +- crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/layer_row_ui.rs | 29 ++- crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/mask_thumbs_ui.rs | 1 + crates/ui-egui/src/menus.rs | 12 + crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/smart_filter_dialog.rs | 227 ++++++++++++++++ .../ui-egui/src/smart_filter_dialog/tests.rs | 246 ++++++++++++++++++ crates/ui-egui/src/smart_ui.rs | 115 ++++++-- crates/ui-egui/src/smart_ui/tests.rs | 154 +++++++++++ docs/smart-filters.md | 51 ++++ 35 files changed, 1139 insertions(+), 56 deletions(-) create mode 100644 crates/ui-egui/src/smart_filter_dialog.rs create mode 100644 crates/ui-egui/src/smart_filter_dialog/tests.rs create mode 100644 crates/ui-egui/src/smart_ui/tests.rs create mode 100644 docs/smart-filters.md diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 03241d8405..7c35959df0 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -1,8 +1,8 @@ -//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside -//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a +//! Layers panel: showing or hiding a layer's effects and Smart Filters under its row (the +//! shared disclosure triangle). View state ([`crate::DocState::fx_collapsed`]): no history step, and a //! clean document stays clean. -use photocraft_doc::LayerId; +use photocraft_doc::{Layer, LayerContent, LayerId}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -16,7 +16,11 @@ fn bad(msg: impl Into) -> EngineError { fn has_effects(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) } + if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) } +} + +fn has_children(l: &Layer) -> bool { + !l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) } fn opt_bool(p: &Value, key: &str) -> Result> { @@ -37,12 +41,12 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?), Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)), }; - let with_fx: Vec = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect(); + let with_fx: Vec = st.doc.walk().iter().filter(|(_, _, l)| has_children(l)).map(|(_, _, l)| l.id).collect(); let current = match layer { Some(id) => { let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if l.effects.items.is_empty() { - return Err(bad(format!("layer {} has no effects", id.0))); + if !has_children(l) { + return Err(bad(format!("layer {} has no effects or smart filters", id.0))); } !st.fx_collapsed.contains(&id) } @@ -65,7 +69,7 @@ pub fn specs() -> Vec { label: "Expand/Collapse Effects", menu: &[], shortcut: None, - params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##, + params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects or smart filters; view state, not an undo step)"##, enabled: has_effects, run: set_expanded, journal: false, @@ -92,6 +96,28 @@ mod tests { !s.active().unwrap().fx_collapsed.contains(&LayerId(id)) } + #[test] + fn smart_filters_share_the_effects_disclosure_without_an_edit() { + let (mut s, a, b) = session(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + let doc = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let steps = s.active().unwrap().history.past_len(); + s.execute(ID, json!({"layer": smart.0, "expanded": false})).unwrap(); + assert!(!open(&s, smart.0)); + assert!(open(&s, b)); + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.past_len(), steps); + s.execute(ID, json!({"expanded": false, "all": true})).unwrap(); + assert!(!open(&s, smart.0) && !open(&s, b)); + s.execute(ID, json!({"expanded": true, "all": true})).unwrap(); + assert!(s.active().unwrap().fx_collapsed.is_empty()); + } + #[test] fn toggles_one_layer_or_all_without_history() { let (mut s, a, b) = session(); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6e0c043578..d80596a681 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -278,8 +278,11 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc // ---------- rendering ---------- -/// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options). +/// Mix a filter's result with its input, without stacking the same pixel coverage twice. fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface { + if opacity <= 0.0 { + return base.clone(); + } let fmt = base.format(); let area = base.content_bounds().union(&top.content_bounds()); let mut out = base.clone(); @@ -290,7 +293,25 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) let (b, t) = (base.read_region(area), top.read_region(area)); let mut o = vec![0.0f32; b.len()]; for ((bp, tp), op) in b.chunks_exact(n).zip(t.chunks_exact(n)).zip(o.chunks_exact_mut(n)) { - let rgba = photocraft_color::blend::composite(mode, photocraft_raster::to_rgba(&fmt, bp), photocraft_raster::to_rgba(&fmt, tp), opacity); + let b = photocraft_raster::to_rgba(&fmt, bp); + let t = photocraft_raster::to_rgba(&fmt, tp); + let cb = [b[0], b[1], b[2]]; + let ct = [t[0], t[1], t[2]]; + let color = if fmt.sample == photocraft_color::SampleType::F32 { + photocraft_color::blend::blend_rgb_hdr(mode, cb, ct) + } else { + photocraft_color::blend::blend_rgb(mode, cb, ct) + }; + // A filter edits this surface, rather than adding a second source-over layer. Mix + // premultiplied results so alpha-preserving filters keep coverage at every opacity, + // while filters that actually change coverage (e.g. blur) interpolate it too. + let a = b[3] + (t[3] - b[3]) * opacity; + let mut rgba = [0.0, 0.0, 0.0, a]; + if a > 0.0 { + for c in 0..3 { + rgba[c] = (cb[c] * b[3] * (1.0 - opacity) + color[c] * t[3] * opacity) / a; + } + } photocraft_raster::from_rgba_into(&fmt, rgba, op); } out.write_region(area, &o); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 2ee54322bf..b496e04514 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -7,6 +7,41 @@ const DEPTHS: [u64; 3] = [8, 16, 32]; const W: i32 = 64; const H: i32 = 48; +#[test] +fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { + for sample in photocraft_color::SampleType::ALL { + let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let mut base = Surface::new(fmt); + let mut filtered = Surface::new(fmt); + base.fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.4, 0.6, 0.5]); + filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.5]); + let before = base.pixel(0, 0); + let after = filtered.pixel(0, 0); + let tolerance = match sample { + photocraft_color::SampleType::U8 => 1.0 / 255.0, + photocraft_color::SampleType::U16 => 1.0 / 65535.0, + photocraft_color::SampleType::F32 => 1e-6, + }; + for opacity in [0.0, 0.01, 0.5, 1.0] { + for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Luminosity] { + let mixed = blend_surfaces(&base, &filtered, mode, opacity).pixel(0, 0); + assert!((mixed[3] - before[3]).abs() <= tolerance); + if mode == BlendMode::Normal { + for c in 0..3 { + assert!((mixed[c] - (before[c] + (after[c] - before[c]) * opacity)).abs() <= tolerance); + } + } + } + } + filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.0]); + let mixed = blend_surfaces(&base, &filtered, BlendMode::Normal, 0.5).pixel(0, 0); + assert!((mixed[3] - before[3] * 0.5).abs() <= tolerance); + for c in 0..3 { + assert!((mixed[c] - before[c]).abs() <= tolerance); + } + } +} + fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index a4b831ed2f..7c6157f389 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -176,7 +176,12 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), diff --git a/crates/ui-egui/src/adjust_preview.rs b/crates/ui-egui/src/adjust_preview.rs index d94d58bff6..c6aa8fc529 100644 --- a/crates/ui-egui/src/adjust_preview.rs +++ b/crates/ui-egui/src/adjust_preview.rs @@ -186,6 +186,9 @@ struct GpuProxy { /// The open adjustment dialog: (kind, params, Preview on). fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> { let d = app.ui.dialogs.iter().find(|d| crate::adjust_dialog::owns(&d.fields))?; + if d.fields.contains_key("__smartFilter") { + return None; + } let kind = d.fields.get("__adjust")?.as_str()?.to_string(); let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); Some((kind, crate::filter_dialog::params_of(&d.fields), preview)) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a1fcda0adb..f5f29af832 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1373,10 +1373,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } let cmd = d.fields.get("__command")?.as_str()?.to_string(); // Previews edit what the command will: a targeted layer mask included (#780). - let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + let (cmd, params) = if d.fields.contains_key("__smartFilter") { + crate::smart_filter_dialog::edit_command(app, &d.fields).ok()? + } else { + (cmd.clone(), app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields))) + }; let st = app.session.documents().get(idx)?; // Filters whose features don't scale with a proxy preview at full resolution (#2063). - let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); + let k = crate::filter_dialog::smart_preview_factor(&st.doc, &cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, dialog: d.id, active: st.active_layer, command: cmd, params, k }; let key = request.doc.0 ^ (1u64 << 61); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c4a700aef7..b1c3d678b9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -226,6 +226,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if fields.contains_key("__export") => crate::export_dialog::body(app, ui, &mut fields), // Image Size and Canvas Size open on Width, selected (#2329). DialogKind::Command if fields.contains_key("__sizing") => first_field_focused(ui, id, |ui| crate::sizing::body(ui, &mut fields)), + DialogKind::Command if crate::smart_filter_dialog::owns(&fields) => crate::smart_filter_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__filter") => { // Color Settings: the monitor profile in use can change while it is open. @@ -385,6 +386,9 @@ fn about_tab_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, tab: &str) { /// The dialog title as shown: [`title`] in the UI language. fn display_title(d: &Dialog) -> String { + if crate::smart_filter_dialog::owns(&d.fields) { + return crate::smart_filter_dialog::title(&d.fields); + } match d.kind { DialogKind::Command => { let label = d.fields.get("__label").and_then(Value::as_str).unwrap_or("Command"); @@ -441,7 +445,13 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { app.filter_preview = None; return Err("dialog has no command".into()); }; - let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields)); + let (cmd, params) = match crate::smart_ui::confirm_command(app, &d.fields, cmd, crate::filter_dialog::params_of(&d.fields)) { + Ok(edit) => edit, + Err(e) => { + app.filter_preview = None; + return Err(e); + } + }; let result = app.run(&cmd, params.clone()); // A filter that runs as a background job keeps its preview on screen until the job // lands (`canvas::committed_filter_preview`) instead of flashing the unfiltered image. diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index d0c79d4faa..cb27d604cb 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -296,7 +296,7 @@ fn restore_remembered(app: &PhotocraftApp, command: &str, spec: &str, fields: &m /// Remember successful built-in schema dialogs; failed or cancelled dialogs do not persist. pub(crate) fn remember(app: &mut PhotocraftApp, command: &str, fields: &Map) { - if !fields.contains_key("__filter") || fields.contains_key("__spec") { + if !fields.contains_key("__filter") || fields.contains_key("__spec") || fields.contains_key("__smartFilter") { return; } let Some(spec) = photocraft_engine::commands::find(command) else { return }; @@ -625,16 +625,72 @@ pub fn preview_document_with( // Warp Text renders the type again from the layer model (font size, position), which a // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; - let proxy = crate::proxy::proxy_document(doc, run_k); + let mut proxy = crate::proxy::proxy_document(doc, run_k); + let mut p = params.clone(); + if is_smart_edit(command) { + let layer = photocraft_doc::LayerId(params.get("layer")?.as_u64()?); + let index = usize::try_from(params.get("index")?.as_u64()?).ok()?; + let photocraft_doc::LayerContent::Smart(sm) = &mut proxy.layer_mut(layer)?.content else { return None }; + // Proxy_document scales the visible cache only. Editing a filter re-renders its source: + // scale the placement, every filter's lengths and the shared mask as well. + if run_k > 1 { + let divisor = f64::from(run_k); + sm.transform.m = sm.transform.m.map(|v| v / divisor); + if let Some(h) = &mut sm.perspective { + for v in h.iter_mut().take(6) { + *v /= divisor; + } + } + if let Some(mask) = &mut sm.filter_mask { + mask.surface = crate::proxy::downsample(&mask.surface, run_k); + mask.feather /= run_k as f32; + } + for f in &mut sm.smart_filters { + scale_preview_params(&f.command, &mut f.params, run_k); + } + if command == "layer.smartFilter.setParams" { + let f = sm.smart_filters.get(index)?; + scale_preview_params(&f.command, p.get_mut("params")?, run_k); + } + } + } else { + scale_preview_params(command, &mut p, run_k); + } let mut s = photocraft_engine::Session::new(); s.set_inline_job_ctx(cancel.cloned()); s.add_document(proxy, None); if let Some(id) = active { s.select_layer(id).ok()?; } - let mut p = params.clone(); - if run_k > 1 - && let Some(o) = p.as_object_mut() + s.execute(command, p).ok()?; + s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) +} + +fn is_smart_edit(command: &str) -> bool { + matches!(command, "layer.smartFilter.setParams" | "layer.smartFilter.blendingOptions") +} + +/// Each stage constrains the proxy factor; an unscalable filter keeps full-resolution preview. +pub(crate) fn smart_preview_factor(doc: &Document, command: &str, params: &Value, k: u32) -> u32 { + if !is_smart_edit(command) { + return preview_factor(command, params, k); + } + let Some(layer) = params.get("layer").and_then(Value::as_u64).and_then(|id| doc.layer(photocraft_doc::LayerId(id))) else { return 1 }; + let photocraft_doc::LayerContent::Smart(sm) = &layer.content else { return 1 }; + let mut factor = sm.smart_filters.iter().fold(k, |k, f| preview_factor(&f.command, &f.params, k)); + if command == "layer.smartFilter.setParams" + && let Some(index) = params.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) + && let Some(f) = sm.smart_filters.get(index) + && let Some(p) = params.get("params") + { + factor = preview_factor(&f.command, p, factor); + } + factor +} + +fn scale_preview_params(command: &str, params: &mut Value, k: u32) { + if k > 1 + && let Some(o) = params.as_object_mut() { let spec = photocraft_engine::commands::find(command).map(|c| parse_spec(c.params)).unwrap_or_default(); for (key, v) in o.iter_mut() { @@ -650,13 +706,11 @@ pub fn preview_document_with( let floor = minimum.unwrap_or(0.0).max(if key == "cellSize" { 1.0 } else { 0.1 }); // Negative lengths (Offset's left/up shift, Displace's inverted scale) keep their // sign: the floor that keeps a radius positive turned them into +0.1 (#2063). - let x = x / run_k as f64; + let x = x / k as f64; *v = json!(if x > 0.0 { x.max(floor) } else { minimum.map_or(x, |m| x.max(m)) }); } } } - s.execute(command, p).ok()?; - s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } /// Cached preview state on the app. diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 2704f8a6ac..a1490656c7 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -95,8 +95,11 @@ impl Worker { /// Stop and release previews of closed dialogs even when their canvas is no longer being drawn. pub(crate) fn discard_closed(app: &mut crate::PhotocraftApp) { - let visible = - app.ui.dialogs.iter().any(|d| d.fields.contains_key("__filter") && d.fields.get("__preview").and_then(serde_json::Value::as_bool) == Some(true)); + let visible = app.ui.dialogs.iter().any(|d| { + d.fields.contains_key("__filter") + && d.fields.get("__preview").and_then(serde_json::Value::as_bool) == Some(true) + && d.fields.get("__smartFilter").is_none_or(|target| crate::smart_filter_dialog::validate_target(app, target).is_ok()) + }); if !visible { if let Some(r) = &app.filter_preview_worker.running { r.job.cancel(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c0912a5448..e496dd91fd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2466,3 +2466,11 @@ cameraData Lens Objektiv Front Image Přední obraz Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení + +# Smart Filter editors + Blending Options ({filter}) Volby prolnutí ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skrýt efekty vrstvy a inteligentní filtry (kliknutí s {key}: všechny vrstvy) + Show the layer's effects and Smart Filters ({key}-click: all layers) Zobrazit efekty vrstvy a inteligentní filtry (kliknutí s {key}: všechny vrstvy) + Wait for the current job to finish. Počkejte na dokončení aktuální úlohy. + The document or Smart Filter changed. Reopen its dialog. Dokument nebo inteligentní filtr se změnil. Znovu otevřete jeho dialog. + Opacity must be between 0% and 100%. Krytí musí být mezi 0% a 100%. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f51f35698d..a8340e6cf7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2504,3 +2504,11 @@ cameraData Lens Objektiv Front Image Vorderes Bild Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen + +# Smart Filter editors + Blending Options ({filter}) Fülloptionen ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ebeneneffekte und Smartfilter ausblenden ({key}-Klick: alle Ebenen) + Show the layer's effects and Smart Filters ({key}-click: all layers) Ebeneneffekte und Smartfilter einblenden ({key}-Klick: alle Ebenen) + Wait for the current job to finish. Warten Sie, bis der aktuelle Auftrag abgeschlossen ist. + The document or Smart Filter changed. Reopen its dialog. Das Dokument oder der Smartfilter wurde geändert. Öffnen Sie den Dialog erneut. + Opacity must be between 0% and 100%. Die Deckkraft muss zwischen 0% und 100% liegen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bf9617d2c7..249168cea2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3572,3 +3572,11 @@ cameraData Lens Φακός Front Image Μπροστινή εικόνα Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις + +# Smart Filter editors + Blending Options ({filter}) Επιλογές ανάμειξης ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Απόκρυψη εφέ επιπέδου και έξυπνων φίλτρων ({key}-κλικ: όλα τα επίπεδα) + Show the layer's effects and Smart Filters ({key}-click: all layers) Εμφάνιση εφέ επιπέδου και έξυπνων φίλτρων ({key}-κλικ: όλα τα επίπεδα) + Wait for the current job to finish. Περιμένετε να ολοκληρωθεί η τρέχουσα εργασία. + The document or Smart Filter changed. Reopen its dialog. Το έγγραφο ή το έξυπνο φίλτρο άλλαξε. Ανοίξτε ξανά το παράθυρό του. + Opacity must be between 0% and 100%. Η αδιαφάνεια πρέπει να είναι μεταξύ 0% και 100%. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 42eab0e04a..a40cabe716 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2545,3 +2545,11 @@ cameraData Lens Objetivo Front Image Imagen frontal Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan + +# Smart Filter editors + Blending Options ({filter}) Opciones de fusión ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ocultar efectos de capa y filtros inteligentes ({key}-clic: todas las capas) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostrar efectos de capa y filtros inteligentes ({key}-clic: todas las capas) + Wait for the current job to finish. Espere a que termine la tarea actual. + The document or Smart Filter changed. Reopen its dialog. El documento o el filtro inteligente ha cambiado. Vuelva a abrir su diálogo. + Opacity must be between 0% and 100%. La opacidad debe estar entre 0% y 100%. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c4dd364a00..53661137dd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2882,3 +2882,11 @@ cameraData Lens Objectif Front Image Image de premier plan Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant + +# Smart Filter editors + Blending Options ({filter}) Options de fusion ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Masquer les effets du calque et les filtres dynamiques ({key}-clic : tous les calques) + Show the layer's effects and Smart Filters ({key}-click: all layers) Afficher les effets du calque et les filtres dynamiques ({key}-clic : tous les calques) + Wait for the current job to finish. Attendez la fin de la tâche en cours. + The document or Smart Filter changed. Reopen its dialog. Le document ou le filtre dynamique a changé. Rouvrez sa boîte de dialogue. + Opacity must be between 0% and 100%. L'opacité doit être comprise entre 0% et 100%. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b78e40ace7..9647097ffe 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2869,3 +2869,11 @@ cameraData Lens Lensa Front Image Gambar Depan Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok + +# Smart Filter editors + Blending Options ({filter}) Opsi Pencampuran ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Sembunyikan efek lapisan dan Filter Cerdas (klik-{key}: semua lapisan) + Show the layer's effects and Smart Filters ({key}-click: all layers) Tampilkan efek lapisan dan Filter Cerdas (klik-{key}: semua lapisan) + Wait for the current job to finish. Tunggu hingga tugas saat ini selesai. + The document or Smart Filter changed. Reopen its dialog. Dokumen atau Filter Cerdas berubah. Buka kembali dialognya. + Opacity must be between 0% and 100%. Opasitas harus antara 0% dan 100%. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62526f3c7d..1882e07b00 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2535,3 +2535,11 @@ cameraData Lens Obiettivo Front Image Immagine in primo piano Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente + +# Smart Filter editors + Blending Options ({filter}) Opzioni di fusione ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Nascondi effetti del livello e filtri avanzati ({key}-clic: tutti i livelli) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostra effetti del livello e filtri avanzati ({key}-clic: tutti i livelli) + Wait for the current job to finish. Attendi il completamento dell'attività corrente. + The document or Smart Filter changed. Reopen its dialog. Il documento o il filtro avanzato è cambiato. Riapri la sua finestra di dialogo. + Opacity must be between 0% and 100%. L'opacità deve essere compresa tra 0% e 100%. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d4b422958e..838339b0da 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3483,3 +3483,11 @@ cameraData Lens レンズ Front Image 前面の画像 Search settings… 設定を検索… No matching settings 一致する設定はありません + +# Smart Filter editors + Blending Options ({filter}) 描画オプション ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) レイヤー効果とスマートフィルターを非表示 ({key}+クリック: すべてのレイヤー) + Show the layer's effects and Smart Filters ({key}-click: all layers) レイヤー効果とスマートフィルターを表示 ({key}+クリック: すべてのレイヤー) + Wait for the current job to finish. 現在の処理が完了するまでお待ちください。 + The document or Smart Filter changed. Reopen its dialog. ドキュメントまたはスマートフィルターが変更されました。ダイアログを開き直してください。 + Opacity must be between 0% and 100%. 不透明度は0%から100%の範囲で指定してください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5b9d257d..9f261a8a58 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3044,3 +3044,11 @@ cameraData Lens 렌즈 Front Image 전면 이미지 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 + +# Smart Filter editors + Blending Options ({filter}) 혼합 옵션 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 레이어 효과 및 스마트 필터 숨기기 ({key}+클릭: 모든 레이어) + Show the layer's effects and Smart Filters ({key}-click: all layers) 레이어 효과 및 스마트 필터 표시 ({key}+클릭: 모든 레이어) + Wait for the current job to finish. 현재 작업이 완료될 때까지 기다리세요. + The document or Smart Filter changed. Reopen its dialog. 문서 또는 스마트 필터가 변경되었습니다. 대화 상자를 다시 여세요. + Opacity must be between 0% and 100%. 불투명도는 0%에서 100% 사이여야 합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4a8edcb3fe..db188435c0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3642,3 +3642,11 @@ cameraData Lens Lens Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer + +# Smart Filter editors + Blending Options ({filter}) Overvloeiopties ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Laageffecten en slimme filters verbergen ({key}-klik: alle lagen) + Show the layer's effects and Smart Filters ({key}-click: all layers) Laageffecten en slimme filters tonen ({key}-klik: alle lagen) + Wait for the current job to finish. Wacht tot de huidige taak is voltooid. + The document or Smart Filter changed. Reopen its dialog. Het document of het slimme filter is gewijzigd. Open het dialoogvenster opnieuw. + Opacity must be between 0% and 100%. De dekking moet tussen 0% en 100% liggen. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b4ad11441..51b62acf74 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3483,3 +3483,11 @@ cameraData Lens Obiektyw Front Image Obraz na wierzchu Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień + +# Smart Filter editors + Blending Options ({filter}) Opcje mieszania ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ukryj efekty warstwy i inteligentne filtry (kliknięcie z {key}: wszystkie warstwy) + Show the layer's effects and Smart Filters ({key}-click: all layers) Pokaż efekty warstwy i inteligentne filtry (kliknięcie z {key}: wszystkie warstwy) + Wait for the current job to finish. Poczekaj na zakończenie bieżącego zadania. + The document or Smart Filter changed. Reopen its dialog. Dokument lub inteligentny filtr został zmieniony. Otwórz ponownie jego okno dialogowe. + Opacity must be between 0% and 100%. Krycie musi wynosić od 0% do 100%. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b24e76fbc1..acfba69860 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2890,3 +2890,11 @@ cameraData Lens Lente Front Image Imagem frontal Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada + +# Smart Filter editors + Blending Options ({filter}) Opções de mesclagem ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ocultar efeitos da camada e Filtros Inteligentes ({key}-clique: todas as camadas) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostrar efeitos da camada e Filtros Inteligentes ({key}-clique: todas as camadas) + Wait for the current job to finish. Aguarde a conclusão da tarefa atual. + The document or Smart Filter changed. Reopen its dialog. O documento ou o Filtro Inteligente mudou. Reabra sua caixa de diálogo. + Opacity must be between 0% and 100%. A opacidade deve estar entre 0% e 100%. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index db32045a95..60b026ca45 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2749,3 +2749,11 @@ cameraData Lens Объектив Rasterize Layer Style Растрировать стиль слоя Search settings… Поиск настроек… No matching settings Нет подходящих настроек + +# Smart Filter editors + Blending Options ({filter}) Параметры наложения ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Скрыть эффекты слоя и смарт-фильтры ({key}-клик: все слои) + Show the layer's effects and Smart Filters ({key}-click: all layers) Показать эффекты слоя и смарт-фильтры ({key}-клик: все слои) + Wait for the current job to finish. Дождитесь завершения текущей задачи. + The document or Smart Filter changed. Reopen its dialog. Документ или смарт-фильтр изменился. Откройте его диалог заново. + Opacity must be between 0% and 100%. Непрозрачность должна быть от 0% до 100%. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da80fc3a6c..34c09db34f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3570,3 +3570,11 @@ cameraData Lens Об'єктив Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана + +# Smart Filter editors + Blending Options ({filter}) Параметри накладання ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Приховати ефекти шару та смарт-фільтри ({key}-клік: усі шари) + Show the layer's effects and Smart Filters ({key}-click: all layers) Показати ефекти шару та смарт-фільтри ({key}-клік: усі шари) + Wait for the current job to finish. Дочекайтеся завершення поточного завдання. + The document or Smart Filter changed. Reopen its dialog. Документ або смарт-фільтр змінився. Відкрийте його діалог знову. + Opacity must be between 0% and 100%. Непрозорість має бути від 0% до 100%. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7b2fac1702..994d4424ee 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2473,3 +2473,11 @@ cameraData Lens 镜头 Front Image 前面的图像 Search settings… 搜索设置… No matching settings 没有匹配的设置 + +# Smart Filter editors + Blending Options ({filter}) 混合选项 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 隐藏图层效果和智能滤镜 ({key} 单击:所有图层) + Show the layer's effects and Smart Filters ({key}-click: all layers) 显示图层效果和智能滤镜 ({key} 单击:所有图层) + Wait for the current job to finish. 请等待当前任务完成。 + The document or Smart Filter changed. Reopen its dialog. 文档或智能滤镜已更改。请重新打开其对话框。 + Opacity must be between 0% and 100%. 不透明度必须介于 0% 和 100% 之间。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 38671e3cd4..85c713847f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2443,3 +2443,11 @@ cameraData Lens 鏡頭 Front Image 前面的影像 Search settings… 搜尋設定… No matching settings 沒有符合的設定 + +# Smart Filter editors + Blending Options ({filter}) 混合選項 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 隱藏圖層效果和智慧型濾鏡 ({key} 點擊:所有圖層) + Show the layer's effects and Smart Filters ({key}-click: all layers) 顯示圖層效果和智慧型濾鏡 ({key} 點擊:所有圖層) + Wait for the current job to finish. 請等待目前的工作完成。 + The document or Smart Filter changed. Reopen its dialog. 文件或智慧型濾鏡已變更。請重新開啟其對話框。 + Opacity must be between 0% and 100%. 不透明度必須介於 0% 和 100% 之間。 diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index d265690597..4f5dcf3b23 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -33,6 +33,7 @@ pub enum Indicator { Lock, FxTriangle, Fx, + SmartFilters, Link, Blend, } @@ -74,8 +75,15 @@ pub fn reserved_width(l: &Layer) -> f32 { if l.locks.transparency || l.locks.position || l.locks.all { w += ICON_W + GAP; } + let smart = crate::smart_ui::has_filters(l); + if !l.effects.items.is_empty() || smart { + w += TRIANGLE_W + GAP; + } if !l.effects.items.is_empty() { - w += TRIANGLE_W + 1.0 + 13.0 + GAP; + w += 13.0 + GAP; + } + if smart { + w += ICON_W + GAP; } if l.link_group.is_some() { w += ICON_W + GAP; @@ -117,6 +125,7 @@ pub fn indicators( let fx_font = theme::semibold(11.0); let blend_font = FontId::proportional(10.5); let has_fx = !l.effects.items.is_empty(); + let has_smart = crate::smart_ui::has_filters(l); let show_blend = l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough; let fx_galley = has_fx.then(|| painter.layout_no_wrap("fx".into(), fx_font, t.text_dim)); let blend_galley = show_blend.then(|| painter.layout_no_wrap(tl!(l.blend.label()).into(), blend_font, t.text_faint)); @@ -124,8 +133,13 @@ pub fn indicators( if locked { items.push((Indicator::Lock, ICON_W)); } - if let Some(g) = &fx_galley { + if has_fx || has_smart { items.push((Indicator::FxTriangle, TRIANGLE_W)); + } + if has_smart { + items.push((Indicator::SmartFilters, ICON_W)); + } + if let Some(g) = &fx_galley { items.push((Indicator::Fx, g.size().x)); } if l.link_group.is_some() { @@ -151,6 +165,11 @@ pub fn indicators( painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_faint); } } + Indicator::SmartFilters => { + let enabled = matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if sm.filters_enabled); + crate::smart_ui::paint_badge(painter, r.center(), if enabled { t.icon } else { t.text_faint }); + ui.interact(r, ui.id().with(("smart-filters-badge", l.id.0)), Sense::hover()).on_hover_text(tl!("Smart Filters")); + } Indicator::FxTriangle => { let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click()); paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon }); @@ -160,7 +179,11 @@ pub fn indicators( actions.push(("layer.setEffectsExpanded".into(), json!({"layer": l.id.0, "expanded": !fx_open, "all": all}))); } let (verb, name) = (if fx_open { tl!("Collapse") } else { tl!("Expand") }, l.name.clone()); - let tip = if fx_open { + let tip = if has_smart && fx_open { + tl!("Hide the layer's effects and Smart Filters ({key}-click: all layers)") + } else if has_smart { + tl!("Show the layer's effects and Smart Filters ({key}-click: all layers)") + } else if fx_open { tl!("Hide the layer's effects ({key}-click: all layers)") } else { tl!("Show the layer's effects ({key}-click: all layers)") diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c30796a5dd..ec0c9f95fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -144,6 +144,7 @@ pub mod shortcut_dispatch; pub mod shortcuts; mod sizing; pub mod slice_ui; +pub mod smart_filter_dialog; pub mod smart_ui; pub mod snap_ui; pub(crate) mod solid_fill_ui; @@ -530,6 +531,7 @@ pub struct PhotocraftApp { /// Selection outline cache: (doc, revision, segments). /// Live filter preview (proxy document with the filter applied). pub(crate) filter_preview: Option, + pub(crate) smart_filter_targets: smart_filter_dialog::Targets, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: filter_preview_worker::Worker, /// Select › Color Range dialog preview (proxy document + mask / image textures). @@ -690,6 +692,7 @@ impl PhotocraftApp { proxy_uploaded: None, outline_cache: None, filter_preview: None, + smart_filter_targets: Default::default(), #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: Default::default(), color_range: None, @@ -1654,6 +1657,9 @@ impl PhotocraftApp { if layer.vector_mask.is_some() { live.insert((layer.id, mask_thumbs_ui::THUMB_VECTOR)); } + if matches!(&layer.content, photocraft_doc::LayerContent::Smart(sm) if sm.filter_mask.is_some()) { + live.insert((layer.id, mask_thumbs_ui::THUMB_SMART_MASK)); + } if let Some(children) = layer.children() { pending.extend(children); } @@ -1702,8 +1708,19 @@ impl PhotocraftApp { } pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { + self.mask_thumb_kind(ctx, doc, id, mask, mask_thumbs_ui::THUMB_MASK) + } + + pub(crate) fn mask_thumb_kind( + &mut self, + ctx: &egui::Context, + doc: &Document, + id: photocraft_doc::LayerId, + mask: &photocraft_doc::LayerMask, + kind: u8, + ) -> egui::TextureId { let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40; - let key = (id, mask_thumbs_ui::THUMB_MASK); + let key = (id, kind); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev { diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 562b2b7521..5ab2181bc4 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -22,6 +22,7 @@ pub const CHAIN_W: f32 = 12.0; pub const THUMB_LAYER: u8 = 0; pub const THUMB_MASK: u8 = 1; pub const THUMB_VECTOR: u8 = 2; +pub const THUMB_SMART_MASK: u8 = 3; /// Which of a layer's masks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a717a12190..a8a83366db 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -378,6 +378,18 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: crate::export_dialog::quick_export_layer_png(app, layer) } } + "layer.smartFilter.blendingOptions" if params.get("blend").is_none() && params.get("opacity").is_none() => { + let st = app.session.active().ok_or("no document open")?; + let layer = params.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).ok_or("no layer selected")?; + 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 index = match params.get("index") { + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("invalid Smart Filter index")?, + None => sm.smart_filters.len().checked_sub(1).ok_or("the Smart Object has no Smart Filters")?, + }; + crate::smart_filter_dialog::open(app, layer, index).map(|id| json!({"dialog": id})) + } "layer.layerStyle.blendingOptions" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::layer_style::open(app, Some(crate::layer_style::BLENDING)).map(|d| json!({"dialog": d})).ok_or_else(|| "no active layer".to_string()) } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 444036c57f..1badd413de 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2131,7 +2131,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) { effect_rows(app, ui, l, depth, &mut actions); } - crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions); + if !fx_collapsed.contains(&l.id) { + crate::smart_ui::filter_rows(app, ui, &doc, l, depth, &mut actions); + } } // #1721: the list takes drops everywhere, not only on a row (Photoshop): the space // between two rows lands above the lower one, the empty space below the bottom-most diff --git a/crates/ui-egui/src/smart_filter_dialog.rs b/crates/ui-egui/src/smart_filter_dialog.rs new file mode 100644 index 0000000000..b8db816939 --- /dev/null +++ b/crates/ui-egui/src/smart_filter_dialog.rs @@ -0,0 +1,227 @@ +//! Per-filter blending and shared target validation for Smart Filter editors. +//! Preview and commit dispatch the existing engine commands against the captured filter. + +use std::sync::{Arc, Weak}; + +use photocraft_color::BlendMode; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, state::DialogKind, theme::Tokens, widgets}; + +pub const COMMAND: &str = "layer.smartFilter.blendingOptions"; +const TARGET: &str = "__smartFilter"; + +/// Private document snapshots; public dialog fields carry an opaque counter, never an address. +#[derive(Default)] +pub(crate) struct Targets { + next: u64, + snapshots: Vec<(u64, Weak)>, +} + +impl Targets { + fn capture(&mut self, doc: &Arc) -> Result { + self.next = self.next.checked_add(1).ok_or("Smart Filter target counter exhausted")?; + self.snapshots.retain(|(_, doc)| doc.strong_count() > 0); + // Dialogs normally are modal. Bound retained snapshots even if automation opens many. + if self.snapshots.len() >= 64 { + self.snapshots.remove(0); + } + self.snapshots.push((self.next, Arc::downgrade(doc))); + Ok(self.next) + } + + fn matches(&self, token: u64, doc: &Arc) -> bool { + self.snapshots.iter().find(|(id, _)| *id == token).and_then(|(_, doc)| doc.upgrade()).is_some_and(|old| Arc::ptr_eq(&old, doc)) + } +} + +fn snapshot(app: &PhotocraftApp, layer: LayerId, index: usize) -> Result { + let st = app.session.active().ok_or("no document open")?; + if app.session.job_on(st.doc.id).is_some() { + return Err(tl!("Wait for the current job to finish.").into()); + } + let sm = match &st.doc.layer(layer).ok_or("Smart Object no longer exists")?.content { + LayerContent::Smart(sm) => sm, + _ => return Err("the layer is not a Smart Object".into()), + }; + let filter = sm.smart_filters.get(index).ok_or("Smart Filter no longer exists")?; + Ok(json!({"doc": st.doc.id.0, "revision": st.revision, "layer": layer.0, "index": index, "filter": filter})) +} + +pub(crate) fn target(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result { + let mut target = snapshot(app, layer, index)?; + let doc = &app.session.active().ok_or("no document open")?.doc; + let token = app.smart_filter_targets.capture(doc)?; + if let Some(fields) = target.as_object_mut() { + fields.insert("guard".into(), json!(token)); + } + Ok(target) +} + +/// Revision/filter identity catches edits; the weak snapshot also rejects a same-id reopen. +pub(crate) fn validate_target(app: &PhotocraftApp, target: &Value) -> Result<(LayerId, usize), String> { + let layer = LayerId(target.get("layer").and_then(Value::as_u64).ok_or("invalid Smart Filter target")?); + let index = target.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("invalid Smart Filter index")?; + let token = target.get("guard").and_then(Value::as_u64).ok_or("invalid Smart Filter snapshot")?; + let doc = &app.session.active().ok_or("no document open")?.doc; + let current = snapshot(app, layer, index)?; + let mut captured = target.clone(); + if let Some(fields) = captured.as_object_mut() { + fields.remove("guard"); + } + if current != captured || !app.smart_filter_targets.matches(token, doc) { + return Err(tl!("The document or Smart Filter changed. Reopen its dialog.").into()); + } + Ok((layer, index)) +} + +pub fn open(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result { + let target = self::target(app, layer, index)?; + let st = app.session.active().ok_or("no document open")?; + let Some(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("Smart Filter no longer exists")?; + let mut fields = Map::new(); + fields.insert("__command".into(), json!(COMMAND)); + fields.insert("__label".into(), json!("Blending Options")); + fields.insert("__filterName".into(), json!(crate::smart_ui::filter_label(filter))); + fields.insert("__filter".into(), json!(true)); + fields.insert("__preview".into(), json!(true)); + fields.insert(TARGET.into(), target); + fields.insert("blend".into(), json!(filter.blend.label())); + fields.insert("opacity".into(), json!(filter.opacity * 100.0)); + let fit = (220.0 / st.doc.size.width.max(st.doc.size.height).max(1) as f64 * 100.0).min(100.0); + fields.insert("__zoom".into(), json!(fit)); + fields.insert("__imageSize".into(), json!([st.doc.size.width, st.doc.size.height])); + app.filter_preview = None; + Ok(app.ui.open_dialog(DialogKind::Command, fields)) +} + +pub fn owns(fields: &Map) -> bool { + fields.get("__command").and_then(Value::as_str) == Some(COMMAND) && fields.contains_key(TARGET) +} + +pub(crate) fn title(fields: &Map) -> String { + crate::i18n::fmt(tl!("Blending Options ({filter})"), &[("filter", fields.get("__filterName").and_then(Value::as_str).unwrap_or_default())]) +} + +/// UI opacity is always percent; the engine gets a fraction, including 1% = 0.01. +fn blend_params(fields: &Map) -> Result { + let opacity = fields + .get("opacity") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (0.0..=100.0).contains(v)) + .ok_or(tl!("Opacity must be between 0% and 100%."))?; + let blend = fields.get("blend").and_then(Value::as_str).ok_or("choose a blend mode")?; + Ok(json!({"blend": blend, "opacity": opacity / 100.0})) +} + +pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map) -> Result<(String, Value), String> { + let (layer, index) = validate_target(app, fields.get(TARGET).ok_or("no Smart Filter target")?)?; + if owns(fields) { + let mut params = blend_params(fields)?; + if let Some(p) = params.as_object_mut() { + p.insert("layer".into(), json!(layer.0)); + p.insert("index".into(), json!(index)); + } + Ok((COMMAND.into(), params)) + } else { + Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": crate::filter_dialog::params_of(fields)}))) + } +} + +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { + ui.horizontal(|ui| { + ui.label(tl!("Mode")); + let mut blend = fields.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string(); + let options: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + if widgets::dropdown(ui, "smart-filter-blend", &mut blend, &options, 200.0) { + fields.insert("blend".into(), json!(blend)); + } + }); + let mut opacity = fields.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32; + if widgets::slider_row(ui, tl!("Opacity"), &mut opacity, 0.0..=100.0, "%", None).changed() { + fields.insert("opacity".into(), json!(opacity)); + } + let mut preview = fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); + if widgets::checkbox(ui, &mut preview, tl!("Preview")).changed() { + fields.insert("__preview".into(), json!(preview)); + } + if let Err(e) = fields.get(TARGET).ok_or("no Smart Filter target").map_err(str::to_string).and_then(|t| validate_target(app, t)) { + ui.colored_label(Tokens::get(ui.ctx()).danger, e); + return; + } + viewport(app, ui, fields, preview); +} + +#[derive(Clone)] +struct ViewImage { + doc: Arc, + texture: egui::TextureHandle, +} + +fn viewport(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map, preview: bool) { + let Some(st) = app.session.active() else { return }; + let doc = if preview { + app.filter_preview.as_ref().filter(|p| p.key.doc == st.doc.id && p.key.revision == st.revision && !p.committing).and_then(|p| p.result.clone()) + } else { + Some(st.doc.clone()) + }; + let Some(doc) = doc else { + ui.allocate_ui(egui::vec2(360.0, 220.0), |ui| { + ui.centered_and_justified(|ui| { + ui.spinner(); + }); + }); + return; + }; + let key = ui.id().with("smart-filter-view-image"); + let mut image = ui.ctx().data(|d| d.get_temp::(key)); + if image.as_ref().is_none_or(|i| !Arc::ptr_eq(&i.doc, &doc)) { + // Fixed thumbnail size and content identity: never composite a full surface per frame. + let buffer = photocraft_compose::thumbnail_buffer(&doc, 256); + match app.session.color.canvas_display(&doc) { + Ok(display) => { + let thumb = display.to_rgba8(&buffer); + let texture = ui.ctx().load_texture( + "smart-filter-view-image", + egui::ColorImage::from_rgba_unmultiplied([thumb.width as usize, thumb.height as usize], &thumb.pixels), + egui::TextureOptions::LINEAR, + ); + image = Some(ViewImage { doc, texture }); + if let Some(i) = &image { + ui.ctx().data_mut(|d| d.insert_temp(key, i.clone())); + } + } + Err(e) => { + ui.colored_label(Tokens::get(ui.ctx()).danger, e.to_string()); + return; + } + } + } + let Some(image) = image else { return }; + let zoom = fields.get("__zoom").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.1, 800.0) as f32; + let size = fields + .get("__imageSize") + .and_then(Value::as_array) + .and_then(|a| Some(egui::vec2(a.first()?.as_u64()? as f32, a.get(1)?.as_u64()? as f32))) + .unwrap_or(image.texture.size_vec2()); + egui::Frame::new().fill(Tokens::get(ui.ctx()).canvas).stroke(egui::Stroke::new(1.0, Tokens::get(ui.ctx()).field_border)).show(ui, |ui| { + ui.set_width(360.0); + egui::ScrollArea::both().id_salt("smart-filter-image").auto_shrink([false, false]).min_scrolled_height(220.0).max_height(220.0).show(ui, |ui| { + ui.add(egui::Image::new(&image.texture).fit_to_exact_size(size * zoom / 100.0)); + }); + }); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, "−", 32.0).clicked() { + fields.insert("__zoom".into(), json!((zoom / 2.0).max(0.1))); + } + ui.label(format!("{zoom:.1}%")); + if widgets::secondary_button(ui, "+", 32.0).clicked() { + fields.insert("__zoom".into(), json!((zoom * 2.0).min(800.0))); + } + }); +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/ui-egui/src/smart_filter_dialog/tests.rs b/crates/ui-egui/src/smart_filter_dialog/tests.rs new file mode 100644 index 0000000000..04b8389133 --- /dev/null +++ b/crates/ui-egui/src/smart_filter_dialog/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use photocraft_doc::{SmartFilter, SmartObject}; + +#[test] +fn a_same_id_reopened_document_rejects_the_previous_dialog() { + let (mut app, layer) = app(8, "rgb"); + let saved = app.session.active().unwrap().doc.as_ref().clone(); + app.session.close(0).unwrap(); + app.session.add_document(saved.clone(), None); + let id = open(&mut app, layer, 0).unwrap(); + let old = app.session.active().unwrap().doc.clone(); + app.session.close(0).unwrap(); + app.session.add_document(saved, None); + assert_eq!(app.session.active().unwrap().revision, 1); + assert_eq!(old.id, app.session.active().unwrap().doc.id); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); +} + +#[test] +fn tiny_images_keep_a_usable_preview_viewport() { + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("__imageSize".into(), json!([8, 8])); + fields.insert("__zoom".into(), json!(100)); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 480.0))), ..Default::default() }; + ctx.run_ui(input, |ui| { + let start = ui.cursor().top(); + super::viewport(&mut app, ui, &mut fields, false); + assert!(ui.cursor().top() - start >= 220.0, "tiny image must not collapse the preview"); + }) + .textures_delta + .clear(); +} + +pub(crate) fn app(depth: u32, mode: &str) -> (PhotocraftApp, LayerId) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "mode": mode})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Smart Object"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#305080"})).unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":32,"height":48})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#e09040"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("filter.blur.boxBlur", json!({"radius": 4})).unwrap(); + s.execute("filter.sharpen.smartSharpen", json!({"amount": 125, "radius": 2})).unwrap(); + let mut app = PhotocraftApp::new(s, Default::default()); + app.background_jobs = false; + (app, layer) +} + +pub(crate) fn smart(app: &PhotocraftApp, layer: LayerId) -> &SmartObject { + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { panic!("smart") }; + sm +} + +fn filters(app: &PhotocraftApp, layer: LayerId) -> Vec { + smart(app, layer).smart_filters.clone() +} + +#[test] +fn blending_edits_one_filter_with_percent_opacity_and_undo_redo() { + for depth in [8, 16, 32] { + for mode in ["rgb", "gray"] { + let (mut app, layer) = app(depth, mode); + let original = filters(&app, layer); + let source = photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(); + for opacity in [0.0, 1.0, 50.0, 100.0] { + for blend in ["Normal", "Multiply", "Screen", "Luminosity"] { + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, layer, 0).unwrap(); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("opacity".into(), json!(opacity)); + f.insert("blend".into(), json!(blend)); + let fields = f.clone(); + let (cmd, params) = edit_command(&app, &fields).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let expected = crate::filter_dialog::preview_document(&before, Some(layer), &cmd, ¶ms, 1).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + let changed = filters(&app, layer); + assert!((changed[0].opacity - opacity as f32 / 100.0).abs() < 1e-6); + assert_eq!(changed[0].blend.label(), blend); + assert_eq!(serde_json::to_value(&changed[1]).unwrap(), serde_json::to_value(&original[1]).unwrap()); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().blend, BlendMode::Normal); + assert_eq!( + smart(&app, layer).cache.as_ref().unwrap().read_region(before.bounds()), + expected.layer(layer).unwrap().surface().unwrap().read_region(expected.bounds()) + ); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(filters(&app, layer)).unwrap(), serde_json::to_value(&original).unwrap()); + app.run("edit.redo", json!({})).unwrap(); + let id = open(&mut app, layer, 0).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64().unwrap(), opacity); + crate::dialogs::cancel(&mut app, id).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + } + } + } + } +} + +#[test] +fn preview_toggle_and_cancel_leave_document_and_history_untouched() { + let (mut app, layer) = app(8, "rgb"); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let steps = st.history.past_len(); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + let ctx = egui::Context::default(); + let idx = app.session.active_index().unwrap(); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + assert_eq!(app.filter_preview.as_ref().unwrap().key.params["opacity"], json!(0.01)); + app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(false)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_none()); + app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(true)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.filter_preview.is_none()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +#[test] +fn stale_filter_dialogs_cannot_edit_a_switched_or_changed_target() { + for command in ["file.new", "edit.undo", "layer.smartFilter.delete", "layer.smartFilter.move"] { + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let params = match command { + "file.new" => json!({"width": 16,"height":16}), + "layer.smartFilter.move" => json!({"layer":layer.0,"index":0,"to":1}), + _ => json!({"layer":layer.0,"index":0}), + }; + app.run(command, params).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{command}"); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } +} + +#[test] +fn reduced_preview_scales_placement_stack_and_mask_without_adding_filters() { + let (mut app, layer) = app(16, "rgb"); + // A half-visible shared mask, different from the layer mask. + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!() }; + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.fill_rect(photocraft_geom::Rect::new(32, 0, 64, 48), &[0.0]); + sm.filter_mask = Some(mask); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(50)); + let (command, params) = edit_command(&app, &app.ui.dialogs[0].fields).unwrap(); + let original = app.session.active().unwrap().doc.clone(); + let preview = crate::filter_dialog::preview_document(&original, Some(layer), &command, ¶ms, 2).unwrap(); + assert_eq!(preview.size.width, 32); + let LayerContent::Smart(sm) = &preview.layer(layer).unwrap().content else { panic!() }; + assert_eq!(sm.smart_filters.len(), 2); + assert_eq!(sm.smart_filters[0].params["radius"], json!(2.0)); + assert_eq!(sm.smart_filters[1].params["radius"], json!(1.0)); + assert_eq!(sm.filter_mask.as_ref().unwrap().surface.pixel(17, 0)[0], 0.0); + assert!(sm.cache.as_ref().unwrap().content_bounds().x1 <= 32); + assert!(Arc::ptr_eq(&original, &app.session.active().unwrap().doc)); +} + +#[test] +fn native_save_preserves_edited_blending_and_existing_invert() { + let (mut app, layer) = app(8, "rgb"); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "image.adjustments.invert", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters.last().unwrap().command, "image.adjustments.invert"); + let id = open(&mut app, layer, 2).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + let loaded = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(doc, &Default::default()).unwrap()).unwrap(); + let LayerContent::Smart(sm) = &loaded.layers.last().unwrap().content else { panic!() }; + assert_eq!(serde_json::to_value(&sm.smart_filters).unwrap(), serde_json::to_value(filters(&app, layer)).unwrap()); +} + +#[test] +fn invalid_opacity_fails_without_an_edit() { + let (mut app, layer) = app(8, "rgb"); + for opacity in [json!(-1), json!(101), json!("x"), Value::Null] { + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), opacity); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } +} + +#[test] +fn blending_does_not_follow_layer_selection_or_the_layer_mask_target() { + let (mut app, layer) = app(8, "rgb"); + app.run("layer.new.layer", json!({"name":"Selected pixel layer"})).unwrap(); + let selected = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerMask.revealAll", json!({"layer":selected.0})).unwrap(); + app.ui.mask_target = true; + let doc = app.session.active().unwrap().doc.clone(); + let mask = doc.layer(selected).unwrap().mask.as_ref().unwrap().surface.clone(); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(25)); + let idx = app.session.active_index().unwrap(); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &Default::default()).is_some()); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].opacity, 0.25); + assert_eq!(app.session.active().unwrap().active_layer, Some(selected)); + assert_eq!( + crate::surface_fingerprint(&app.session.active().unwrap().doc.layer(selected).unwrap().mask.as_ref().unwrap().surface), + crate::surface_fingerprint(&mask) + ); +} + +#[test] +fn invert_blending_keeps_transparency_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("synthetic translucent pixels", |doc, active| { + let bounds = doc.bounds(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.2, 0.4, 0.6, 0.5]); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + let before = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, Default::default()); + app.background_jobs = false; + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).cache.as_ref().unwrap().pixel(3, 3)[3], before[3]); + } +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..614a1898e8 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,8 +1,8 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; -use photocraft_doc::{Layer, LayerContent, SmartObject}; +use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -22,7 +22,7 @@ pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { /// The display name of a smart filter (its command's label without the ellipsis). A Photoshop /// filter PhotoCraft doesn't implement shows its own name and is marked as kept as is. -fn filter_label(f: &photocraft_doc::SmartFilter) -> String { +pub(crate) fn filter_label(f: &photocraft_doc::SmartFilter) -> String { let command = f.command.as_str(); if command == photocraft_engine::smart_cmds::UNSUPPORTED_FILTER { let name = f.params.get("name").and_then(Value::as_str).unwrap_or("Smart filter"); @@ -34,16 +34,27 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. -pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { +pub fn has_filters(l: &Layer) -> bool { + matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) +} + +/// The two rings in the layer row indicate an attached Smart Filters stack. +pub fn paint_badge(painter: &egui::Painter, center: egui::Pos2, color: Color32) { + for dx in [-2.5, 2.5] { + painter.circle_stroke(center + vec2(dx, 0.0), 3.5, Stroke::new(1.0, color)); + } +} + +pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let LayerContent::Smart(sm) = &l.content else { return }; if sm.smart_filters.is_empty() { return; } let t = Tokens::get(ui.ctx()); - let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let indent = 30.0 + crate::layer_row_ui::indent(depth, ui.available_width(), 130.0); let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), if index.is_none() { 30.0 } else { 22.0 }), Sense::hover()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -51,35 +62,70 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); } let (name, on) = match index { - None => ("Smart Filters".to_string(), sm.filters_enabled), + None => (tl!("Smart Filters").to_string(), sm.filters_enabled), Some(i) => (filter_label(&sm.smart_filters[i]), sm.smart_filters[i].visible && sm.filters_enabled), }; - let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); + let eye = Rect::from_center_size(pos2(rect.left() + indent + if index.is_none() { 9.0 } else { 41.0 }, rect.center().y), vec2(18.0, 18.0)); let eye_resp = ui.interact(eye, ui.id().with(("sf-eye", l.id.0, index)), Sense::click()); let shown = match index { None => sm.filters_enabled, Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", 12.0, if on { t.icon } else { t.text_faint }); } + eye_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, shown, format!("{} {name}", tl!("Visibility")))); if eye_resp.clicked() { actions.push(match index { None => ("layer.smartFilter.disableSmartFilters".into(), json!({"layer": l.id.0})), Some(i) => ("layer.smartFilter.setVisible".into(), json!({"layer": l.id.0, "index": i})), }); } - let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; + let x = rect.left() + indent + 60.0; if index.is_none() { - let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); - ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); + let sw = Rect::from_center_size(pos2(x - 20.0, rect.center().y), vec2(24.0, 24.0)); + if let Some(mask) = &sm.filter_mask { + let tex = app.mask_thumb_kind(ui.ctx(), doc, l.id, mask, crate::mask_thumbs_ui::THUMB_SMART_MASK); + ui.painter().image(tex, sw, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if !mask.enabled { + ui.painter().line_segment([sw.left_top(), sw.right_bottom()], Stroke::new(1.0, t.danger)); + ui.painter().line_segment([sw.right_top(), sw.left_bottom()], Stroke::new(1.0, t.danger)); + } + } else { + ui.painter().rect_filled(sw, 0.0, Color32::WHITE); + } ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); - if let Some(i) = index - && resp.double_clicked() - { - open_editor(app, ui.ctx(), l.id.0, sm, i); + let blend = index.map(|_| Rect::from_center_size(pos2(rect.right() - crate::layer_row_ui::RIGHT_PAD - 9.0, rect.center().y), vec2(18.0, 18.0))); + let right = blend.map_or(rect.right() - crate::layer_row_ui::RIGHT_PAD, |r| r.left() - 4.0); + crate::layer_row_ui::label( + ui.painter(), + x, + rect.center().y, + right, + &name, + egui::FontId::proportional(11.5), + if on { t.text_dim } else { t.text_faint }, + ); + if let Some(i) = index { + let name_rect = Rect::from_min_max(pos2(x.min(right), rect.top()), pos2(right, rect.bottom())); + let name_resp = ui.interact(name_rect, ui.id().with(("sf-name", l.id.0, i)), Sense::click()); + name_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + if name_resp.double_clicked() { + open_editor(app, ui.ctx(), l.id.0, sm, i); + } + if let Some(r) = blend { + let blend_resp = ui.interact(r, ui.id().with(("sf-blend", l.id.0, i)), Sense::click()); + icons::paint(ui, r, "sliders-horizontal", 13.0, if blend_resp.hovered() { t.text } else { t.icon }); + let label = crate::i18n::fmt(tl!("Blending Options ({filter})"), &[("filter", &name)]); + blend_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + if blend_resp.double_clicked() + && let Err(e) = crate::smart_filter_dialog::open(app, l.id, i) + { + crate::notices::error(app, e); + } + blend_resp.on_hover_text(label); + } } } } @@ -96,17 +142,23 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } - if !crate::filter_dialog::has_dialog(&f.command) { + let target = match crate::smart_filter_dialog::target(app, photocraft_doc::LayerId(layer), index) { + Ok(target) => target, + Err(e) => { + crate::notices::error(app, e); + return; + } + }; + let Some(id) = crate::adjust_dialog::open(app, &f.command) + .or_else(|| crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()) + else { return; - } - let Some(id) = crate::filter_dialog::open(app, &f.command) else { return }; + }; if let Some(d) = app.ui.dialog_mut(id) { - // The live preview would stack a second copy of the filter on the smart object. - d.fields.remove("__preview"); - d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); + d.fields.insert("__smartFilter".into(), target); if let Value::Object(p) = &f.params { for (k, v) in p { - if d.fields.contains_key(k) { + if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); } } @@ -115,9 +167,18 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } /// The command a confirmed filter dialog runs: smart-filter edits update the recorded filter. -pub fn confirm_command(fields: &serde_json::Map, command: String, params: Value) -> (String, Value) { +pub fn confirm_command(app: &PhotocraftApp, fields: &serde_json::Map, command: String, params: Value) -> Result<(String, Value), String> { + if crate::smart_filter_dialog::owns(fields) { + return crate::smart_filter_dialog::edit_command(app, fields); + } match fields.get("__smartFilter") { - Some(sf) => ("layer.smartFilter.setParams".into(), json!({"layer": sf["layer"], "index": sf["index"], "params": params})), - None => (command, params), + Some(sf) => { + let (layer, index) = crate::smart_filter_dialog::validate_target(app, sf)?; + Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": params}))) + } + None => Ok((command, params)), } } + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/smart_ui/tests.rs b/crates/ui-egui/src/smart_ui/tests.rs new file mode 100644 index 0000000000..c007b2ca6b --- /dev/null +++ b/crates/ui-egui/src/smart_ui/tests.rs @@ -0,0 +1,154 @@ +use egui::{Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::{ + PhotocraftApp, + smart_filter_dialog::tests::{app, smart}, +}; + +fn harness(width: f32, scale: f32, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let (app, _) = app(8, "rgb"); + let mut h = + Harness::builder().with_size(vec2(1200.0, 850.0)).with_pixels_per_point(scale).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + app + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new( + "ui.set", + json!({"theme":theme.id(),"dockWidth":width,"dock":{"collapsed":["color","properties","history","navigator"]}}), + ); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(5); + h +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn names_and_blend_icons_have_separate_double_click_targets_and_no_overlap() { + for theme in crate::theme::ThemeKind::ALL { + for width in [250.0, 420.0] { + let mut h = harness(width, 1.0, theme); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let rows = crate::layer_row_ui::recorded(&h.ctx); + let row = rows.iter().find(|r| r.layer == layer.0).unwrap(); + assert!(row.indicators.iter().any(|(k, _)| *k == crate::layer_row_ui::Indicator::SmartFilters)); + let blend = h.get_by_label("Blending Options (Box Blur)").rect(); + let edit = h.get_by_label("Box Blur").rect(); + assert!(edit.right() <= blend.left(), "{width} {theme:?}: {edit:?} {blend:?}"); + click(&mut h, blend.center()); + assert!(h.state().ui.dialogs.is_empty(), "single click does not open blending"); + click(&mut h, blend.center()); + assert!(crate::smart_filter_dialog::owns(&h.state().ui.dialogs.last().unwrap().fields)); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__smartFilter"]["index"], json!(0)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + // Start a new gesture after egui's triple-click interval, as native users do. + for _ in 0..40 { + h.step(); + } + click(&mut h, edit.center()); + click(&mut h, edit.center()); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__command"], json!("filter.blur.boxBlur")); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["radius"].as_f64(), Some(4.0)); + } + } +} + +#[test] +fn disclosure_hides_both_stacks_without_changing_pixels_history_or_selection() { + let mut h = harness(290.0, 1.5, Default::default()); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("layer.layerStyle.dropShadow", json!({"layer":layer.0})).unwrap(); + h.state_mut().run("layer.new.layer", json!({"name":"Selected elsewhere"})).unwrap(); + h.run_steps(5); + let before = h.state().session.active().unwrap().doc.clone(); + let revision = h.state().session.active().unwrap().revision; + let steps = h.state().session.active().unwrap().history.past_len(); + let active = h.state().session.active().unwrap().active_layer; + let at = h.get_by_label("Collapse effects Smart Object").rect().center(); + click(&mut h, at); + assert!(h.state().session.active().unwrap().fx_collapsed.contains(&layer)); + assert!(h.query_by_label("Blending Options (Box Blur)").is_none()); + assert!(h.query_by_label("Drop Shadow").is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps); + assert_eq!(h.state().session.active().unwrap().active_layer, active); + let at = h.get_by_label("Expand effects Smart Object").rect().center(); + click(&mut h, at); + assert!(h.query_by_label("Blending Options (Box Blur)").is_some()); +} + +#[test] +fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending() { + let (mut app, layer) = app(8, "rgb"); + app.run("image.adjustments.hueSaturation", json!({"hue":35,"saturation":12,"lightness":-7})).unwrap(); + let ctx = egui::Context::default(); + let sm = smart(&app, layer).clone(); + super::open_editor(&mut app, &ctx, layer.0, &sm, 2); + let id = app.ui.dialogs.last().unwrap().id; + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(fields["hue"], json!(35)); + assert!(crate::adjust_dialog::owns(fields)); + fields.insert("hue".into(), json!(75)); + let idx = app.session.active_index().unwrap(); + assert!(!crate::adjust_preview::on_layer(&mut app, idx)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + let steps = app.session.active().unwrap().history.past_len(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).smart_filters.len(), 3); + assert_eq!(smart(&app, layer).smart_filters[2].params["hue"], json!(75)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[2].params["hue"], json!(35)); + app.run("image.adjustments.invert", json!({})).unwrap(); + let sm = smart(&app, layer).clone(); + super::open_editor(&mut app, &ctx, layer.0, &sm, 3); + assert!(app.ui.dialogs.is_empty(), "Invert has no parameter editor"); +} + +#[test] +fn smart_and_layer_masks_have_separate_thumbnail_cache_entries() { + let (mut app, layer) = app(8, "rgb"); + app.run("layer.layerMask.revealAll", json!({"layer":layer.0})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let mask = doc.layer(layer).unwrap().mask.as_ref().unwrap(); + let ctx = egui::Context::default(); + let first = app.mask_thumb(&ctx, &doc, layer, mask); + let mut other = mask.clone(); + other.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[0.0]); + let second = app.mask_thumb_kind(&ctx, &doc, layer, &other, crate::mask_thumbs_ui::THUMB_SMART_MASK); + assert_ne!(first, second); + assert_eq!(first, app.mask_thumb(&ctx, &doc, layer, mask)); +} + +#[test] +fn ctrl_i_uses_existing_invert_command_and_undo_redo_keep_the_source() { + let mut h = harness(290.0, 1.0, Default::default()); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let before = smart(h.state(), layer).clone(); + let source = photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(); + let steps = h.state().session.active().unwrap().history.past_len(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::I); + h.run_steps(4); + let sm = smart(h.state(), layer); + assert_eq!(sm.smart_filters.len(), 3); + assert_eq!(sm.smart_filters[2].command, "image.adjustments.invert"); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); + assert_ne!(sm.cache.as_ref().unwrap().pixel(8, 8), before.cache.as_ref().unwrap().pixel(8, 8)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(h.state(), layer).smart_filters.len(), 2); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(smart(h.state(), layer).smart_filters.len(), 3); +} diff --git a/docs/smart-filters.md b/docs/smart-filters.md new file mode 100644 index 0000000000..74c32ea46c --- /dev/null +++ b/docs/smart-filters.md @@ -0,0 +1,51 @@ +# Smart Filters in the Layers panel + +A Smart Object with filters has a rings indicator and a disclosure arrow beside +its name. The arrow hides or shows both its layer effects and Smart Filters; it +does not disable them or change the document. Alt-click applies that view choice +to all layers with effects or Smart Filters. + +The Smart Filters header shows the shared filter mask and the stack's visibility +control. Filters appear newest first, while the engine evaluates them from bottom +to top. Each filter has its own visibility control. + +Double-click a filter's name to edit its saved parameters in its existing editor. +OK updates that filter; it does not append a second copy. Parameter-free filters +such as Invert have no settings dialog. Camera Raw retains its dedicated editor. + +Double-click the sliders icon at the right of a filter to open **Blending Options**. +Mode and Opacity belong to that filter, independently of the layer and other +filters. Opacity is shown as a percentage (1% means 0.01). Preview changes the +temporary canvas composite and the dialog's image viewport. Zoom controls and +scrolling affect only that viewport. The viewport uses a bounded image thumbnail; +it is a navigation preview, not a full-resolution detail inspection. + +OK applies one undoable edit. Cancel or Escape leaves the document untouched. +Editors capture their document snapshot, revision, layer and filter. Reopening +the same saved document also invalidates a retained dialog. A document switch, +Undo/Redo, filter deletion/reorder or pending document job requires reopening the +dialog rather than applying settings to a changed target. + +Ctrl+I / Image > Adjustments > Invert on Smart Object content uses the existing +engine adjustment command to add an Invert Smart Filter, preserving the source. +When a layer mask or channel is targeted, its existing adjustment semantics apply. + +## Storage and current limits + +`.pcraft` stores the complete Smart Filter stack, parameters, shared mask, +visibility and per-filter blending. PSD only supports the filters mapped by +`crates/io/src/smart_map.rs`: Invert and Smart Sharpen currently lack live PSD +mappings. PSD export can warn and omit unmapped live filters. This interface +change does not extend the PSD mapping table or unknown-descriptor support. + +Preview reuses the native filter worker and reduced document pipeline. Some +filters require full-resolution previews. Committing edits and toggling filter +eyes retain the cached stages and background jobs already supplied by the engine +(PR #2739). This change does not add a new GPU filter implementation or establish +large-image performance budgets. + +Filter opacity interpolates premultiplied input and filtered results, so +alpha-preserving filters retain translucent coverage at partial strength. This +is scoped to Smart Filters; global layer compositing is unchanged. Dissolve +currently follows the inherited Smart Filter color-blending path without +per-pixel coverage sampling; exact Dissolve parity remains a separate task. From 1515cafae4919da8f8fb4227d5ff79f813f87788 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:31:23 +0300 Subject: [PATCH 0008/1452] fix(engine): retain Smart Filter coverage around blur edges --- crates/engine/src/smart_cmds.rs | 13 ++++++++- crates/engine/src/smart_cmds/tests.rs | 35 +++++++++++++++++++++++- crates/engine/tests/photoshop_oracles.rs | 12 ++++++++ docs/smart-filters.md | 6 +++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index d80596a681..366011daf1 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -291,10 +291,21 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) } let n = fmt.channels(); let (b, t) = (base.read_region(area), top.read_region(area)); + // Alpha-preserving filters edit the same coverage. A non-Normal filter that + // changes coverage (e.g. blur) must also retain input-only pixels and admit + // filtered-only pixels, as Photoshop's Smart Filter blend oracles require. + // Classify the whole result so unchanged pixels do not switch blend rules + // independently at the edge of a blur. + let composite_coverage = mode != BlendMode::Normal && fmt.alpha && b.chunks_exact(n).zip(t.chunks_exact(n)).any(|(bp, tp)| bp.last() != tp.last()); let mut o = vec![0.0f32; b.len()]; for ((bp, tp), op) in b.chunks_exact(n).zip(t.chunks_exact(n)).zip(o.chunks_exact_mut(n)) { let b = photocraft_raster::to_rgba(&fmt, bp); let t = photocraft_raster::to_rgba(&fmt, tp); + if composite_coverage { + let rgba = photocraft_color::blend::composite(mode, b, t, opacity); + photocraft_raster::from_rgba_into(&fmt, rgba, op); + continue; + } let cb = [b[0], b[1], b[2]]; let ct = [t[0], t[1], t[2]]; let color = if fmt.sample == photocraft_color::SampleType::F32 { @@ -304,7 +315,7 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) }; // A filter edits this surface, rather than adding a second source-over layer. Mix // premultiplied results so alpha-preserving filters keep coverage at every opacity, - // while filters that actually change coverage (e.g. blur) interpolate it too. + // while Normal-mode filters that change coverage interpolate it too. let a = b[3] + (t[3] - b[3]) * opacity; let mut rgba = [0.0, 0.0, 0.0, a]; if a > 0.0 { diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index b496e04514..55d5fa9642 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -23,7 +23,7 @@ fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { photocraft_color::SampleType::F32 => 1e-6, }; for opacity in [0.0, 0.01, 0.5, 1.0] { - for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Luminosity] { + for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Luminosity] { let mixed = blend_surfaces(&base, &filtered, mode, opacity).pixel(0, 0); assert!((mixed[3] - before[3]).abs() <= tolerance); if mode == BlendMode::Normal { @@ -42,6 +42,39 @@ fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { } } +#[test] +fn non_normal_coverage_changes_retain_source_over_semantics() { + for sample in photocraft_color::SampleType::ALL { + let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let mut base = Surface::new(fmt); + let mut filtered = Surface::new(fmt); + // Input-only, output-only, opaque input with blurred output, and a + // translucent pixel whose alpha happens to be unchanged by this filter. + for (x, (ba, ta)) in [(0.5, 0.0), (0.0, 0.5), (1.0, 0.25), (0.5, 0.5)].into_iter().enumerate() { + let x = x as i32; + base.fill_rect(Rect::new(x, 0, 1, 1), &[0.2, 0.4, 0.6, ba]); + filtered.fill_rect(Rect::new(x, 0, 1, 1), &[0.8, 0.6, 0.4, ta]); + } + for mode in [BlendMode::Screen, BlendMode::Multiply, BlendMode::Luminosity] { + for opacity in [0.0, 0.01, 0.5, 1.0] { + let mixed = blend_surfaces(&base, &filtered, mode, opacity); + for x in 0..4 { + if opacity == 0.0 { + assert_eq!(mixed.pixel(x, 0), base.pixel(x, 0)); + continue; + } + let b = photocraft_raster::to_rgba(&fmt, &base.pixel(x, 0)); + let t = photocraft_raster::to_rgba(&fmt, &filtered.pixel(x, 0)); + let expected = photocraft_color::blend::composite(mode, b, t, opacity); + let mut reference = Surface::new(fmt); + reference.fill_rect(Rect::new(x, 0, 1, 1), &expected); + assert_eq!(mixed.pixel(x, 0), reference.pixel(x, 0), "{sample:?} {mode:?} {opacity} x={x}"); + } + } + } + } +} + fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/photoshop_oracles.rs index 2edd13b2ba..e07b451efc 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/photoshop_oracles.rs @@ -80,6 +80,18 @@ fn rerender(doc: &mut Document) -> Result { Ok(n) } +#[test] +fn screen_motion_blur_retains_input_coverage() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-blend-screen-100.psd"); + let bytes = std::fs::read(&path).expect("Screen/Motion Blur oracle missing: run cargo xtask corpus --all"); + let file = PsdFile::from_bytes(&bytes).expect("parse Screen/Motion Blur oracle"); + let mut doc = photocraft_io::import("sf-blend-screen-100.psd", &bytes).expect("import Screen/Motion Blur oracle").document; + assert!(rerender(&mut doc).expect("re-render Screen/Motion Blur oracle") > 0); + let merged = photocraft_io::merged_composite(&file).expect("Photoshop merged composite"); + let (error, bad) = max_diff(&photocraft_compose::flatten(&doc).px, &merged); + assert!(error <= PASS_TOL, "Screen/Motion Blur error {error}, {bad}% pixels exceed 2/255"); +} + #[test] fn photoshop_oracles_rerendered() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop"); diff --git a/docs/smart-filters.md b/docs/smart-filters.md index 74c32ea46c..278c989966 100644 --- a/docs/smart-filters.md +++ b/docs/smart-filters.md @@ -46,6 +46,10 @@ large-image performance budgets. Filter opacity interpolates premultiplied input and filtered results, so alpha-preserving filters retain translucent coverage at partial strength. This -is scoped to Smart Filters; global layer compositing is unchanged. Dissolve +also applies to Normal mode when a filter changes coverage. Non-Normal modes +use source-over compositing when the filtered surface changes alpha, preserving +input-only and filtered-only pixels around blur edges. The choice applies to +the whole filtered surface. This is scoped to Smart Filters; global layer +compositing is unchanged. Dissolve currently follows the inherited Smart Filter color-blending path without per-pixel coverage sampling; exact Dissolve parity remains a separate task. From c17cc118f550ed5c15837428833032dc1d892895 Mon Sep 17 00:00:00 2001 From: Fabian Ghenof Date: Sun, 11 Oct 2026 11:11:48 +0300 Subject: [PATCH 0009/1452] Moving a shape keeps its rendering: Artboards to Files matches Photoshop translate_vectors re-rendered a shape layer on every move. For a shape from a PSD that replaced Photoshop's anti-aliasing with PhotoCraft's, so Artboards to Files (which moves each board to the origin), the Move tool, nudges, layer comps and moving artboards changed how imported shapes look. A whole-pixel move now shifts the rendered pixels, through one shift_shape helper shared with the Move tool's live preview (layer_multi_cmds:: shift_shown), which already did this. The render is cut at the canvas, so a shape reaching past it still renders again, as does a fractional move; the path moves either way, and fit_canvas still re-renders shapes when an artboard move grows the canvas (#2389). Measured on a Steam store asset template with #3218 (the same fix for type): the "How to use" artboard has no pixel off by more than 2 levels from Photoshop's own composite; before, both its shapes differed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 14 +----- crates/engine/src/vector_cmds.rs | 22 +++++++-- crates/engine/src/vector_cmds/tests.rs | 68 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 5db8f52703..75ddffda90 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -345,19 +345,7 @@ fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { *c = translate_surface(c, dx, dy); } } - LayerContent::Shape(sh) => { - // The rendered shape is cut at the canvas: one that reaches past it renders again. - let canvas = doc.bounds(); - let inside = sh.cache.as_ref().is_some_and(|c| { - let b = photocraft_compose::bounds::content_bounds(c); - b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) - }); - crate::vector_cmds::transform_shape(sh, &a); - match &mut sh.cache { - Some(c) if inside => *c = translate_surface(c, dx, dy), - _ => crate::vector_cmds::refresh_shape(doc, sh), - } - } + LayerContent::Shape(sh) => crate::vector_cmds::shift_shape(doc, sh, f64::from(dx), f64::from(dy)), LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy), LayerContent::Group(g) => { if let Some(ab) = &mut g.artboard { diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index fda3ba8b46..6b44efd56a 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -481,10 +481,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { vm.path = vm.path.transform(&a); } match &mut l.content { - LayerContent::Shape(sh) => { - transform_shape(sh, &a); - refresh_shape(doc, sh); - } + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), LayerContent::Group(g) => { for c in &mut g.children { translate_vectors(doc, c, dx, dy); @@ -494,6 +491,23 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { } } +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + /// Applies an affine transform to a shape's path, keeping the live shape when the transform /// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 6f41eb03c1..5346f49dfd 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -666,3 +666,71 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } } + +/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not +/// what PhotoCraft would draw, a block of another colour, so a re-render shows). +fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId { + s.edit("shape", |doc, active| { + let fmt = doc.pixel_format(); + let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height())); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0])); + let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() }; + let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + }) + .unwrap() +} + +fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape layer: {}", other.kind_name()), + } +} + +fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool { + let c = sh.cache.as_ref().unwrap(); + let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y)); + (px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01 +} + +#[test] +fn moving_and_exporting_imported_shapes_keeps_their_rendering() { + for depth in [8, 16, 32] { + let mut s = session(100, 80, depth); + let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40)); + // The Move tool: the same pixels, moved; the path moves with them. + s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}"); + assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered"); + assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too"); + // Artboards to Files moves the board to the origin the same way. + s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap(); + let board = doc(&s).artboards()[0].0; + s.edit("into board", |doc, _| { + let l = doc.remove(id).unwrap(); + let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") }; + g.children.push(l); + Ok(()) + }) + .unwrap(); + let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap(); + let sh = shape_layer(&one, id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}"); + assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered"); + } +} + +#[test] +fn a_shape_reaching_past_the_canvas_renders_again_when_moved() { + let mut s = session(100, 80, 8); + // Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in. + let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40)); + s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)"); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows"); +} From 46f046ca4e916c9c1dcb90c36a9cd53ec8092d3b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 16:26:20 +0200 Subject: [PATCH 0010/1452] scorecard: five rows catch up with work already on main AGENTS.md sends contributors to the scorecard's missing and partial rows when picking work; these five described code that has since landed. - TYPE-218-7 missing -> done: Warp Text opens with the layer's warp (filter_dialog.rs warp_defaults) and previews live; covered by warp_text_dialog_tests.rs and warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy. - TOOL-214-13 partial -> done: Properties shows Density and Feather for both pixel and vector masks (mask_props_ui.rs, mask_props_ui_tests.rs). - BUG-207-5 missing -> done: all twelve retouch tools are tested against mask, alpha channel, Channels-panel and Quick Mask targets; Sample All Layers and the smudge fringe have their own tests (retouch_cmds/tests.rs). Pencil and Mixer Brush targets are still untested, noted in the row. - UI-217-18 missing -> partial: Alt+wheel zooms around the pointer at x1.1 a notch, measured on Photoshop in #1718, not the ~5 % the row asks for; the status bar zoom controls fit (#2823); Navigator not checked. - BUG-208-4 stays partial with a corrected note: marquee add/subtract, Lasso add/subtract/intersect and Polygonal Lasso are tested from modifiers held at the press; a marquee Shift+Alt intersect test is still missing. docs/gaps.md (G8), docs/target-app-parity.md (Type row) and docs/ui-parity.md updated to match, with revision-history rows. docs/scorecard.md regenerated: Tools 26/15/2, Workspace and UI 8/9/6, Type 2/4/3, Open bugs 30/2/0. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 12 +++++++----- docs/scorecard.md | 18 +++++++++--------- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 7 ++++--- scorecard/bugs.toml | 6 +++--- scorecard/tools.toml | 4 ++-- scorecard/type.toml | 4 ++-- scorecard/ui.toml | 4 ++-- 8 files changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index a2ec3dddc4..bc99f05298 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every known shortfall ranked, from the 2026-10-10 re-measure) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: kerning and the Warp Text preview are done) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -78,10 +78,11 @@ what an agent can't finish alone. ### G8. Type depth - **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script fallback fonts and golden tests (TYPE-218-3), variable-font axes (TYPE-218-4), OpenType panel - UI (TYPE-218-5), manual and optical kerning with PSD round trip (TYPE-218-6, #206), live Warp - Text preview (TYPE-218-7, #2689), triple/quadruple click (TYPE-218-8), "rasterize and continue" - for distort/perspective on type (TYPE-218-9), text free transform (#2630). -- **Evidence:** scorecard Type: 0 done, 3 partial, 6 missing. The Type tool froze the app for + UI (TYPE-218-5), triple/quadruple click (TYPE-218-8), "rasterize and continue" for + distort/perspective on type (TYPE-218-9), text free transform (#2630). Done since the + re-measure: manual and optical kerning with PSD round trip (TYPE-218-6, #206) and the live Warp + Text preview that opens with the layer's warp (TYPE-218-7, #2689). +- **Evidence:** scorecard Type: 2 done, 4 partial, 3 missing (2026-10-11). The Type tool froze the app for 60 s on one macOS machine (#2518). - **Estimate:** 120–200 h. @@ -212,5 +213,6 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | G8 Type depth: manual/optical kerning (TYPE-218-6) and the live Warp Text preview (TYPE-218-7) moved to done; scorecard Type evidence updated to 2/4/3 | | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/scorecard.md b/docs/scorecard.md index c0c15c3187..b1d729a68f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,13 +8,13 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 25 | 16 | 2 | | -| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 32 of 154 (target 0) | -| [Type](#type) | 1 | 4 | 4 | | +| [Tools](#tools) | 26 | 15 | 2 | | +| [Workspace and UI](#workspace-and-ui) | 9 | 9 | 5 | settings that do nothing: 32 of 154 (target 0) | +| [Type](#type) | 2 | 4 | 3 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | -| [Open bugs](#open-bugs) | 29 | 2 | 1 | | +| [Open bugs](#open-bugs) | 30 | 2 | 0 | | ## Performance @@ -157,7 +157,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); no Shift 45 deg, Backspace or Alt while drawing | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | -| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | +| TOOL-214-13 | Mask Properties: Density and Feather controls | done | [#214](https://github.com/storytold/photocraft/issues/214) | Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs; mask_props_ui_tests.rs pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, mask_slider_drags_preview_live_and_undo_as_separate_gestures, vector_mask_properties_edit_density_and_feather) | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | @@ -199,7 +199,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_dialog.rs adds black/gray/white eyedroppers; smoothing and pencil modes remain missing | | UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | -| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | +| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs; input_tests.rs alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked | | UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | @@ -216,7 +216,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | partial | [#218](https://github.com/storytold/photocraft/issues/218) | type_cmds.rs:184 accepts variations; FaceInfo.axes unused in the UI | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | -| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 | +| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs warp_defaults; warp_text_dialog_tests.rs warp_text_opens_with_the_type_layers_current_warp, warp_text_opens_at_none_for_a_layer_without_warp, remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (warp_text_dialog_previews_the_warp_without_editing_the_document, cancelling_warp_text_leaves_the_document_and_history_untouched, confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Cmd+A, paste and double-click exist; no triple/quadruple click, no tests | | TYPE-218-9 | Offer 'Rasterize and continue?' for Distort/Perspective/Warp on live type | missing | [#218](https://github.com/storytold/photocraft/issues/218) | No such prompt | @@ -298,9 +298,9 @@ Never-crash attribute on 28 of 28 crate roots. | BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) | | BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) | | BUG-207-4 | edit.transform transforms a targeted alpha channel | done | [#207](https://github.com/storytold/photocraft/issues/207) | edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel | -| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) | +| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | done | [#207](https://github.com/storytold/photocraft/issues/207) | Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask, only a targeted alpha channel, and to follow the Channels panel target (engine retouch_cmds/tests.rs retouch_tools_paint_only_the_targeted_mask, retouch_tools_paint_only_the_targeted_alpha_channel, retouch_tools_follow_the_channels_panel_target, retouch_tools_paint_the_quick_mask, retouch_targets_fail_gracefully). Sample All Layers changes the result over two layers (sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, sample_all_layers_smudge_drags_colour_from_below, sample_all_layers_spot_healing_heals_onto_an_empty_layer, clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs sample_all_layers_reaches_the_command); no smudge fringe (smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: tests.rs painting_can_target_the_layer_mask, channel_cmds/tests.rs painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel | | BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners applies Shift via Drag::marquee_mods; marquee_tests.rs::shift_during_the_drag_draws_a_square_and_the_readout_follows covers the drag | | BUG-208-2 | Alt while dragging a marquee draws from the centre | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners centres Alt drags; marquee_tests.rs::alt_draws_from_the_centre_and_shift_alt_a_centred_circle verifies rectangles and ellipses | | BUG-208-3 | W x H px readout beside the cursor while dragging a marquee | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_readout draws the readout; marquee_tests.rs asserts dimensions while dragging and its removal after release | -| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195) | +| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (marquee_tests.rs modifiers_held_before_the_drag_pick_the_mode_not_the_shape). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift/Alt at the first click keep the selection and combine (polygon_lasso_tests.rs modifiers_at_the_first_click_keep_and_combine_the_selection). Missing: a test that Shift+Alt held before a marquee drag intersects | | BUG-208-5 | Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size | done | [#208](https://github.com/storytold/photocraft/issues/208) | crates/ui-egui/src/marquee_tests.rs covers Shift, Alt, selection bounds, W/H readout and drag completion | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7a4f238cc8..7bac928a5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment into this file) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Type evidence: kerning and the Warp Text preview are done; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -173,7 +173,7 @@ parity for the area and are already contained in the dimension rows above. | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | -| Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | +| Type | 40% | 120–200 | Engine works; manual/optical kerning and a live Warp Text preview (2026-10-11); no type on a path, RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | @@ -537,6 +537,7 @@ See [method and results](octagonal-mask-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Type row evidence: manual/optical kerning and the live Warp Text preview are done (TYPE-218-6/7); the 40% estimate is not re-measured | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow alpha gate in `roadmap.md`: passes | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 91afa00dcc..b4a9080482 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-11 · **Change:** minor (Hand-tool flick panning) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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, @@ -103,7 +103,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | ⇧ square/circle, ⌥ from centre, live W × H readout (marquee) | done | `marquee_tests.rs` | -| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | partial | works; only a badge test (BUG-208-4) | +| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | partial | works; tested for the marquee (add, subtract), Lasso (all three) and Polygonal Lasso; no marquee ⇧⌥ intersect test (BUG-208-4) | | Space repositions the marquee while dragging | done | `hold_keys.rs` | | Polygonal Lasso ⇧ snaps to 45° | missing | #2352 | | Autoscroll at the window edge while selecting | missing | #2231 | @@ -164,7 +164,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | | Animated zoom | missing | unread pref; #566 | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | -| ⌥-wheel zoom in gentle steps | missing | UI-217-18 | +| ⌥-wheel zoom around the pointer | partial | ×1.1 per notch, measured on Photoshop (#1718; `wheel_nav.rs`, `input_tests.rs`); #217 asked for ~5 % steps (UI-217-18) | | Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | | Trackpad rotate drives Rotate View | done | `rotate_view.rs:136` | | Zoom levels to 3200% and beyond | partial | #1813 | @@ -209,3 +209,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | +| 2026-10-11 | minor | Selection modifiers at press: evidence now names the marquee, Lasso and Polygonal Lasso tests (still partial: no marquee ⇧⌥ intersect test). ⌥-wheel zoom: missing → partial (works at ×1.1 a notch; #217 asked for ~5 %) | diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index a4620bf7d6..2d701c9b9a 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -191,9 +191,9 @@ note = "edit.transform follows a targeted alpha channel or Quick Mask (explicit [[item]] id = "BUG-207-5" target = "Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe" -status = "missing" +status = "done" issue = 207 -note = "Only smudge_drags_colour_along_the_stroke (retouch.rs:407)" +note = "Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask, only a targeted alpha channel, and to follow the Channels panel target (engine retouch_cmds/tests.rs retouch_tools_paint_only_the_targeted_mask, retouch_tools_paint_only_the_targeted_alpha_channel, retouch_tools_follow_the_channels_panel_target, retouch_tools_paint_the_quick_mask, retouch_targets_fail_gracefully). Sample All Layers changes the result over two layers (sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, sample_all_layers_smudge_drags_colour_from_below, sample_all_layers_spot_healing_heals_onto_an_empty_layer, clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs sample_all_layers_reaches_the_command); no smudge fringe (smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: tests.rs painting_can_target_the_layer_mask, channel_cmds/tests.rs painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel" [[item]] id = "BUG-208-1" @@ -221,7 +221,7 @@ id = "BUG-208-4" target = "Add/subtract/intersect from modifiers pressed before the drag, kept by a test" status = "partial" issue = 208 -note = "Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195)" +note = "Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (marquee_tests.rs modifiers_held_before_the_drag_pick_the_mode_not_the_shape). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift/Alt at the first click keep the selection and combine (polygon_lasso_tests.rs modifiers_at_the_first_click_keep_and_combine_the_selection). Missing: a test that Shift+Alt held before a marquee drag intersects" [[item]] id = "BUG-208-5" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6c9f5f8d55..540293a6d4 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -197,9 +197,9 @@ note = "select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a [[item]] id = "TOOL-214-13" target = "Mask Properties: Density and Feather controls" -status = "partial" +status = "done" issue = 214 -note = "Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls" +note = "Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs; mask_props_ui_tests.rs pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, mask_slider_drags_preview_live_and_undo_as_separate_gestures, vector_mask_properties_edit_density_and_feather)" [[item]] id = "TOOL-214-14" diff --git a/scorecard/type.toml b/scorecard/type.toml index faf1f88dc5..ece895b305 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -49,9 +49,9 @@ note = "Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pa [[item]] id = "TYPE-218-7" target = "Warp Text dialog previews live and opens with the layer's current warp" -status = "missing" +status = "done" issue = 218 -note = "view_cmds.rs:834 always seeds arc, bend 50" +note = "Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs warp_defaults; warp_text_dialog_tests.rs warp_text_opens_with_the_type_layers_current_warp, warp_text_opens_at_none_for_a_layer_without_warp, remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (warp_text_dialog_previews_the_warp_without_editing_the_document, cancelling_warp_text_leaves_the_document_and_history_untouched, confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy)" [[item]] id = "TYPE-218-8" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 8ffa8ca4be..1f784b832a 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -127,9 +127,9 @@ note = "panels.rs:1837 single field + hex/RGB readout" [[item]] id = "UI-217-18" target = "Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped" -status = "missing" +status = "partial" issue = 217 -note = "canvas.rs:1107-1125 no Alt handling" +note = "Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs; input_tests.rs alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked" [[item]] id = "UI-217-19" From 0a75c37c7a75599850c64513f8669941f82e2fe2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 16:44:26 +0200 Subject: [PATCH 0011/1452] Move tool: show all six Align icons and an icon menu for Distribute (#3490) Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 +- assets/icons/align-edge-bottom.svg | 16 ++++++ assets/icons/align-edge-hcenter.svg | 16 ++++++ assets/icons/align-edge-left.svg | 16 ++++++ assets/icons/align-edge-right.svg | 16 ++++++ assets/icons/align-edge-top.svg | 16 ++++++ assets/icons/align-edge-vcenter.svg | 16 ++++++ assets/icons/distribute-bottom.svg | 19 +++++++ assets/icons/distribute-hcenter.svg | 22 ++++++++ assets/icons/distribute-horizontal.svg | 16 ++++++ assets/icons/distribute-left.svg | 19 +++++++ assets/icons/distribute-right.svg | 19 +++++++ assets/icons/distribute-top.svg | 19 +++++++ assets/icons/distribute-vcenter.svg | 22 ++++++++ assets/icons/distribute-vertical.svg | 16 ++++++ crates/ui-egui/src/align_bar.rs | 76 ++++++++++++++++++++++++++ crates/ui-egui/src/icon_data.rs | 14 +++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 29 +--------- 19 files changed, 342 insertions(+), 29 deletions(-) create mode 100644 assets/icons/align-edge-bottom.svg create mode 100644 assets/icons/align-edge-hcenter.svg create mode 100644 assets/icons/align-edge-left.svg create mode 100644 assets/icons/align-edge-right.svg create mode 100644 assets/icons/align-edge-top.svg create mode 100644 assets/icons/align-edge-vcenter.svg create mode 100644 assets/icons/distribute-bottom.svg create mode 100644 assets/icons/distribute-hcenter.svg create mode 100644 assets/icons/distribute-horizontal.svg create mode 100644 assets/icons/distribute-left.svg create mode 100644 assets/icons/distribute-right.svg create mode 100644 assets/icons/distribute-top.svg create mode 100644 assets/icons/distribute-vcenter.svg create mode 100644 assets/icons/distribute-vertical.svg create mode 100644 crates/ui-egui/src/align_bar.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0d9edd9c67..1dc55cccda 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,7 +20,7 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -29,6 +29,7 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/assets/icons/align-edge-bottom.svg b/assets/icons/align-edge-bottom.svg new file mode 100644 index 0000000000..e2993c8305 --- /dev/null +++ b/assets/icons/align-edge-bottom.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-hcenter.svg b/assets/icons/align-edge-hcenter.svg new file mode 100644 index 0000000000..264b4fe9bb --- /dev/null +++ b/assets/icons/align-edge-hcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-left.svg b/assets/icons/align-edge-left.svg new file mode 100644 index 0000000000..192310fd3f --- /dev/null +++ b/assets/icons/align-edge-left.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-right.svg b/assets/icons/align-edge-right.svg new file mode 100644 index 0000000000..0386cf7475 --- /dev/null +++ b/assets/icons/align-edge-right.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-top.svg b/assets/icons/align-edge-top.svg new file mode 100644 index 0000000000..54d4bdce21 --- /dev/null +++ b/assets/icons/align-edge-top.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-vcenter.svg b/assets/icons/align-edge-vcenter.svg new file mode 100644 index 0000000000..6df5d35096 --- /dev/null +++ b/assets/icons/align-edge-vcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-bottom.svg b/assets/icons/distribute-bottom.svg new file mode 100644 index 0000000000..20d3b31c33 --- /dev/null +++ b/assets/icons/distribute-bottom.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-hcenter.svg b/assets/icons/distribute-hcenter.svg new file mode 100644 index 0000000000..34faee88f4 --- /dev/null +++ b/assets/icons/distribute-hcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-horizontal.svg b/assets/icons/distribute-horizontal.svg new file mode 100644 index 0000000000..fef7592891 --- /dev/null +++ b/assets/icons/distribute-horizontal.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-left.svg b/assets/icons/distribute-left.svg new file mode 100644 index 0000000000..075305c0c0 --- /dev/null +++ b/assets/icons/distribute-left.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-right.svg b/assets/icons/distribute-right.svg new file mode 100644 index 0000000000..659d58fbee --- /dev/null +++ b/assets/icons/distribute-right.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-top.svg b/assets/icons/distribute-top.svg new file mode 100644 index 0000000000..1f02d74c9b --- /dev/null +++ b/assets/icons/distribute-top.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-vcenter.svg b/assets/icons/distribute-vcenter.svg new file mode 100644 index 0000000000..4d1734a083 --- /dev/null +++ b/assets/icons/distribute-vcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-vertical.svg b/assets/icons/distribute-vertical.svg new file mode 100644 index 0000000000..fdc7c6abd2 --- /dev/null +++ b/assets/icons/distribute-vertical.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui-egui/src/align_bar.rs b/crates/ui-egui/src/align_bar.rs new file mode 100644 index 0000000000..d663283e02 --- /dev/null +++ b/crates/ui-egui/src/align_bar.rs @@ -0,0 +1,76 @@ +//! The Move tool's Align and Distribute controls (Photoshop shows them whenever the Move tool is +//! active; they enable once enough layers are selected). Every button dispatches an existing +//! `layer.align.*` / `layer.distribute.*` command. + +use serde_json::json; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// (command id, icon, label). The labels are the Layer menu's, already translated. +const ALIGN: [(&str, &str, &str); 6] = [ + ("layer.align.leftEdges", "align-edge-left", "Left Edges"), + ("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"), + ("layer.align.rightEdges", "align-edge-right", "Right Edges"), + ("layer.align.topEdges", "align-edge-top", "Top Edges"), + ("layer.align.verticalCenters", "align-edge-vcenter", "Vertical Centers"), + ("layer.align.bottomEdges", "align-edge-bottom", "Bottom Edges"), +]; + +const DISTRIBUTE: [(&str, &str, &str); 8] = [ + ("layer.distribute.topEdges", "distribute-top", "Top Edges"), + ("layer.distribute.verticalCenters", "distribute-vcenter", "Vertical Centers"), + ("layer.distribute.bottomEdges", "distribute-bottom", "Bottom Edges"), + ("layer.distribute.leftEdges", "distribute-left", "Left Edges"), + ("layer.distribute.horizontalCenters", "distribute-hcenter", "Horizontal Centers"), + ("layer.distribute.rightEdges", "distribute-right", "Right Edges"), + ("layer.distribute.horizontally", "distribute-horizontal", "Horizontally"), + ("layer.distribute.vertically", "distribute-vertical", "Vertically"), +]; + +/// The six align icons followed by a menu holding both groups with icons and names. +pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing.x = 0.0; + for (cmd, icon, label) in ALIGN { + let tip = format!("{}: {}", tl!("Align"), tl!(label)); + let on = app.session.is_enabled(cmd); + if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, &tip)).inner.clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.add_space(6.0); + let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(200.0); + let mut section = |ui: &mut egui::Ui, title: &str, items: &[(&str, &str, &str)]| { + ui.label(egui::RichText::new(title).small().color(t.text_dim)); + for (cmd, icon, label) in items { + let on = app.session.is_enabled(cmd); + let tint = if on { t.icon } else { t.text_dim }; + let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); + if ui.add_enabled(on, button).clicked() { + let _ = app.run(cmd, json!({})); + ui.close(); + } + } + }; + section(ui, tl!("Align"), &ALIGN); + ui.separator(); + section(ui, tl!("Distribute"), &DISTRIBUTE); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_button_is_a_live_command_with_an_icon() { + let specs = photocraft_engine::command_specs(); + for (cmd, icon, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert!(specs.iter().any(|c| c.id == *cmd), "{cmd} is not a command"); + assert!(icons::exists(icon), "{icon} is not embedded"); + } + } +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 11658b1d1a..ccd1536918 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -6,6 +6,20 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), + ("align-edge-bottom", include_bytes!("../../../assets/icons/align-edge-bottom.svg")), + ("align-edge-hcenter", include_bytes!("../../../assets/icons/align-edge-hcenter.svg")), + ("align-edge-left", include_bytes!("../../../assets/icons/align-edge-left.svg")), + ("align-edge-right", include_bytes!("../../../assets/icons/align-edge-right.svg")), + ("align-edge-top", include_bytes!("../../../assets/icons/align-edge-top.svg")), + ("align-edge-vcenter", include_bytes!("../../../assets/icons/align-edge-vcenter.svg")), + ("distribute-bottom", include_bytes!("../../../assets/icons/distribute-bottom.svg")), + ("distribute-hcenter", include_bytes!("../../../assets/icons/distribute-hcenter.svg")), + ("distribute-horizontal", include_bytes!("../../../assets/icons/distribute-horizontal.svg")), + ("distribute-left", include_bytes!("../../../assets/icons/distribute-left.svg")), + ("distribute-right", include_bytes!("../../../assets/icons/distribute-right.svg")), + ("distribute-top", include_bytes!("../../../assets/icons/distribute-top.svg")), + ("distribute-vcenter", include_bytes!("../../../assets/icons/distribute-vcenter.svg")), + ("distribute-vertical", include_bytes!("../../../assets/icons/distribute-vertical.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), ("artboard", include_bytes!("../../../assets/icons/artboard.svg")), ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 39ca677e73..08e1f777f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod adjust_dialog; pub mod adjust_editors; pub mod adjust_preview; pub mod adjust_ui; +mod align_bar; mod alt_grab; pub mod analysis_ui; pub mod anchor_tools; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4d128f5c59..3558c0132e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1282,34 +1282,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls")); widgets::vline(ui, 22.0); - ui.spacing_mut().item_spacing.x = 0.0; - for (icon, tip, cmd) in [ - ("panels-top-left", tl!("Align left edges"), "layer.align.leftEdges"), - ("app-window", tl!("Align horizontal centers"), "layer.align.horizontalCenters"), - ("panel-right", tl!("Align right edges"), "layer.align.rightEdges"), - ] { - let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() { - let _ = app.run(cmd, json!({})); - } - } - ui.add_space(6.0); - let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(200.0); - let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| { - ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim)); - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) { - if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() { - let _ = app.run(c.id, json!({})); - ui.close(); - } - } - }; - section(ui, tl!("Align"), "layer.align."); - ui.separator(); - section(ui, tl!("Distribute"), "layer.distribute."); - }); + crate::align_bar::options(app, ui); } Tool::RectMarquee | Tool::EllipseMarquee => hint( ui, From 5aa9a1efae91e008b5f1ea6c8c107655f4ad419f Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:12:16 +0800 Subject: [PATCH 0012/1452] feat(ui): track recently used type families (#204) --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/lib.rs | 14 ++++++++++++++ crates/ui-egui/src/state.rs | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index eb8151508f..a042eff844 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1013,7 +1013,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.generatorEnabled", "type.missingGlyphProtection", "type.textEngine", - "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", "enhancedControls.touchGestures", "rawDefaults.colorSpace", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e77681ec92..81efe6be17 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1020,6 +1020,20 @@ impl PhotocraftApp { self.session.prefs.edit(|p| p.file_handling.recent_files = r); } + /// Records a picked type family for the font menu's Recent group, capped by + /// Preferences ▸ Type ▸ Recent Fonts (0 keeps no list), most recent first, de-duplicated. + pub fn note_recent_font(&mut self, family: &str) { + let cap = self.session.prefs().type_.recent_fonts.min(50) as usize; + let r = &mut self.ui.recent_fonts; + r.retain(|f| f != family); + if cap == 0 { + r.clear(); + return; + } + r.insert(0, family.to_string()); + r.truncate(cap); + } + /// File › Open Recent › Clear Recent File List. pub fn clear_recent(&mut self) { self.ui.recent_files.clear(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9e31124198..49d27d12c9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -967,6 +967,10 @@ pub struct UiState { /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, + /// Recently picked type families, most-recent first (the font menu's Recent group). Capped by + /// Preferences ▸ Type ▸ Recent Fonts; de-duplicated. + #[serde(default)] + pub recent_fonts: Vec, /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, @@ -1164,6 +1168,7 @@ impl Default for UiState { tool: Tool::Brush, group_tools: Vec::new(), recent_files: Vec::new(), + recent_fonts: Vec::new(), text_edit: None, type_transform: None, type_transform_pivot: None, From 6293fd87eb2e8b1a6833d532fe463ce6d5deff27 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:12:16 +0800 Subject: [PATCH 0013/1452] feat(ui): hoist recent families in the font menu (#204) --- crates/ui-egui/src/type_font_picker_tests.rs | 38 +++++++++++-- crates/ui-egui/src/type_panels_ui.rs | 11 ++-- crates/ui-egui/src/type_tool.rs | 59 +++++++++++++++++--- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 2fc600511f..0ba91455ed 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -12,6 +12,7 @@ struct State { families: Vec, popup: egui::Id, preview: FontPreview, + recents: Vec, } fn harness() -> Harness<'static, State> { @@ -19,15 +20,20 @@ fn harness() -> Harness<'static, State> { } fn harness_with(preview: FontPreview) -> Harness<'static, State> { + harness_with_recents(preview, Vec::new()) +} + +fn harness_with_recents(preview: FontPreview, recents: Vec) -> Harness<'static, State> { // Enough families that the unfiltered menu hits its maximum height and scrolls. let mut families: Vec = (0..60).map(|i| format!("Family {i:02}")).collect(); families.push("Zeta Sans".into()); - let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL, preview }; + let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL, preview, recents }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let recents = s.recents.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents); }, state, ); @@ -413,12 +419,14 @@ fn localized_font_names_label_the_menu_but_pick_the_family() { families: vec!["Arial".into(), "MS Gothic".into(), "Zeta Sans".into()], popup: egui::Id::NULL, preview: FontPreview::Medium, + recents: Vec::new(), }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let recents = s.recents.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents); }, state, ); @@ -453,7 +461,7 @@ fn font_menu_rows_search_both_names_and_sort_by_label() { let labels: HashMap = [("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); let rows = |labels: Option<&HashMap>, q: &str| -> Vec<(String, String)> { - super::font_menu_rows(&families, labels, q).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect() + super::font_menu_rows(&families, labels, q, &[]).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect() }; let english = rows(None, ""); assert!(english.iter().all(|(f, l)| f == l)); @@ -463,3 +471,25 @@ fn font_menu_rows_search_both_names_and_sort_by_label() { assert_eq!(rows(Some(&labels), "雅黑"), [("Microsoft YaHei".to_string(), "微软雅黑".to_string())]); assert!(rows(Some(&labels), "nothing").is_empty()); } + +/// Preferences ▸ Type ▸ Recent Fonts: recently used families move to the top (most recent first, +/// once each); a query drops the hoisting so the list stays alphabetical while searching. +#[test] +fn recent_fonts_are_hoisted_to_the_top() { + let families: Vec = ["Arial", "Baskerville", "Courier", "Zeta Sans"].iter().map(|s| s.to_string()).collect(); + let recents: Vec = vec!["Zeta Sans".into(), "Courier".into()]; + let rows = |q: &str| -> Vec { super::font_menu_rows(&families, None, q, &recents).into_iter().map(|(f, _)| f.clone()).collect() }; + assert_eq!(rows(""), ["Zeta Sans", "Courier", "Arial", "Baskerville"], "recents hoist, most recent first, once each"); + assert_eq!(rows("a"), ["Arial", "Baskerville", "Zeta Sans"], "a query drops the hoisting"); +} + +/// The menu itself draws the hoisted recents first. +#[test] +fn recent_fonts_appear_first_in_the_menu() { + let mut h = harness_with_recents(FontPreview::Medium, vec!["Zeta Sans".into(), "Family 05".into()]); + open(&mut h); + let zeta = h.get_by_label("Zeta Sans").rect().top(); + let five = h.get_by_label("Family 05").rect().top(); + let six = h.get_by_label("Family 06").rect().top(); + assert!(zeta < five && five < six, "recents come first, in order: {zeta} {five} {six}"); +} diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index fbab3f84f9..e2a5f57113 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -373,6 +373,8 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s let defined = |attrs: &Value, k: &str| attrs.get(k).is_some(); // Preferences ▸ Type ▸ Font Preview Size sizes the family picker's samples. let preview = app.session.prefs().type_.font_preview; + // The fonts the pickers hoist to the top (Preferences ▸ Type ▸ Recent Fonts). + let recents = app.ui.recent_fonts.clone(); // Preferences ▸ Units & Rulers ▸ Type: the model stores points; the fields may show pixels // (by the document's resolution) or millimetres. let (type_unit, unit_label) = crate::type_tool::type_unit(app); @@ -399,7 +401,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s ui.add_enabled_ui(on, |ui| match kind { "font" => { let mut f = v.as_str().unwrap_or("").to_string(); - if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview) { + if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview, &recents) { set = Some(json!({ panel: f })); } } @@ -508,9 +510,9 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s } /// Searchable family combo box. -fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview) -> bool { +fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool { let mut shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() }; - let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview); + let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview, recents); if changed { *current = shown; } @@ -600,7 +602,8 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Font family + style. ui.horizontal(|ui| { let mut f = family.clone(); - if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview) { + if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&f); app.ui.type_panels.glyph_family = f; app.ui.type_panels.glyph_style = tl!("Regular").into(); } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d80e234dd5..efa2931e7c 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -939,8 +939,10 @@ fn localized_families() -> Arc> { /// The font menu's rows: each family with the label it shows (its localized name when `labels` /// has one, else the family), matching `query` (lowercase) by either name. With localized labels -/// the rows sort by label, so the menu stays alphabetical as shown. -fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap>, query: &str) -> Vec<(&'a String, &'a str)> { +/// the rows sort by label, so the menu stays alphabetical as shown. Recently used families are +/// hoisted to the top, most recent first (Preferences ▸ Type ▸ Recent Fonts); a query hides the +/// hoisting so the list stays strictly alphabetical while searching. +fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap>, query: &str, recents: &[String]) -> Vec<(&'a String, &'a str)> { let label = |f: &'a String| labels.and_then(|l| l.get(f)).map_or(f.as_str(), String::as_str); let mut rows: Vec<(&String, &str)> = families .iter() @@ -950,6 +952,16 @@ fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap String { } /// Searchable font-family combo box. -fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview) -> bool { - family_picker_in(ui, "type-font", current, width, &families(), preview) +fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool { + family_picker_in(ui, "type-font", current, width, &families(), preview, recents) } /// Maximum height of the font menu. @@ -1031,8 +1043,15 @@ const FONT_MENU_HEIGHT: f32 = 460.0; /// [`font_picker`] over a given family list (tests pass their own). #[cfg(test)] -fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview) -> bool { - family_picker_in(ui, "type-font", current, width, families, preview) +fn font_picker_in( + ui: &mut egui::Ui, + current: &mut String, + width: f32, + families: &[String], + preview: photocraft_engine::prefs::FontPreview, + recents: &[String], +) -> bool { + family_picker_in(ui, "type-font", current, width, families, preview, recents) } /// Shared by the Type options, Character, style and Glyphs panels. Selection changes still @@ -1044,6 +1063,7 @@ pub(crate) fn family_picker_in( width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview, + recents: &[String], ) -> bool { let mut changed = false; let search_id = ui.id().with(("font-search", salt)); @@ -1086,7 +1106,7 @@ pub(crate) fn family_picker_in( d.insert_temp(search_id, q.clone()); }); let ql = q.to_lowercase(); - let rows = font_menu_rows(families, labels, &ql); + let rows = font_menu_rows(families, labels, &ql, recents); if down || up { let index = rows.iter().position(|(f, _)| *f == current); let next = match index { @@ -1304,7 +1324,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.status_error = true; } } - if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview) { + if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&fam); app.ui.tool_options.type_font = fam.clone(); let st = styles(&fam); style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) }; @@ -1616,7 +1637,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let w = field_width(full, 2, LABEL_W); let mut fam = c.font_family.clone(); row(ui, &mut |ui| { - if font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview) { + if font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&fam); app.ui.tool_options.type_font = fam.clone(); apply(app, ui.ctx(), json!({"font": fam})); } @@ -1897,6 +1919,25 @@ mod tests { app } + /// Preferences ▸ Type ▸ Recent Fonts caps the recorded list; a re-pick moves to the front and + /// 0 keeps no list. + #[test] + fn note_recent_font_caps_and_de_duplicates() { + let mut app = app(); + for f in ["A", "B", "C"] { + app.note_recent_font(f); + } + assert_eq!(app.ui.recent_fonts, ["C", "B", "A"]); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap(); + app.note_recent_font("D"); + assert_eq!(app.ui.recent_fonts, ["D", "C"], "the cap trims the tail"); + app.note_recent_font("C"); + assert_eq!(app.ui.recent_fonts, ["C", "D"], "a re-pick moves to the front"); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap(); + app.note_recent_font("E"); + assert!(app.ui.recent_fonts.is_empty(), "0 keeps no list"); + } + fn layer_text(app: &PhotocraftApp) -> String { let ed = app.ui.text_edit.as_ref().unwrap(); current_text(app, LayerId(ed.layer)).unwrap() From 2042a0ffb33bd47e584f29f0bbd7d5232b9e6440 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:12:16 +0800 Subject: [PATCH 0014/1452] docs(scorecard): settings that do nothing 31 of 154 --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 08e4bd6be8..1cffe7892a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 25 | 16 | 2 | | -| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 32 of 154 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 31 of 154 (target 0) | | [Type](#type) | 1 | 4 | 4 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 32 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 31 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 8781937ae25605d2fe43a866216bcdf88ef9b895 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:18:57 +0800 Subject: [PATCH 0015/1452] test(automation): tasks 21-30 in a second acceptance file --- .../automation/tests/agent_tasks_extended.rs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 crates/automation/tests/agent_tasks_extended.rs diff --git a/crates/automation/tests/agent_tasks_extended.rs b/crates/automation/tests/agent_tasks_extended.rs new file mode 100644 index 0000000000..4f950ed1a8 --- /dev/null +++ b/crates/automation/tests/agent_tasks_extended.rs @@ -0,0 +1,184 @@ +//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD). +//! +//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the +//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the +//! exported files, never engine internals. + +use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp}; +use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig}; +use rmcp::service::RunningService; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Value, json}; + +#[derive(Clone, Default)] +struct Client; +impl ClientHandler for Client { + fn get_info(&self) -> ClientConfig { + ClientConfig::default() + } +} + +type Conn = RunningService; +type Server = tokio::task::JoinHandle<()>; + +async fn connect(root: &std::path::Path) -> (Conn, Server) { + let (s, c) = tokio::io::duplex(1 << 20); + let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace"); + let server = tokio::spawn(async move { + if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await { + let _ = running.waiting().await; + } + }); + (Client.serve(c).await.expect("client init"), server) +} + +async fn shutdown(c: Conn, server: Server) { + c.cancel().await.unwrap(); + server.await.unwrap(); +} + +fn text(r: &CallToolResult) -> String { + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") +} + +/// Call a tool and parse its JSON result, failing the test on a tool error. +async fn tool(c: &Conn, name: &str, args: Value) -> Value { + let mut p = CallToolRequestParams::new(name.to_owned()); + if let Value::Object(m) = args { + p = p.with_arguments(m); + } + let r = c.call_tool(p).await.expect("call_tool transport"); + assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r)); + serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r))) +} + +async fn run(c: &Conn, id: &str, params: Value) -> Value { + tool(c, "command_run", json!({"id": id, "params": params})).await +} + +async fn inspect(c: &Conn) -> Value { + tool(c, "doc_inspect", json!({})).await +} + +async fn pixel(c: &Conn, x: i32, y: i32) -> Vec { + let v = run(c, "document.pixel", json!({"x": x, "y": y})).await; + let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) }; + arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect() +} + +fn top_layer(doc: &Value) -> &Value { + &doc["layers"][0] +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn close(a: &[f64], b: &[f64], tol: f64) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping, +/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip +/// that preserves a group. +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_ten_extended_tasks() { + let dir = tmp("tasks3"); + let (c, server) = connect(&dir).await; + + // 21. Paint a red background and cover it with a solid-colour fill layer. + tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await; + run(&c, "select.all", json!({})).await; + run(&c, "edit.fill", json!({"color": "#ff0000"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21"); + let cyan = pixel(&c, 32, 32).await; + assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}"); + + // 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan. + run(&c, "layer.newAdjustmentLayer.invert", json!({})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22"); + let inverted = pixel(&c, 32, 32).await; + assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan"); + + // 23. Hide a rectangular selection behind the layer's mask. + run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows + run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await; + run(&c, "layer.layerMask.hideSelection", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["hasMask"], true, "task 23"); + let hidden = pixel(&c, 32, 32).await; + let kept = pixel(&c, 4, 4).await; + assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}"); + assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}"); + run(&c, "layer.layerMask.delete", json!({})).await; + + // 24. Group every layer; the pixels are unchanged. + let before = pixel(&c, 32, 32).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}"); + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only"); + + // 25. Flatten the grouped document to one layer. + run(&c, "layer.flattenImage", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25"); + + // 26. Duplicate the layer and delete the copy. + run(&c, "layer.duplicate", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26"); + run(&c, "layer.delete", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26"); + + // 27. A Curves adjustment darkens the red; deleting it restores the original. + run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await; + let dark = pixel(&c, 32, 32).await; + assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red"); + + // 28. Hue/Saturation at -100 desaturates the red to gray. + run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await; + let gray = pixel(&c, 32, 32).await; + assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}"); + run(&c, "layer.delete", json!({})).await; + + // 29. Save a selection to a channel, load it back and paint through it. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await; + run(&c, "select.saveSelection", json!({"name": "Box"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.loadSelection", json!({"channel": "Box"})).await; + run(&c, "edit.fill", json!({"color": "#0000ff"})).await; + let painted = pixel(&c, 32, 32).await; + let outside = pixel(&c, 4, 4).await; + assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}"); + assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}"); + run(&c, "select.deselect", json!({})).await; + + // 30. Save natively with a group, reopen and find the group and its content intact. + run(&c, "layer.new.layer", json!({"name": "Badge"})).await; + run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await; + run(&c, "edit.fill", json!({"color": "#00ff00"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({"name": "Set"})).await; + tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await; + tool(&c, "doc_close", json!({})).await; + tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}"); + assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}"); + let green = pixel(&c, 48, 48).await; + assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}"); + tool(&c, "doc_close", json!({})).await; + + shutdown(c, server).await; + std::fs::remove_dir_all(dir).unwrap(); +} From f5c6e428f269afbbdb5f034079f8e6cd816c940b Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:34 +0800 Subject: [PATCH 0016/1452] docs(scorecard): FILE-216-6 counts the second acceptance file --- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 08e4bd6be8..63d862fcb5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | | FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | missing | [#216](https://github.com/storytold/photocraft/issues/216) | Save paths for layered TIFF, PDF, SVG, ACO/ASE, .shc and Smart Filters are absent. Atomic-save failure injection is covered: format/src/atomic/tests.rs write_failure_halfway_keeps_original and rename_failure_keeps_original (see BUG-203-6) | -| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks | +| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs and agent_tasks_extended.rs: 20 MCP tasks | ## Tools diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 48314a2c41..8305f445c0 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -192,4 +192,4 @@ id = "FILE-216-6" target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands" status = "partial" issue = 216 -note = "automation/tests/agent_tasks.rs: 10 MCP tasks" +note = "automation/tests/agent_tasks.rs and agent_tasks_extended.rs: 20 MCP tasks" From b5e3e80a3b76036bdb9639a982529b0bcf57a9ac Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 17:37:41 +0200 Subject: [PATCH 0017/1452] History: set the History Brush source from the panel (history.setSource) (#2966) The History panel gets Photoshop's source column at the left of every state: the History Brush icon marks the source, and a click there makes that state the source through the new `history.setSource {state}` command (not a step, and the row is not stepped to). `paint.historyBrush`, the interactive History Brush and Fill > History use the source when no `state` is passed; by default it is the oldest state held, as before. The source is kept by state, not index: photocraft-ops gives every history state a stable id, so the source stays on its state while older states fall off. As in Photoshop (checked by script), the source state itself does not fall off the history-state limit or the memory budget, and once it is deleted or discarded by a new step after stepping back there is no source: the History Brush then refuses to paint without an explicit `state` until a new source is set. Rows count the greyed redo states too, so the common "filter, step back, paint the filtered state in" workflow works. `document.inspect` / `ui_inspect` report `historySource` {state, label} (null when its state was deleted). Snapshots are not implemented yet: `snapshot` returns an "unsupported" error. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 1 + crates/engine/src/fill_cmds.rs | 4 +- crates/engine/src/history_source_cmds.rs | 206 +++++++++++++++++++++++ crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/retouch_cmds.rs | 9 +- crates/ops/src/lib.rs | 155 ++++++++++++++++- crates/ui-egui/src/panels.rs | 50 ++++++ crates/ui-egui/src/retouch_ui.rs | 2 +- 9 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/history_source_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01f669f148..9b781aeec1 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1174,6 +1174,7 @@ fn build() -> Vec { v.extend(crate::analysis_cmds::specs()); v.extend(crate::notes_cmds::specs()); v.extend(crate::history_cmds::specs()); + v.extend(crate::history_source_cmds::specs()); v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); v.extend(crate::document_preset_cmds::specs()); diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 78a28b0480..b1e1e8ad30 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -121,8 +121,8 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let source_doc = match explicit { Some(i) if i == past => st.doc.clone(), Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?, - // The History Brush's default source: the oldest state still held. - None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), + // The History Brush's source (History panel), by default the oldest state held. + None => crate::history_source_cmds::source_doc(st)?, }; let mut source_doc = (*source_doc).clone(); let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p) diff --git a/crates/engine/src/history_source_cmds.rs b/crates/engine/src/history_source_cmds.rs new file mode 100644 index 0000000000..6457dd2569 --- /dev/null +++ b/crates/engine/src/history_source_cmds.rs @@ -0,0 +1,206 @@ +//! The History Brush's source (#2966): the History panel's source column, at the left of every +//! state, sets which state the History Brush (and Fill › History) restores from. Exactly one +//! source per document; by default the oldest state held (Photoshop's default is the first +//! snapshot, the document as opened, which that state is until it falls off the history). +//! +//! The source is kept by state, not by index ([`photocraft_ops::History::set_brush_source`]), so +//! it stays on its state while older states fall off, and that state is kept when it would fall +//! off the history itself. Once it is deleted, or discarded by a new step after stepping back, +//! there is no source until one is set, and the History Brush refuses to paint without an +//! explicit `state`, as Photoshop does. (Photoshop keeps a source discarded by a new step in the +//! list; that needs states outside the linear history, so here it is gone.) + +use std::sync::Arc; + +use photocraft_doc::Document; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const CMD: &str = "history.setSource"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// The document the History Brush paints from when no `state` is passed: the source state set in +/// the History panel, or the default (the oldest state held). +pub(crate) fn source_doc(st: &DocState) -> Result> { + st.history + .brush_source() + .and_then(|i| st.history.row_doc(i, &st.doc)) + .ok_or_else(|| EngineError::Other("the History Brush source state was deleted: set a new source in the History panel".into())) +} + +/// `history.setSource {state}`: make a History panel row the History Brush's source. Rows are +/// numbered as the panel lists them: the states (0 = oldest, then the current one), then the +/// greyed states a step back left redoable. +fn set_source(s: &mut Session, p: &Value) -> Result { + if p.get("snapshot").is_some_and(|v| !v.is_null()) { + return Err(EngineError::Other("History snapshots are not supported yet: set the source to a `state`".into())); + } + let v = match p.get("state") { + None | Some(Value::Null) => return Err(bad("pass the `state` to paint from (a History panel row, 0 = oldest)")), + Some(v) => v, + }; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let rows = st.history.entries().len() + st.history.redo_labels().count(); + let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(format!("`state` must be a History panel row (0..{rows}), not {v}")))?; + if !st.history.set_brush_source(i) { + return Err(bad(format!("no history state {i} (0..{rows})"))); + } + Ok(source_json(st)) +} + +/// The source as `doc_inspect` reports it: its History panel row and label (`null` when its state +/// was deleted). +pub fn source_json(st: &DocState) -> Value { + let Some(i) = st.history.brush_source() else { return Value::Null }; + let label = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_string)).nth(i); + json!({ "state": i, "label": label }) +} + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Set History Brush Source", + menu: &[], + shortcut: None, + params: r##"{"state":index} (History panel row: 0 = oldest state, then the current one, then the greyed redo states; `snapshot` is not supported yet) → {state,label}: paint.historyBrush and Fill › History use this state when no `state` is passed"##, + enabled: has_doc, + run: set_source, + journal: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A white 8×8 document filled red, then green: rows Open, Fill (red), Fill (green). + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 16, "background": "white"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s + } + + fn rgb(s: &Session) -> [f32; 3] { + let st = s.active().unwrap(); + let px = st.doc.layers[0].surface().unwrap().rgba(4, 4); + [px[0], px[1], px[2]] + } + + fn brush(s: &mut Session, extra: Value) { + let mut p = json!({"points": [[4, 4]], "size": 40, "hardness": 100}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + s.execute("paint.historyBrush", p).unwrap(); + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01) + } + + #[test] + fn default_source_is_the_oldest_state() { + let mut s = session(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], json!({"state": 0, "label": "Open"})); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + } + + #[test] + fn the_history_brush_and_fill_paint_from_the_set_source() { + let mut s = session(); + let r = s.execute(CMD, json!({"state": 1})).unwrap(); + assert_eq!(r, json!({"state": 1, "label": "Fill"})); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"]["state"], 1); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "the red state: {:?}", rgb(&s)); + // An explicit `state` still wins. + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "Fill › History uses the source too: {:?}", rgb(&s)); + // Setting the source is not a step. + assert_eq!(s.active().unwrap().history.entries().len(), 6); + } + + /// The common Photoshop workflow: apply, step back, set the source on the greyed state, paint. + #[test] + fn a_redo_state_can_be_the_source_until_a_new_step_discards_it() { + let mut s = session(); + assert!(s.undo()); + s.execute(CMD, json!({"state": 2})).unwrap(); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the greyed green state: {:?}", rgb(&s)); + // Painting was a new step, which discarded the green state: no source until one is set. + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + let before = rgb(&s); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + assert!(s.execute("edit.fill", json!({"contents": "history"})).is_err()); + assert!(close(rgb(&s), before), "nothing painted"); + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "an explicit state still paints: {:?}", rgb(&s)); + s.execute(CMD, json!({"state": 0})).unwrap(); + brush(&mut s, json!({})); + } + + #[test] + fn the_source_follows_its_state_as_older_states_fall_off() { + let mut s = session(); + s.active_mut().unwrap().history.max_states = 2; + s.execute(CMD, json!({"state": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + // Open fell off: the red state is row 0 now and still the source. + let st = s.active().unwrap(); + assert_eq!((st.history.entries().len(), st.history.brush_source()), (3, Some(0))); + // It would fall off next: it is kept, as in Photoshop, and paints red. + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + brush(&mut s, json!({})); + let st = s.active().unwrap(); + assert_eq!((st.history.entries()[0].as_str(), st.history.brush_source()), ("Fill", Some(0))); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + } + + #[test] + fn deleting_the_source_state_leaves_no_source() { + let mut s = session(); + s.execute(CMD, json!({"state": 2})).unwrap(); + s.execute("history.deleteState", json!({"state": 2})).unwrap(); + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + s.execute(CMD, json!({"state": 1})).unwrap(); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + } + + #[test] + fn bad_params_and_no_document_are_errors() { + assert!(matches!(Session::new().execute(CMD, json!({"state": 0})), Err(EngineError::NoDocument | EngineError::Disabled(..)))); + let mut s = session(); + for p in [ + json!({}), + json!({"state": null}), + json!({"state": -1}), + json!({"state": 3}), + json!({"state": 1e300}), + json!({"state": 1.5}), + json!({"state": "1"}), + json!({"state": [1]}), + json!({"snapshot": 0}), + json!({"snapshot": "Open", "state": 1}), + ] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + } + assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed"); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1d1c7e0c72..576b19d080 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -39,6 +39,7 @@ pub fn document(d: &DocState) -> Value { "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected)).collect::>(), "history": d.history.entries(), + "historySource": crate::history_source_cmds::source_json(d), "canUndo": d.history.can_undo(), "canRedo": d.history.can_redo(), "revision": d.revision, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a4354a2b6..44181eef0e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,7 @@ pub mod group_view_cmds; pub mod guide_layout; pub mod hidden_target; pub mod history_cmds; +pub mod history_source_cmds; pub mod image_cmds; pub mod inspect; pub mod jobs; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 0da474c22b..e92306589b 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -501,14 +501,15 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { } /// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in history -/// state `state` (default: the oldest state still held, the "Open" snapshot unless trimmed). +/// state `state` (default: the source set in the History panel, `history.setSource`, else the +/// oldest state still held, the "Open" snapshot unless trimmed). fn history_source(s: &Session, id: Option, p: &Value, cmd: &str) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let explicit = p.get("state").and_then(Value::as_u64).map(|v| v as usize); let source_doc = match explicit { Some(i) if i == st.history.past_len() => st.doc.clone(), Some(i) => st.history.state(i).ok_or_else(|| bad(cmd, format!("no history state {i} (0..={})", st.history.past_len())))?, - None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), + None => crate::history_source_cmds::source_doc(st)?, }; let mut source_doc = (*source_doc).clone(); crate::channel_cmds::target_surface(&mut source_doc, id, p) @@ -1242,7 +1243,9 @@ pub fn specs() -> Vec { label: "History Brush", menu: &[], shortcut: None, - params: brush_params!(r#","state":index (into history.entries; default 0 = the oldest held state, normally the Open snapshot)"#), + params: brush_params!( + r#","state":index (into history.entries; default the History panel's source, history.setSource, else 0 = the oldest held state)"# + ), enabled: has_pixel_layer, run: history_brush, journal: true, diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 0f29cf8e98..743134f961 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -33,6 +33,8 @@ pub struct HistoryState { pub doc: Arc, /// The layers targeted when this state was created. pub layers: LayerTarget, + /// Stable while the state is held: indices shift when older states fall off, ids don't. + pub id: u64, } #[derive(Clone, Debug)] @@ -50,6 +52,12 @@ pub struct History { current_label: String, /// The layers targeted when the current document was created (opened or edited). current_layers: LayerTarget, + /// [`HistoryState::id`] of the current state. + current_id: u64, + /// The id the next state gets: never reused, so a discarded state's id names no other. + next_id: u64, + /// The History Brush's source state, set in the History panel (`None`: the default). + brush_source: Option, } impl Default for History { @@ -68,6 +76,9 @@ impl History { max_bytes: 0, current_label: "Open".into(), current_layers: LayerTarget::default(), + current_id: 0, + next_id: 1, + brush_source: None, } } @@ -82,10 +93,14 @@ impl History { } let prev_label = std::mem::replace(&mut self.current_label, label.into()); let prev_layers = std::mem::replace(&mut self.current_layers, layers); - self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers }); + let prev_id = std::mem::replace(&mut self.current_id, self.next_id); + self.next_id = self.next_id.wrapping_add(1); + self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers, id: prev_id }); self.clear_redo(); while self.undo.len() > self.max_states { - if let Some(state) = self.undo.pop_front() + // The History Brush's source doesn't fall off (as in Photoshop): the next oldest does. + let pinned = self.max_states > 1 && self.undo.front().is_some_and(|s| Some(s.id) == self.brush_source); + if let Some(state) = self.undo.remove(usize::from(pinned)) && let Some(tiles) = &mut self.tiles { tiles.remove(&state.doc); @@ -126,7 +141,8 @@ impl History { } let label = std::mem::replace(&mut self.current_label, prev.label); let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone()); - self.redo.push(HistoryState { label, doc: current, layers }); + let id = std::mem::replace(&mut self.current_id, prev.id); + self.redo.push(HistoryState { label, doc: current, layers, id }); Some((prev.doc, prev.layers)) } @@ -139,7 +155,8 @@ impl History { } let label = std::mem::replace(&mut self.current_label, next.label); let layers = std::mem::replace(&mut self.current_layers, next.layers.clone()); - self.undo.push_back(HistoryState { label, doc: current, layers }); + let id = std::mem::replace(&mut self.current_id, next.id); + self.undo.push_back(HistoryState { label, doc: current, layers, id }); Some((next.doc, next.layers)) } @@ -154,6 +171,59 @@ impl History { self.undo.get(i).map(|s| s.doc.clone()) } + /// Id of History panel row `i`: the [`History::entries`] (0 = oldest, then the current state), + /// then the redo states, the next redo first ([`History::redo_labels`]). `None` past the end. + pub fn row_id(&self, i: usize) -> Option { + let past = self.undo.len(); + match i.checked_sub(past) { + None => self.undo.get(i).map(|s| s.id), + Some(0) => Some(self.current_id), + Some(k) => self.redo.iter().rev().nth(k - 1).map(|s| s.id), + } + } + + /// The History panel row of the state with id `id`, if it is still held. + pub fn row_of(&self, id: u64) -> Option { + let past = self.undo.len(); + if id == self.current_id { + return Some(past); + } + if let Some(i) = self.undo.iter().position(|s| s.id == id) { + return Some(i); + } + self.redo.iter().rev().position(|s| s.id == id).map(|k| past + 1 + k) + } + + /// Document of History panel row `i` (see [`History::row_id`]); `current` is the current + /// document, which the history does not hold. + pub fn row_doc(&self, i: usize, current: &Arc) -> Option> { + let past = self.undo.len(); + match i.checked_sub(past) { + None => self.state(i), + Some(0) => Some(current.clone()), + Some(k) => self.redo.iter().rev().nth(k - 1).map(|s| s.doc.clone()), + } + } + + /// Make row `i` the History Brush's source (the History panel's source column). It stays on + /// that state while older states fall off, and the state itself is kept when it would fall off + /// the history (by count or memory); `false` if there is no such row. + pub fn set_brush_source(&mut self, i: usize) -> bool { + let Some(id) = self.row_id(i) else { return false }; + self.brush_source = Some(id); + true + } + + /// The History Brush's source row: the oldest state held by default, else the state set with + /// [`History::set_brush_source`]. `None` once that state is gone (deleted, or discarded by a + /// new step after stepping back): as in Photoshop, there is then no source until one is set. + pub fn brush_source(&self) -> Option { + match self.brush_source { + None => Some(0), + Some(id) => self.row_of(id), + } + } + /// Number of past states (entries before the current one). pub fn past_len(&self) -> usize { self.undo.len() @@ -241,10 +311,20 @@ impl History { keep += 1; } let drop = self.undo.len() - keep; + let mut pinned = None; for state in self.undo.drain(..drop) { - tiles.remove(&state.doc); + // The History Brush's source is kept, as when states fall off the count limit. + if Some(state.id) == self.brush_source { + pinned = Some(state); + } else { + tiles.remove(&state.doc); + } } - drop + let kept = usize::from(pinned.is_some()); + if let Some(state) = pinned { + self.undo.push_front(state); + } + drop - kept } } @@ -337,6 +417,69 @@ mod tests { assert_eq!(h.redo_labels().collect::>(), ["A", "B"], "next redo first"); } + /// The History Brush source is a state, not an index: it stays on its state through undo, + /// redo and older states falling off, is kept when it would fall off itself, and is gone + /// (no source) once a new step discards it. + #[test] + fn brush_source_follows_its_state() { + let mut h = History::new(3); + let mut cur = Arc::new(base()); + assert_eq!(h.brush_source(), Some(0), "default: the oldest state"); + assert!(!h.set_brush_source(1), "no such row yet"); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(h.set_brush_source(1)); + assert_eq!(h.row_doc(1, &cur).unwrap().name, "a"); + // Undo / redo: the rows stay put, and A's state is still row 1. + cur = h.undo(cur).unwrap().0; + assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); + assert_eq!(h.row_doc(2, &cur).unwrap().name, "b", "a redo row"); + cur = h.redo(cur).unwrap().0; + // Open falls off the 3-state limit: A's state moves up to row 0 and stays the source. + edit(&mut h, &mut cur, "C", |d| d.name = "c".into()); + edit(&mut h, &mut cur, "D", |d| d.name = "d".into()); + assert_eq!(h.entries(), ["A", "B", "C", "D"]); + assert_eq!(h.brush_source(), Some(0)); + // A would fall off next: it is kept, and the states after it fall off instead. + edit(&mut h, &mut cur, "E", |d| d.name = "e".into()); + edit(&mut h, &mut cur, "F", |d| d.name = "f".into()); + assert_eq!(h.entries(), ["A", "D", "E", "F"]); + assert_eq!((h.brush_source(), h.row_doc(0, &cur).unwrap().name.as_str()), (Some(0), "a")); + // A redo state as the source, discarded by a new step: no source until one is set. + cur = h.undo(cur).unwrap().0; + assert!(h.set_brush_source(3), "the greyed F row"); + assert_eq!((h.brush_source(), h.row_doc(3, &cur).unwrap().name.as_str()), (Some(3), "f")); + assert!(h.row_doc(4, &cur).is_none() && h.row_id(4).is_none()); + edit(&mut h, &mut cur, "G", |d| d.name = "g".into()); + assert_eq!(h.brush_source(), None); + // The current state as the source keeps it once it becomes a past state; once it is no + // longer the source, it falls off as usual. + assert!(h.set_brush_source(3)); + edit(&mut h, &mut cur, "H", |d| d.name = "h".into()); + assert_eq!((h.brush_source(), h.row_doc(2, &cur).unwrap().name.as_str()), (Some(2), "g")); + assert!(h.set_brush_source(1)); + edit(&mut h, &mut cur, "I", |d| d.name = "i".into()); + assert_eq!(h.entries(), ["E", "G", "H", "I"]); + } + + #[test] + fn byte_budget_keeps_the_brush_source() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert!(h.set_brush_source(1)); + h.max_bytes = 1; + let dropped = h.trim(&cur); + assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step"); + assert_eq!(dropped, 3); + assert_eq!(h.brush_source(), Some(0)); + } + #[test] fn new_edit_clears_redo() { let mut h = History::default(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 49318f84c8..4ea94ee212 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2799,6 +2799,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + let source = st.history.brush_source(); // Snapshot row (Photoshop shows the document's opening state with a thumbnail). { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::hover()); @@ -2814,6 +2815,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); } let mut target: Option = None; + let mut set_source: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; @@ -2843,11 +2845,20 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); + if history_source_column(ui, rect, i, source == Some(i)) { + set_source = Some(i); + } if resp.clicked() { target = Some(i as isize - current as isize); } } }); + if let Some(i) = set_source + && let Err(e) = app.run("history.setSource", json!({"state": i})) + { + app.ui.status = e; + app.ui.status_error = true; + } let mut footer_cmd: Option<&str> = None; if t.pro { let can_delete = app.session.is_enabled("history.deleteState"); @@ -2881,6 +2892,21 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The History panel's source column at the left of a state row: the History Brush icon on the +/// source state; a click makes the row the source (`history.setSource`). It sits above the row, so +/// the click doesn't also step to the state. +fn history_source_column(ui: &mut egui::Ui, row: Rect, i: usize, is_source: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); + let resp = ui.interact(cell, ui.id().with(("history-source", i)), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); + if is_source { + icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.icon); + } else if resp.hovered() { + icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.text_faint); + } + resp.clicked() +} + // ----------------------------------------------------------------------------- properties /// Floating Properties card anchored to the canvas' top-right corner. @@ -4082,6 +4108,30 @@ mod history_transform_tests { assert_eq!(st.history.entries().len(), steps - 1, "trash deletes the current state"); assert!(!st.history.can_redo(), "and it can't be redone"); } + + /// The source column at the left of each state (#2966): a click makes that state the History + /// Brush's source through `history.setSource`, without stepping to it. + #[test] + fn history_source_column_sets_the_history_brush_source() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + // The column cells are the only 24 pt squares, one per state, top to bottom. + let mut cells: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 24.0)).map(|w| w.rect).collect()); + cells.sort_by(|a, b| a.center().y.total_cmp(&b.center().y)); + cells.dedup(); + assert_eq!(cells.len(), 3, "Open, Fill, Fill: {cells:?}"); + assert_eq!(h.state().session.active().unwrap().history.brush_source(), Some(0), "the default source"); + click(&mut h, cells[1].center()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.brush_source(), Some(1), "the clicked state is the source"); + assert_eq!((st.history.entries().len(), st.history.can_redo()), (3, false), "and the document did not step back"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 8e78a5dd98..e018584e24 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -459,7 +459,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cr_tolerance, 0.0..=100.0, "%", 62.0); crate::widgets::checkbox(ui, &mut o.cr_anti_alias, tl!("Anti-alias")); } - Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), + Tool::HistoryBrush => opt(ui, tl!("Paints from the History panel's source state")), Tool::QuickSelection => { crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.enhance_edge, tl!("Enhance Edge")); From 2b738e0c7c8eca49b2fed345d279c66ff46a3736 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 17:42:31 +0200 Subject: [PATCH 0018/1452] History: panel menu, Step Forward/Backward and Clear History (#2966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The History panel's menu (☰) now has Photoshop's items: Step Forward, Step Backward, New Snapshot… (greyed, snapshots aren't built yet), Delete, Clear History, New Document, History Options… (greyed, built in parallel) and Close, above the dock's own group items. Each runs an engine command. - history.stepForward / history.stepBackward: one state along the list (Err when there is none). - history.clear: removes every state but the current one without changing the image. It is undoable: History keeps the cleared states until the next step, and Edit > Undo puts them back. Fade is dropped and can no longer underflow on an empty history. - history.deleteState is enabled only when there is an earlier state. - Translations for the new labels in all complete-menu languages. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 1 + crates/engine/src/edit_menu_cmds.rs | 2 +- crates/engine/src/history_cmds.rs | 2 +- crates/engine/src/history_menu_cmds.rs | 210 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/ops/src/lib.rs | 95 ++++++++++- crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/history_menu_ui.rs | 161 +++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panel_docking.rs | 4 + 26 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/history_menu_cmds.rs create mode 100644 crates/ui-egui/src/history_menu_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 3d4b73f4d2..41da781608 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1179,6 +1179,7 @@ fn build() -> Vec { v.extend(crate::analysis_cmds::specs()); v.extend(crate::notes_cmds::specs()); v.extend(crate::history_cmds::specs()); + v.extend(crate::history_menu_cmds::specs()); v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); v.extend(crate::document_preset_cmds::specs()); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 93ecd479c4..7af2b192a5 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -201,7 +201,7 @@ fn fade(s: &mut Session, p: &Value) -> Result { let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; let mode = blend_from_str(str_or(p, "mode", "normal")).ok_or_else(|| bad(cmd, "unknown blend mode"))?; let st = s.active().ok_or(EngineError::NoDocument)?; - let prev = st.history.state(st.history.past_len() - 1).ok_or_else(|| bad(cmd, "no previous state"))?; + let prev = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).ok_or_else(|| bad(cmd, "no previous state"))?; let before = surface_of(&prev, src.layer, src.mask).cloned().ok_or_else(|| EngineError::Other("the layer did not exist before this step".into()))?; let now = surface_of(&st.doc, src.layer, src.mask).ok_or(EngineError::NoLayer(src.layer))?; if now.format() != before.format() { diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index d9ca98f962..d3b2ab3f76 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -73,7 +73,7 @@ fn new_document(s: &mut Session, p: &Value) -> Result { } fn can_delete(s: &Session) -> std::result::Result<(), String> { - s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) + s.active().filter(|d| d.history.past_len() > 0).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) } fn has_doc(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/history_menu_cmds.rs b/crates/engine/src/history_menu_cmds.rs new file mode 100644 index 0000000000..7154addbfb --- /dev/null +++ b/crates/engine/src/history_menu_cmds.rs @@ -0,0 +1,210 @@ +//! The History panel menu (☰, #2966): Step Forward, Step Backward and Clear History. Delete and +//! New Document are [`crate::history_cmds`]; New Snapshot and History Options live elsewhere. +//! +//! Step Forward / Step Backward move one state along the History list, like Edit › Redo / Undo +//! (which step through the list too), but report a step that isn't there as an error. Clear +//! History takes every state but the current one off the list without changing the image; it is +//! undoable: Edit › Undo puts the states back until the next step is recorded. + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// `{states, current, label}`: the list length, the current state's index (0 = oldest) and its +/// name, after a History panel menu command. +fn list(s: &Session) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let entries = st.history.entries(); + Ok(json!({ "states": entries.len() + st.history.redo_labels().count(), "current": st.history.past_len(), "label": entries.last() })) +} + +fn step(s: &mut Session, forward: bool) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let possible = if forward { st.history.can_redo() } else { st.history.can_undo() }; + if !possible { + return Err(EngineError::Other(if forward { "no later state to step forward to" } else { "no earlier state to step back to" }.into())); + } + let moved = if forward { s.redo() } else { s.undo() }; + if !moved { + return Err(EngineError::Other("the history can't step right now".into())); + } + list(s) +} + +/// History panel › Clear History: only the current state stays in the list; the image doesn't +/// change. Edit › Undo brings the states back. +fn clear(s: &mut Session, _p: &Value) -> Result { + if s.active_job().is_some() { + return Err(EngineError::Other("the history can't change while a job runs".into())); + } + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + st.coalesce = None; + if !st.history.clear_states() { + return Err(EngineError::Other("the current state is the only state".into())); + } + // Edit › Fade blends against the previous state, which is no longer in the list. + s.edit_state.fade = None; + list(s) +} + +fn can_step_forward(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.can_redo() { Ok(()) } else { Err("no later state".into()) } +} + +fn can_step_backward(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.can_undo() { Ok(()) } else { Err("no earlier state".into()) } +} + +fn can_clear(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.past_len() > 0 || st.history.can_redo() { Ok(()) } else { Err("the current state is the only state".into()) } +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "history.stepForward", + label: "Step Forward", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: makes the next History state current (Err when there is none)"##, + enabled: can_step_forward, + run: |s, _| step(s, true), + journal: true, + }, + CommandSpec { + id: "history.stepBackward", + label: "Step Backward", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: makes the previous History state current (Err when there is none)"##, + enabled: can_step_backward, + run: |s, _| step(s, false), + journal: true, + }, + CommandSpec { + id: "history.clear", + label: "Clear History", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: removes every History state but the current one without changing the image; Edit › Undo restores them"##, + enabled: can_clear, + run: clear, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// A document with three steps after Open: entries Open, New Layer, Fill, Fill. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s + } + + fn entries(s: &Session) -> Vec { + s.active().unwrap().history.entries() + } + + #[test] + fn step_backward_and_forward_move_one_state() { + let mut s = session(); + let all = entries(&s); + let last = s.active().unwrap().doc.clone(); + assert!(!s.is_enabled("history.stepForward"), "no later state yet"); + assert!(s.is_enabled("history.stepBackward")); + let r = s.execute("history.stepBackward", json!({})).unwrap(); + assert_eq!((r["states"].as_u64(), r["current"].as_u64()), (Some(all.len() as u64), Some(all.len() as u64 - 2))); + assert_eq!(r["label"], json!(all[all.len() - 2])); + assert_eq!(entries(&s), all[..all.len() - 1].to_vec()); + assert!(s.is_enabled("history.stepForward")); + s.execute("history.stepBackward", json!({})).unwrap(); + s.execute("history.stepForward", json!({})).unwrap(); + let r = s.execute("history.stepForward", json!({})).unwrap(); + assert_eq!(r["current"].as_u64(), Some(all.len() as u64 - 1)); + assert_eq!(entries(&s), all); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &last), "back at the last state"); + assert!(s.execute("history.stepForward", json!({})).is_err(), "nothing to step forward to"); + } + + #[test] + fn step_backward_stops_at_the_oldest_state() { + let mut s = session(); + while s.is_enabled("history.stepBackward") { + s.execute("history.stepBackward", json!({})).unwrap(); + } + assert_eq!(entries(&s), ["Open"]); + assert!(s.execute("history.stepBackward", json!({})).is_err()); + assert_eq!(entries(&s), ["Open"], "a refused step changes nothing"); + } + + #[test] + fn clear_history_keeps_the_image_and_leaves_one_state() { + let mut s = session(); + s.execute("history.stepBackward", json!({})).unwrap(); + let all = entries(&s); + let doc = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let r = s.execute("history.clear", json!({})).unwrap(); + assert_eq!((r["states"].as_u64(), r["current"].as_u64()), (Some(1), Some(0))); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), [all.last().unwrap().clone()], "the current state is the only one"); + assert!(!st.history.can_redo(), "redo states are cleared too"); + assert!(Arc::ptr_eq(&st.doc, &doc) && st.revision == revision, "the image doesn't change"); + assert!(!s.is_enabled("history.clear"), "nothing left to clear"); + assert!(s.execute("history.clear", json!({})).is_err()); + assert!(!s.is_enabled("history.stepForward") && !s.is_enabled("history.deleteState")); + assert!(!s.is_enabled("edit.fade")); + } + + #[test] + fn undo_after_clear_history_brings_the_states_back() { + let mut s = session(); + s.execute("history.stepBackward", json!({})).unwrap(); + let all = entries(&s); + let redo: Vec = s.active().unwrap().history.redo_labels().map(str::to_string).collect(); + let doc = s.active().unwrap().doc.clone(); + s.execute("history.clear", json!({})).unwrap(); + assert!(s.is_enabled("edit.undo") && s.is_enabled("history.stepBackward"), "Clear History is undoable"); + s.execute("edit.undo", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), all); + assert_eq!(st.history.redo_labels().map(str::to_string).collect::>(), redo); + assert!(Arc::ptr_eq(&st.doc, &doc), "undoing it doesn't change the image either"); + // A step after Clear History forgets the cleared states. + s.execute("history.clear", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + assert_eq!(entries(&s).len(), 2); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.is_enabled("edit.undo"), "the cleared states are gone"); + } + + #[test] + fn history_menu_commands_refuse_without_a_document_and_ignore_odd_params() { + let mut s = Session::new(); + for id in ["history.stepForward", "history.stepBackward", "history.clear"] { + assert!(!s.is_enabled(id), "{id}"); + assert!(s.execute(id, json!({})).is_err(), "{id} without a document"); + } + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for id in ["history.stepForward", "history.stepBackward", "history.clear"] { + assert!(!s.is_enabled(id), "{id}: only the opening state"); + for p in [json!({}), json!(null), json!([1]), json!({"state": -1}), json!("x")] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + assert_eq!(entries(&s), ["Open"]); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1fffd0bdc1..21916ba24c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,7 @@ pub mod group_view_cmds; pub mod guide_layout; pub mod hidden_target; pub mod history_cmds; +pub mod history_menu_cmds; pub mod image_cmds; pub mod inspect; pub mod jobs; diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 0f29cf8e98..b0ca32b909 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -50,8 +50,21 @@ pub struct History { current_label: String, /// The layers targeted when the current document was created (opened or edited). current_layers: LayerTarget, + /// The states History panel › Clear History took off the list, kept so the next undo puts + /// them back (Clear History is itself undoable). Any new step drops them. + cleared: Option>, } +/// The undo and redo states [`History::clear_states`] removed. +#[derive(Clone, Debug)] +struct Cleared { + undo: VecDeque, + redo: Vec, +} + +/// The undo label of the step [`History::clear_states`] adds (Edit › Undo Clear History). +pub const CLEAR_HISTORY_LABEL: &str = "Clear History"; + impl Default for History { fn default() -> Self { Self::new(50) @@ -68,6 +81,7 @@ impl History { max_bytes: 0, current_label: "Open".into(), current_layers: LayerTarget::default(), + cleared: None, } } @@ -80,6 +94,7 @@ impl History { if let Some(tiles) = &mut self.tiles { tiles.add(&before); } + self.cleared = None; let prev_label = std::mem::replace(&mut self.current_label, label.into()); let prev_layers = std::mem::replace(&mut self.current_layers, layers); self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers }); @@ -94,12 +109,15 @@ impl History { } pub fn can_undo(&self) -> bool { - !self.undo.is_empty() + !self.undo.is_empty() || self.cleared.is_some() } pub fn can_redo(&self) -> bool { !self.redo.is_empty() } pub fn undo_label(&self) -> Option<&str> { + if self.cleared.is_some() { + return Some(CLEAR_HISTORY_LABEL); + } self.can_undo().then_some(self.current_label.as_str()) } pub fn redo_label(&self) -> Option<&str> { @@ -119,6 +137,14 @@ impl History { /// Undo: `current` becomes redoable; returns the document to make current and the layers it /// targeted when it was created. pub fn undo(&mut self, current: Arc) -> Option<(Arc, LayerTarget)> { + // Undo Clear History: the cleared states come back; the document doesn't change. + if let Some(cleared) = self.cleared.take() { + let Cleared { undo, redo } = *cleared; + self.undo = undo; + self.redo = redo; + self.tiles = None; + return Some((current, self.current_layers.clone())); + } let prev = self.undo.pop_back()?; if let Some(tiles) = &mut self.tiles { tiles.remove(&prev.doc); @@ -168,6 +194,9 @@ impl History { /// last step can no longer be undone and its pixels are released. pub fn purge_last(&mut self) -> bool { self.clear_redo(); + if self.cleared.take().is_some() { + return true; + } let Some(state) = self.undo.pop_back() else { return false }; if let Some(tiles) = &mut self.tiles { tiles.remove(&state.doc); @@ -189,6 +218,27 @@ impl History { self.undo.clear(); self.redo.clear(); self.tiles = None; + self.cleared = None; + } + + /// History panel › Clear History: take every state but the current one off the list without + /// changing the document. Unlike [`Self::clear`] (Edit › Purge › Histories) it is undoable: + /// the next [`Self::undo`] puts the states back, and the next recorded step forgets them. + /// Returns false (and changes nothing) when the current state is the only one. + pub fn clear_states(&mut self) -> bool { + if self.undo.is_empty() && self.redo.is_empty() { + return false; + } + let undo = std::mem::take(&mut self.undo); + let redo = std::mem::take(&mut self.redo); + self.cleared = Some(Box::new(Cleared { undo, redo })); + self.tiles = None; + true + } + + /// Whether the last change to the list was Clear History (so undo restores the states). + pub fn is_cleared(&self) -> bool { + self.cleared.is_some() } /// Approximate unique pixel bytes held by history (tiles not shared with `current`). @@ -530,4 +580,47 @@ mod tests { assert_eq!(h.trim(¤t), 1, "current alone exceeds the budget; only the mandatory undo survives"); assert_counter(&h, ¤t); } + + #[test] + fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { + let mut h = History::new(10); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + edit(&mut h, &mut cur, "C", |d| d.name = "c".into()); + let (d, _) = h.undo(cur.clone()).unwrap(); + cur = d; + let entries = h.entries(); + assert_eq!(entries, ["Open", "A", "B"]); + assert!(h.clear_states()); + assert_eq!(h.entries(), ["B"], "only the current state is left"); + assert!(!h.can_redo() && h.can_undo() && h.is_cleared()); + assert_eq!(h.undo_label(), Some(CLEAR_HISTORY_LABEL)); + assert!(!h.clear_states(), "nothing left to clear"); + assert!(h.is_cleared(), "and the cleared states are still held"); + let (d, _) = h.undo(cur.clone()).unwrap(); + assert!(Arc::ptr_eq(&d, &cur), "undoing Clear History doesn't change the document"); + assert_eq!(h.entries(), entries); + assert_eq!(h.redo_labels().collect::>(), ["C"], "the redo state is back too"); + assert!(!h.is_cleared()); + } + + #[test] + fn a_new_step_or_purge_forgets_the_cleared_states() { + let mut h = History::new(10); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + assert!(h.clear_states()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert_eq!(h.entries(), ["A", "B"]); + assert!(!h.is_cleared()); + assert!(h.undo(cur.clone()).is_some()); + assert!(!h.can_undo(), "the cleared states are gone"); + let mut h = History::new(10); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + assert!(h.clear_states()); + assert!(h.purge_last()); + assert!(!h.can_undo() && !h.is_cleared()); + assert!(!h.clear_states(), "a single state can't be cleared"); + } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 944842de14..2d7511b82b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -554,6 +554,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"History") { + crate::history_menu_ui::panel_menu(app, ui); + ui.separator(); + } if let Some(label) = tabs.get(sel) { crate::panel_docking::legacy_menu(app, ui, label); } diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs new file mode 100644 index 0000000000..6a7912e346 --- /dev/null +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -0,0 +1,161 @@ +//! The History panel's own items at the top of its panel menu (☰, #2966), in Photoshop's order: +//! Step Forward, Step Backward, New Snapshot…, Delete, Clear History, New Document, History +//! Options…, Close. Every item runs an engine command (or the dock's `window.panel.close`); the +//! dock appends its group items (Close Tab Group …) after them. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; + +/// The menu's items: label and command (None = shown greyed; not built yet). +pub const ITEMS: &[(&str, Option<&str>)] = &[ + ("Step Forward", Some("history.stepForward")), + ("Step Backward", Some("history.stepBackward")), + ("---", None), + // Snapshots are not implemented yet. + ("New Snapshot…", None), + ("Delete", Some("history.deleteState")), + ("Clear History", Some("history.clear")), + ("New Document", Some("history.newDocument")), + ("---", None), + // The History Options dialog is not built yet. + ("History Options…", None), + ("---", None), + ("Close", Some("window.panel.close")), +]; + +fn params(cmd: &str) -> Value { + if cmd == "window.panel.close" { json!({"panel": "history"}) } else { json!({}) } +} + +fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { + if cmd == "window.panel.close" { + return !app.session.prefs().workspace_locked; + } + // An open Free Transform owns the history (see `panels::history`). + app.ui.transform.is_none() && app.session.is_enabled(cmd) +} + +/// Draw the History items of the panel menu and run the one clicked. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let mut clicked: Option<&str> = None; + for &(label, cmd) in ITEMS { + if label == "---" { + ui.separator(); + continue; + } + let on = cmd.is_some_and(|c| enabled(app, c)); + if ui.add_enabled(on, egui::Button::new(crate::i18n::t(label))).clicked() { + clicked = cmd; + } + } + let Some(cmd) = clicked else { return }; + ui.close(); + if let Err(e) = app.run(cmd, params(cmd)) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app + } + + fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h + } + + fn states(h: &Harness<'static, PhotocraftApp>) -> (usize, usize) { + let st = h.state().session.active().unwrap(); + (st.history.entries().len(), st.history.redo_labels().count()) + } + + fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(2); + } + + fn is_disabled(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().is_disabled() + } + + #[test] + fn menu_items_run_their_commands() { + let mut h = harness(app()); + assert_eq!(states(&h), (4, 0)); + assert!(is_disabled(&h, "Step Forward") && !is_disabled(&h, "Step Backward")); + assert!(is_disabled(&h, "New Snapshot…") && is_disabled(&h, "History Options…"), "not built yet: greyed"); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (3, 1)); + assert!(!is_disabled(&h, "Step Forward")); + click(&mut h, "Step Forward"); + assert_eq!(states(&h), (4, 0)); + click(&mut h, "Delete"); + assert_eq!(states(&h), (3, 0), "Delete removes the current state"); + let doc = h.state().session.active().unwrap().doc.clone(); + click(&mut h, "Clear History"); + assert_eq!(states(&h), (1, 0), "only the current state is left"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &doc), "the image is unchanged"); + assert!(is_disabled(&h, "Clear History") && is_disabled(&h, "Delete") && is_disabled(&h, "Step Forward")); + click(&mut h, "New Document"); + assert_eq!(h.state().session.documents().len(), 2); + assert!(!h.state().ui.status_error); + } + + #[test] + fn close_hides_the_history_panel() { + let mut app = app(); + app.run("window.panel.activate", json!({"panel": "history"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"history".to_string())); + let mut h = harness(app); + click(&mut h, "Close"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(!layout.contains(&"history".to_string()), "the History panel is closed"); + } + + #[test] + fn items_wait_for_an_open_transform() { + let mut app = app(); + app.run("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 20})).unwrap(); + let mut h = harness(app); + let ctx = h.ctx.clone(); + crate::transform_tool::begin(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + for label in ["Step Backward", "Delete", "Clear History", "New Document"] { + assert!(is_disabled(&h, label), "{label}"); + } + } + + /// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that + /// claims complete menus must still translate them. + #[test] + fn menu_labels_are_translated() { + for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { + for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") { + assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); + } + } + } + + #[test] + fn without_a_document_only_close_is_enabled() { + let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for &(label, cmd) in ITEMS.iter().filter(|(l, _)| *l != "---") { + assert_eq!(is_disabled(&h, label), cmd != Some("window.panel.close"), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9dd0c2f72d..99dfdcc958 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2578,3 +2578,10 @@ cameraData Lens Objektiv Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete No document open Není otevřen žádný dokument {n} of {max} sample points {n} z {max} bodů měření + +# History panel menu (#2966) + Step Forward Krok vpřed + Step Backward Krok zpět + Clear History Vymazat historii + New Snapshot… Nový snímek… + History Options… Volby historie… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 457e183233..d0622cbc9d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2609,3 +2609,10 @@ cameraData Lens Objektiv Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen No document open Kein Dokument geöffnet {n} of {max} sample points {n} von {max} Messpunkten + +# History panel menu (#2966) + Step Forward Schritt vorwärts + Step Backward Schritt zurück + Clear History Protokoll löschen + New Snapshot… Neuer Schnappschuss… + History Options… Protokolloptionen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cb8393393..158c914962 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3669,3 +3669,7 @@ cameraData Lens Φακός # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση {n} of {max} sample points {n} από {max} σημεία δειγματοληψίας + +# History panel menu (#2966) + New Snapshot… Νέο στιγμιότυπο… + History Options… Επιλογές ιστορικού… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d331384bc5..325af40af5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2650,3 +2650,10 @@ cameraData Lens Objetivo Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar No document open Ningún documento abierto {n} of {max} sample points {n} de {max} puntos de muestreo + +# History panel menu (#2966) + Step Forward Paso adelante + Step Backward Paso atrás + Clear History Borrar historia + New Snapshot… Nueva instantánea… + History Options… Opciones de historia… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2087139879..00a6878701 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2987,3 +2987,7 @@ cameraData Lens Objectif Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer No document open Aucun document ouvert {n} of {max} sample points {n} sur {max} points d'échantillonnage + +# History panel menu (#2966) + New Snapshot… Nouvel instantané… + History Options… Options d'historique… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 248865b834..057490fb65 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2974,3 +2974,7 @@ cameraData Lens Lensa Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus No document open Tidak ada dokumen yang terbuka {n} of {max} sample points {n} dari {max} titik sampel + +# History panel menu (#2966) + New Snapshot… Snapshot Baru… + History Options… Opsi Riwayat… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 398a11f45c..77dd75cf14 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2641,3 +2641,10 @@ cameraData Lens Obiettivo Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dalla tela per rimuovere No document open Nessun documento aperto {n} of {max} sample points {n} di {max} punti di campionamento + +# History panel menu (#2966) + Step Forward Passo avanti + Step Backward Passo indietro + Clear History Cancella cronologia + New Snapshot… Nuova istantanea… + History Options… Opzioni cronologia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b1fcbeea45..331b51984e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3584,3 +3584,7 @@ cameraData Lens レンズ Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除 No document open ドキュメントが開かれていません {n} of {max} sample points {max} 個中 {n} 個のサンプルポイント + +# History panel menu (#2966) + New Snapshot… 新規スナップショット… + History Options… ヒストリーオプション… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 78c75e4e29..e1a6125235 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3153,3 +3153,10 @@ cameraData Lens 렌즈 Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거 No document open 열린 문서 없음 {n} of {max} sample points {max}개 중 {n}개의 샘플 지점 + +# History panel menu (#2966) + Step Forward 한 단계 앞으로 + Step Backward 한 단계 뒤로 + Clear History 작업 내역 지우기 + New Snapshot… 새 스냅샷… + History Options… 작업 내역 옵션… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f883800f58..0a66a4ba60 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3735,3 +3735,7 @@ cameraData Lens Lens # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen {n} of {max} sample points {n} van {max} steekproefpunten + +# History panel menu (#2966) + New Snapshot… Nieuwe momentopname… + History Options… Geschiedenisopties… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a67617172e..cea20696ab 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3585,3 +3585,7 @@ cameraData Lens Obiektyw Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć No document open Brak otwartego dokumentu {n} of {max} sample points {n} z {max} punktów próbkowania + +# History panel menu (#2966) + New Snapshot… Nowa migawka… + History Options… Opcje historii… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26081f4a8..66acd7ab5c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2994,3 +2994,7 @@ cameraData Lens Lente Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover No document open Nenhum documento aberto {n} of {max} sample points {n} de {max} pontos de amostragem + +# History panel menu (#2966) + New Snapshot… Novo instantâneo… + History Options… Opções de histórico… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 419c25b7f7..6e4f988a21 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2916,3 +2916,7 @@ cameraData Lens Объектив Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления No document open Документ не открыт {n} of {max} sample points {n} из {max} точек замера + +# History panel menu (#2966) + New Snapshot… Новый снимок… + History Options… Параметры истории… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 16a41e350b..0927c9863c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3672,3 +3672,7 @@ cameraData Lens Об'єктив Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення No document open Документ не відкрито {n} of {max} sample points {n} з {max} точок вимірювання + +# History panel menu (#2966) + New Snapshot… Новий знімок стану… + History Options… Параметри історії… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0594427f89..dae6ccaaee 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2585,3 +2585,10 @@ cameraData Lens 镜头 Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除 No document open 未打开任何文档 {n} of {max} sample points {max} 个取样点中的 {n} 个 + +# History panel menu (#2966) + Step Forward 前进一步 + Step Backward 后退一步 + Clear History 清除历史记录 + New Snapshot… 新建快照… + History Options… 历史记录选项… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 80458d2947..d8a5e174d5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2555,3 +2555,10 @@ cameraData Lens 鏡頭 Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除 No document open 未開啟任何文件 {n} of {max} sample points {max} 個取樣點中的 {n} 個 + +# History panel menu (#2966) + Step Forward 向前一步 + Step Backward 向後一步 + Clear History 清除步驟記錄 + New Snapshot… 新增快照… + History Options… 步驟記錄選項… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49a0332bcb..18d0a73181 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -85,6 +85,7 @@ pub mod gpu_canvas; pub mod gpu_status; pub mod gradient_ui; pub mod guide_layout_ui; +pub mod history_menu_ui; pub mod hold_keys; pub mod i18n; mod icon_data; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 8c219bdd6c..d3fba3515f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -842,6 +842,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "history" => { + crate::history_menu_ui::panel_menu(app, ui); + ui.separator(); + } _ => {} } } From 05af9b146bf10d9a119394365a52e5117e48ddb8 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:26:13 +0300 Subject: [PATCH 0019/1452] test(engine): expect signed HDR outputs from destructive Levels --- crates/engine/src/adjust_params.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 00fb67dd03..8bc99a40bc 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,9 +1240,12 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). + // integer documents. A 32-bit document evaluates the signed power curve beyond both + // ranges, retaining negative values and highlights above 1. The float expectations + // below were evaluated independently from the configured curve, not a clipped LUT. let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { + for depth in [8, 16, 32] { + let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1250,8 +1253,10 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); + assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); + assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); + assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); } } From 8911a4ffbe85c4fd329585669c0d80a0fd193375 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:26:13 +0300 Subject: [PATCH 0020/1452] test(engine): expect signed HDR outputs from destructive Levels --- crates/engine/src/adjust_params.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 00fb67dd03..8bc99a40bc 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,9 +1240,12 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). + // integer documents. A 32-bit document evaluates the signed power curve beyond both + // ranges, retaining negative values and highlights above 1. The float expectations + // below were evaluated independently from the configured curve, not a clipped LUT. let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { + for depth in [8, 16, 32] { + let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1250,8 +1253,10 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); + assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); + assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); + assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); } } From 69b1f2544d3bada482f5c9ea5e01a40f191a518f Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:29:14 +0300 Subject: [PATCH 0021/1452] Deny ambient installed profile discovery in automation --- crates/automation/src/workspace.rs | 19 ++++++++++++++++++- docs/control-protocol.md | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d8e82503eb..400c0d9e15 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,7 +307,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { + // Installed profiles scan OS and user directories even without a request path. + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -752,6 +753,22 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } + #[test] + fn installed_profile_discovery_requires_filesystem_authority() { + for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] + { + assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); + } + // Built-in profiles remain usable without reading installed profile files. + for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { + assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + } + } + #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 87d1bbf65f..cf4a1159a3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From 28a3514a73d00ef178085009c314e67c33aa5841 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:29:15 +0300 Subject: [PATCH 0022/1452] Deny ambient installed profile discovery in automation --- crates/automation/src/workspace.rs | 19 ++++++++++++++++++- docs/control-protocol.md | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d8e82503eb..400c0d9e15 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,7 +307,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { + // Installed profiles scan OS and user directories even without a request path. + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -752,6 +753,22 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } + #[test] + fn installed_profile_discovery_requires_filesystem_authority() { + for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] + { + assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); + } + // Built-in profiles remain usable without reading installed profile files. + for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { + assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + } + } + #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 87d1bbf65f..cf4a1159a3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From aa57cc80b9c0fe4ffa9a348ea9701b179dccc2ae Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Sun, 11 Oct 2026 15:41:14 -0300 Subject: [PATCH 0023/1452] feat: animate zoom changes (#566) --- contributors/people.toml | 3 + crates/ui-egui/src/actions.rs | 9 +- crates/ui-egui/src/artboard_ui.rs | 2 + crates/ui-egui/src/canvas.rs | 181 +++++++++++++++++++++-- crates/ui-egui/src/canvas_tool_menu.rs | 26 ++++ crates/ui-egui/src/chrome_ui.rs | 2 + crates/ui-egui/src/control.rs | 4 + crates/ui-egui/src/crop_ui.rs | 13 +- crates/ui-egui/src/move_outline_tests.rs | 13 +- crates/ui-egui/src/panels.rs | 24 +++ crates/ui-egui/src/preset_panels.rs | 13 +- crates/ui-egui/src/scrollbars.rs | 26 +++- crates/ui-egui/src/state.rs | 31 +++- crates/ui-egui/src/view_cmds.rs | 10 ++ crates/ui-egui/src/zoom_tool.rs | 150 +++++++++++++++++++ docs/ui-parity.md | 5 +- 16 files changed, 493 insertions(+), 19 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 0c8aa6fb09..a3cf9d5667 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ display_name = "SyntaxError" [people.axlecoffee] sync_github_name = true + +[people.zi-ger] +sync_github_name = true diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index cf06940224..30ccf04317 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -142,21 +142,28 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu let v = app.ui.views.get_mut(i).ok_or("no document view")?; v.fill_pending = false; match id { - "view.fitOnScreen" => v.fit_pending = true, + "view.fitOnScreen" => { + v.fit_pending = true; + v.zoom_animating = true; + } "view.zoomIn" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, 1, v.doc_size); + v.zoom_animating = true; } "view.zoomOut" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, -1, v.doc_size); + v.zoom_animating = true; } "view.actualPixels" => { v.fit_pending = false; v.zoom = 1.0; + v.zoom_animating = true; } _ => return Err(format!("unsupported view command: {id}")), } + v.zoom_anchor = None; app.session.journal.push((id.into(), params)); Ok(Value::Null) } diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 1ff452a829..1dda153ff2 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -73,6 +73,8 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result { // overflows an i32 sum (#981). v.center = [((f64::from(b.x0) + f64::from(b.x1)) / 2.0) as f32, ((f64::from(b.y0) + f64::from(b.y1)) / 2.0) as f32]; v.fit_pending = false; + v.zoom_animating = true; + v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "artboard": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fbac6ce436..845b26750b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -787,10 +787,11 @@ impl ViewXform { /// The main canvas's mapping for the active document as last laid out (inside the rulers). pub fn active(app: &PhotocraftApp) -> Option { let v = app.ui.views.get(app.session.active_index()?)?; + let (zoom, center) = if v.zoom_animating { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; Some(Self { rect: crate::rulers::content_rect(app, app.last_canvas_rect), - zoom: v.zoom / app.canvas_ppp(), - center: v.center, + zoom: zoom / app.canvas_ppp(), + center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, aspect: app.ui.view.display_aspect(), @@ -878,6 +879,119 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f view.fill_pending = false; } +/// Ease a transient presentation camera over 220 ms without changing the requested camera. +fn animated_view(view: &mut View, xf: ViewXform, ppp: f32, time: f64) -> View { + let target = (view.zoom, view.center); + // Clamping, a canvas resize, or another camera command can make the original anchor + // incompatible with the requested camera. The target must always win. + if let Some((doc, screen)) = view.zoom_anchor { + let offset = xf.unmap_vec((pos2(screen[0], screen[1]) - xf.rect.center()) / (view.zoom / ppp)); + if (doc[0] as f32 - offset.x - view.center[0]).abs() > 0.01 || (doc[1] as f32 - offset.y - view.center[1]).abs() > 0.01 { + view.zoom_anchor = None; + } + } + let mut shown = view.clone(); + if view.zoom_animating { + if view.zoom_transition.is_none_or(|a| a.target != target) { + view.zoom_transition = Some(crate::state::ZoomTransition { from: view.display_camera.unwrap_or(target), target, started: time }); + } + if let Some(a) = view.zoom_transition { + let elapsed = (time - a.started) / 0.22; + let t = if elapsed.is_finite() { elapsed.clamp(0.0, 1.0) as f32 } else { 1.0 }; + let ease = t * t * (3.0 - 2.0 * t); + let lerp = |from: f32, to: f32| from + (to - from) * ease; + shown.zoom = lerp(a.from.0, target.0); + shown.center = [lerp(a.from.1[0], target.1[0]), lerp(a.from.1[1], target.1[1])]; + if let Some((doc, screen)) = view.zoom_anchor { + let zoom = shown.zoom / ppp; + if zoom.is_finite() && zoom > 0.0 { + let offset = xf.unmap_vec((pos2(screen[0], screen[1]) - xf.rect.center()) / zoom); + shown.center = [doc[0] as f32 - offset.x, doc[1] as f32 - offset.y]; + } + } + if t >= 1.0 { + view.zoom_animating = false; + } + } + } + if !view.zoom_animating { + shown.zoom = target.0; + shown.center = target.1; + view.zoom_anchor = None; + view.zoom_transition = None; + } + view.display_camera = Some((shown.zoom, shown.center)); + shown +} + +#[cfg(test)] +mod zoom_animation_tests { + use super::*; + + fn camera(view: &View) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)), zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 } + } + + fn view() -> View { + View { zoom: 1.0, center: [200.0, 150.0], doc_size: [400, 300], fit_pending: false, ..Default::default() } + } + + #[test] + fn zoom_animation_obeys_clamped_targets_and_eases_recentering() { + let mut v = view(); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + let pointer = xf.to_screen(390.0, 150.0); + zoom_about(&mut v, &xf, pointer, 2.0, false, 1.0); + v.zoom_anchor = Some(([390.0, 150.0], [pointer.x, pointer.y])); + v.zoom_animating = true; + crate::scrollbars::clamp_view(&mut v, xf.rect.size(), 1.0); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + assert!(v.zoom_anchor.is_none()); + let done = animated_view(&mut v, xf, 1.0, 0.22); + assert_eq!((done.zoom, done.center), (v.zoom, v.center)); + assert!(!v.zoom_animating); + + let mut v = view(); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + zoom_about(&mut v, &xf, pointer, 2.0, true, 1.0); + v.zoom_animating = true; + let target = v.center; + let xf = camera(&v); + let start = animated_view(&mut v, xf, 1.0, 0.0); + assert_eq!(start.center, [200.0, 150.0], "recentering must not teleport"); + let halfway = animated_view(&mut v, xf, 1.0, 0.11); + assert!(halfway.center[0] > start.center[0] && halfway.center[0] < target[0]); + assert_eq!(animated_view(&mut v, xf, 1.0, 0.22).center, target); + } + + #[test] + fn zoom_animation_views_are_independent_and_direct_navigation_snaps() { + let mut a = view(); + let mut b = view(); + let xf = camera(&a); + animated_view(&mut a, xf, 1.0, 0.0); + animated_view(&mut b, xf, 1.0, 0.0); + a.zoom = 2.0; + a.zoom_animating = true; + animated_view(&mut a, xf, 1.0, 0.0); + b.zoom = 3.0; + b.zoom_animating = true; + animated_view(&mut b, xf, 1.0, 0.11); + assert_eq!(animated_view(&mut a, xf, 1.0, 0.22).zoom, 2.0); + let shown = animated_view(&mut b, xf, 1.0, 0.22); + assert!(shown.zoom > 1.0 && shown.zoom < 3.0); + assert!(b.zoom_animating && !a.zoom_animating); + b.zoom_animating = false; + b.center = [220.0, 170.0]; + let shown = animated_view(&mut b, xf, 1.0, 0.23); + assert_eq!((shown.zoom, shown.center), (b.zoom, b.center)); + assert!(b.zoom_transition.is_none() && b.zoom_anchor.is_none()); + } +} + /// Centre the document and zoom until it fills the canvas. One axis can extend beyond the /// viewport, matching the Hand tool's Fill Screen action. pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f32) { @@ -2470,6 +2584,26 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let full = rect; let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect }; + // Direct navigation starts from the authoritative camera. This also means a click or drag + // never maps through a camera that is still moving under the pointer. + let (pointer_here, hover_here) = ctx.input(|i| { + ( + (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), + i.pointer.hover_pos().is_some_and(|p| rect.contains(p)), + ) + }); + let direct_input = pointer_here + || (hover_here + && crate::wheel_nav::read( + &ctx, + app.session.prefs().general.zoom_with_scroll_wheel, + app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, + ) + .is_some()); + if direct_input { + view.zoom_animating = false; + view.zoom_anchor = None; + } let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; if view.doc_size != size { @@ -2489,12 +2623,23 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + view.zoom_anchor = None; ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; - // Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points. - let point_zoom = view.zoom / ppp; - let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation, aspect }; + // Document rendering follows the target; only shader uniforms and overlay geometry ease. + let target_xf = ViewXform { rect, zoom: view.zoom / ppp, center: view.center, flip, rotation: view.rotation, aspect }; + let render_zoom = view.zoom; + let render_visible = visible_doc_rect(&target_xf); + let mut display_view = animated_view(&mut view, target_xf, ppp, ctx.input(|i| i.time)); + if primary && let Some(stored) = app.ui.views.get_mut(idx) { + stored.zoom_animating = view.zoom_animating; + stored.display_camera = view.display_camera; + } + if view.zoom_animating { + ctx.request_repaint(); + } + let xf = ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, ..target_xf }; let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let stroke_dismissal = primary && crate::shape_stroke_ui::dismiss_from_canvas(app, &response); @@ -2535,7 +2680,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = - ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) + ensure_adjust_proxy(app, idx, render_zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) { on_gpu = true; let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)]; @@ -2561,7 +2706,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: offset: [0.0, 0.0], }; paint_gpu_view(&painter, rect, params, &pattern); - } else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) { + } else if gpu_ok && ensure_gpu(app, idx, render_visible) { on_gpu = true; app.perf.gpu = true; // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). @@ -2727,7 +2872,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: (img_rect, beyond, layer_edges) }; - let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &view, xf)) }; + let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf)) }; let img_rect = xf.doc_rect(doc.bounds()); // Under an open dialog the canvas widget is inert, but the image still pans and zooms. @@ -2759,6 +2904,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { + view.zoom_animating = false; + view.zoom_anchor = None; let next = view.rotation + delta; if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); @@ -2871,6 +3018,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_pan && response.dragged() && !stroke_dismissal, response.drag_delta(), flick_on) { let d = xf.unmap_vec(d) / (view.zoom / ppp); if d.is_finite() { + view.zoom_animating = false; + view.zoom_anchor = None; view.center[0] -= d.x; view.center[1] -= d.y; } @@ -3121,7 +3270,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::Zoom => { let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size); let center = app.session.prefs().tools.zoom_clicked_point_to_center; + let doc_anchor = xf.to_doc(p); + let screen_anchor = p; zoom_about(&mut view, &xf, p, nz, center, ppp); + view.zoom_animating = nz != display_view.zoom; + view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y])); } // A click with the (temporary) Hand does nothing, never the tool underneath. Tool::Hand | Tool::RotateView => {} @@ -3189,8 +3342,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } } - let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; - let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); + let xf = if crop_camera { + if view.center != target_xf.center { + display_view.center = view.center; + } + display_view.rotation = view.rotation; + ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf } + } else { + xf + }; + let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf)); if primary && !(hand_pan && response.dragged()) { if app.ui.extras.grid && app.ui.view.extras { crate::rulers::draw_grid(app, &painter, &xf, &doc); diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 5674e909bf..c14a93c487 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -480,6 +480,8 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co let v = app.ui.views.get_mut(i).ok_or("no document view")?; v.fit_pending = false; v.fill_pending = true; + v.zoom_animating = true; + v.zoom_anchor = None; return Ok(()); } let before = app.ui.views.get(i).map(|v| (v.zoom, v.center)); @@ -495,6 +497,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co // from the centre shrinks (or grows) by the zoom ratio, whatever the rotation or flip. let ratio = zoom0 / v.zoom; v.center = if center_on_point || !ratio.is_finite() { d } else { [d[0] - (d[0] - c[0]) * ratio, d[1] - (d[1] - c[1]) * ratio] }; + v.zoom_anchor = (!center_on_point).then_some(([d[0] as f64, d[1] as f64], menu.pos)); Ok(()) } @@ -1124,6 +1127,29 @@ mod tests { assert!((screen_of(h.state(), doc) - p).length() < 1.0); } + #[test] + fn view_menu_zoom_keeps_its_anchor_during_the_animation() { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + let ctx = h.ctx.clone(); + // Choose without the widget click helper, which runs repaint frames until settled. + choose(h.state_mut(), &ctx, "view.zoomIn"); + h.step(); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.11); + h.step(); + let displayed = crate::canvas::ViewXform::active(h.state()).unwrap(); + assert!(displayed.zoom > zoom && displayed.zoom < h.state().ui.views[0].zoom); + assert!(displayed.to_screen(doc[0], doc[1]).distance(p) < 1.0); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); + h.step(); + assert!(!h.state().ui.views[0].zoom_animating); + } + #[test] fn view_menu_zoom_in_centres_the_clicked_point_with_the_preference() { let mut app = zoom_app(400, 300); diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 81cf0fce54..80b16525ee 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -151,6 +151,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let control_h = crate::panels::status_bar_heights(&t).1; if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0, control_h).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); + app.ui.views[i].zoom_animating = false; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } ui.add_space(12.0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fde3f0b2cb..1f0ab6b55e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -651,6 +651,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) crate::panels::request_dock_width(ctx, w as f32); } if let Some(i) = app.session.active_index() { + if zoom.is_some() || center.is_some() || rotation.is_some() || fit { + app.ui.views[i].zoom_animating = false; + app.ui.views[i].zoom_anchor = None; + } if let Some(z) = zoom { let size = app.session.documents().get(i).map_or([0, 0], |st| [st.doc.size.width, st.doc.size.height]); app.ui.views[i].zoom = crate::zoom_levels::clamp(z as f32, size); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 8b67459c42..0245c41924 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1231,7 +1231,18 @@ mod tests { fn space_on_the_canvas_moves_the_frame_not_the_view() { use egui::{Event, Key, PointerButton, pos2}; let mut a = app(SampleType::U8); - let view = crate::state::View { zoom: 2.0, center: [100.0, 50.0], fit_pending: false, fill_pending: false, doc_size: [200, 100], rotation: 0.0 }; + let view = crate::state::View { + zoom: 2.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; a.ui.views = vec![view.clone()]; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { diff --git a/crates/ui-egui/src/move_outline_tests.rs b/crates/ui-egui/src/move_outline_tests.rs index 7029ab9faf..48808b089f 100644 --- a/crates/ui-egui/src/move_outline_tests.rs +++ b/crates/ui-egui/src/move_outline_tests.rs @@ -38,7 +38,18 @@ fn app() -> (PhotocraftApp, LayerId, LayerId) { app.ui.view.extras = true; app.ui.view.show.layer_edges = true; // 4 screen px per document px, the document centred. - app.ui.views = vec![View { zoom: 4.0, center: [32.0, 32.0], fit_pending: false, fill_pending: false, doc_size: [64, 64], rotation: 0.0 }]; + app.ui.views = vec![View { + zoom: 4.0, + center: [32.0, 32.0], + fit_pending: false, + fill_pending: false, + doc_size: [64, 64], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; (app, ids[0], ids[1]) } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 3afff0f399..a2ec65a9ae 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1332,11 +1332,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; } if widgets::secondary_button(ui, "100%", 0.0).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fit_pending = false; + app.ui.views[i].fill_pending = false; } } Tool::RotateView => { @@ -1362,6 +1368,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; } @@ -1369,12 +1377,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fill_pending = false; } if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].fill_pending = true; + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } } @@ -1531,6 +1543,8 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut pct = app.ui.views[i].zoom * 100.0; if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0, control_h).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); + app.ui.views[i].zoom_animating = false; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } widgets::vline(ui, 16.0); @@ -1808,6 +1822,8 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { let d = (pp - rect.min) / s; app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; + app.ui.views[idx].zoom_animating = false; + app.ui.views[idx].zoom_anchor = None; app.ui.views[idx].fit_pending = false; } // The Navigator has its own zoom target: an unmodified wheel over the preview @@ -1822,6 +1838,8 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); if next != view.zoom { view.zoom = next; + view.zoom_animating = false; + view.zoom_anchor = None; view.fit_pending = false; } } @@ -1843,11 +1861,15 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { app.ui.views[idx].zoom = z; + app.ui.views[idx].zoom_animating = true; + app.ui.views[idx].zoom_anchor = None; app.ui.views[idx].fit_pending = false; } } if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { app.ui.views[idx].fit_pending = true; + app.ui.views[idx].zoom_animating = true; + app.ui.views[idx].zoom_anchor = None; } }); }); @@ -1856,6 +1878,8 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut lz = v.zoom.log2(); if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() { app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size); + app.ui.views[idx].zoom_animating = false; + app.ui.views[idx].zoom_anchor = None; app.ui.views[idx].fit_pending = false; } }); diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 17737dec15..8e4f8aca96 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1171,7 +1171,18 @@ mod tests { fn preset_drops_map_through_the_ruler_inset() { let (mut app, _) = app(); app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); - app.ui.views[0] = crate::state::View { zoom: 2.0, center: [32.0, 24.0], fit_pending: false, fill_pending: false, doc_size: [64, 48], rotation: 0.0 }; + app.ui.views[0] = crate::state::View { + zoom: 2.0, + center: [32.0, 24.0], + fit_pending: false, + fill_pending: false, + doc_size: [64, 48], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; let p = egui::pos2(400.0, 300.0); let off = crate::canvas::doc_point_at(&app, p).unwrap(); assert_eq!(off, [32.0, 24.0], "without rulers the widget rect is the whole mapping"); diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 2ab7cdeab6..dde52d1181 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -289,7 +289,18 @@ mod tests { assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 0.2), -250.0, "the image's right edge on the view's"); assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's"); assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred"); - let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 }; + let mut v = View { + zoom: 1.0, + center: [-500.0, 20.0], + fit_pending: false, + fill_pending: false, + doc_size: [1000, 800], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; assert!(clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); assert_eq!(v.center, [250.0, 200.0]); assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop"); @@ -308,7 +319,18 @@ mod tests { let (at, len) = s.thumb((0.0, 1000.0), 0.0); assert!(at.is_finite() && len.is_finite()); assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0); - let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0], rotation: 0.0 }; + let mut v = View { + zoom: 1.0, + center: [0.0, 0.0], + fit_pending: false, + fill_pending: false, + doc_size: [0, 0], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 52d44b9b2b..21affeadfd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -479,11 +479,40 @@ pub struct View { /// Canvas camera rotation in degrees, wrapped to (−180, 180]. Turns the view, not the pixels. #[serde(default)] pub rotation: f32, + /// Transient presentation state for a discrete zoom command; the target camera above remains + /// authoritative and this flag is never persisted. + #[serde(skip)] + pub(crate) zoom_animating: bool, + /// Optional document/screen anchor for a pointer-centred Zoom-tool transition. + #[serde(skip)] + pub(crate) zoom_anchor: Option<([f64; 2], [f32; 2])>, + #[serde(skip)] + pub(crate) display_camera: Option<(f32, [f32; 2])>, + #[serde(skip)] + pub(crate) zoom_transition: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ZoomTransition { + pub from: (f32, [f32; 2]), + pub target: (f32, [f32; 2]), + pub started: f64, } impl Default for View { fn default() -> Self { - Self { zoom: 1.0, center: [0.0, 0.0], fit_pending: true, fill_pending: false, doc_size: [0, 0], rotation: 0.0 } + Self { + zoom: 1.0, + center: [0.0, 0.0], + fit_pending: true, + fill_pending: false, + doc_size: [0, 0], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } } } diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 190634cca9..cddc47ec94 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -607,6 +607,8 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res let screen = app.session.prefs().units_and_rulers.screen_resolution.max(1.0) as f32; let z = if id == "view.twoHundredPercent" { 2.0 } else { screen / dpi }; app.ui.views[i].zoom = crate::zoom_levels::clamp(z, size); + app.ui.views[i].zoom_animating = true; + app.ui.views[i].zoom_anchor = None; Ok(json!({"zoom": app.ui.views[i].zoom})) } "view.fitLayersOnScreen" => fit_layers(app), @@ -638,12 +640,18 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result { v.zoom = crate::zoom_levels::clamp(points * ppp, size); v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0]; v.fit_pending = false; + v.zoom_animating = true; + v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 { let wid = app.ui.alloc_id(); let mut view = app.ui.views.get(doc).cloned().unwrap_or_default(); + view.zoom_animating = false; + view.zoom_anchor = None; + view.zoom_transition = None; + view.display_camera = None; view.fit_pending = offset > 0 || view.fit_pending; app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true }); wid @@ -679,6 +687,8 @@ fn arrange(app: &mut PhotocraftApp, k: &str) -> Result { let apply = |v: &mut crate::state::View| { if zoom { v.zoom = src.zoom; + v.zoom_animating = true; + v.zoom_anchor = None; } if loc { v.center = src.center; diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index e28e7e9c0a..c892041e76 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -42,6 +42,8 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 { /// Set `view` to `zoom` (device pixels per document pixel) with document point `doc` shown at /// screen point `screen` (egui points) on a display with `ppp` physical pixels per point. pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) { + view.zoom_animating = false; + view.zoom_anchor = None; view.zoom = zoom; let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; let d = xf.unmap_vec((screen - xf.rect.center()) / (zoom / ppp)); @@ -194,6 +196,154 @@ mod tests { assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(zc, 1, h.state().ui.views[0].doc_size)); } + #[test] + fn discrete_zoom_animates_and_keeps_its_document_anchor_fixed() { + let mut h = harness(true, 1.0); + let r = h.state().last_canvas_rect; + let pointer = r.center() + vec2(90.0, -45.0); + let anchor_doc = view_xf(&h).to_doc(pointer); + let anchor_screen = if h.state().session.prefs().tools.zoom_clicked_point_to_center { r.center() } else { pointer }; + let start = h.state().current_zoom(); + + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + + let target = crate::zoom_levels::step(start, 1, h.state().ui.views[0].doc_size); + assert_eq!(h.state().current_zoom(), target, "the model keeps the requested zoom as its target"); + assert!(h.state().ui.views[0].zoom_animating); + let mut time = h.ctx.input(|i| i.time) + 0.05; + for _ in 0..5 { + time += 0.01; + h.input_mut().time = Some(time); + h.step(); + } + let displayed_xf = view_xf(&h); + let displayed = displayed_xf.to_screen(anchor_doc[0] as f32, anchor_doc[1] as f32); + assert!(displayed.distance(anchor_screen) < 0.5, "anchor moved to {displayed:?}"); + let intermediate = displayed_xf.zoom; + assert!(intermediate > start && intermediate < target, "expected an intermediate zoom, got {intermediate}"); + + for _ in 0..30 { + time += 0.01; + h.input_mut().time = Some(time); + h.step(); + } + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!(view_xf(&h).zoom, target); + assert_eq!(h.state().current_zoom(), target); + } + + #[test] + fn gpu_zoom_animation_reuses_the_document_composite() { + let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_pixels_per_point(1.0).renderer(renderer).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.set_wgpu(cc.wgpu_render_state.clone().unwrap()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("edit.fill", json!({"color": "#2060c0"})).unwrap(); + app + }); + h.run_steps(4); + h.state_mut().run("view.actualPixels", json!({})).unwrap(); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.3; + h.input_mut().time = Some(time); + h.step(); + let id = h.state().session.documents()[0].doc.id; + assert!(h.state().canvases[&(id, crate::canvas::GPU_OUTPUT)].on_gpu); + let directory = std::env::var_os("CRAFT_UI_ZOOM_FIXTURES").map(std::path::PathBuf::from); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + } + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + let target = h.state().current_zoom(); + for (name, elapsed) in [("start", 0.0), ("midpoint", 0.11), ("finished", 0.23)] { + h.state_mut().perf.last_refresh = ""; + h.input_mut().time = Some(time + elapsed); + h.step(); + assert!(h.state().perf.gpu, "{name}: keep the GPU canvas"); + assert_eq!(h.state().perf.last_refresh, "", "{name}: reuse the composite"); + assert!(h.state().canvases.values().all(|cache| cache.texture.is_none()), "{name}: no CPU texture fallback"); + let xf = view_xf(&h); + if name == "midpoint" { + assert!(xf.zoom > 1.0 && xf.zoom < target); + } + let image = h.render().unwrap(); + let center = xf.to_screen(200.0, 150.0); + let pixel = image.get_pixel(center.x as u32, center.y as u32); + assert!(pixel[2] > pixel[0] * 2, "{name}: document remains visible: {pixel:?}"); + if let Some(directory) = &directory { + image.save(directory.join(format!("photocraft-zoom-{name}.png"))).unwrap(); + } + } + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!(view_xf(&h).zoom, target); + } + + #[test] + fn view_commands_retarget_a_zoom_click_without_retaining_its_anchor() { + for command in ["view.zoomIn", "view.actualPixels", "view.fitOnScreen"] { + let mut h = harness(true, 1.0); + let pointer = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + assert!(h.state().ui.views[0].zoom_anchor.is_some()); + h.step(); + h.state_mut().run(command, json!({})).unwrap(); + assert!(h.state().ui.views[0].zoom_anchor.is_none(), "{command}"); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.3; + h.input_mut().time = Some(time); + h.step(); + let target = &h.state().ui.views[0]; + assert!(!target.zoom_animating, "{command}"); + assert_eq!(view_xf(&h).zoom, target.zoom); + assert_eq!(view_xf(&h).center, target.center); + } + } + + #[test] + fn wheel_pinch_pan_and_scrubby_zoom_interrupt_a_transition() { + for gesture in 0..5 { + let mut h = harness(true, 1.0); + let pointer = h.state().last_canvas_rect.center(); + h.event(Event::PointerMoved(pointer)); + h.step(); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.step(); + assert!(h.state().ui.views[0].zoom_animating); + match gesture { + 0 => h.event(Event::Zoom(1.25)), + 1 | 2 => h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers { alt: gesture == 1, ..Default::default() }, + }), + _ => { + h.state_mut().ui.tool = if gesture == 3 { crate::state::Tool::Hand } else { crate::state::Tool::Zoom }; + press(&mut h, pointer, true); + h.event(Event::PointerMoved(pointer + vec2(120.0, 0.0))); + } + } + h.step(); + assert!(!h.state().ui.views[0].zoom_animating, "gesture {gesture}"); + h.step(); + let target = &h.state().ui.views[0]; + assert_eq!(view_xf(&h).zoom, target.zoom, "gesture {gesture}"); + assert_eq!(view_xf(&h).center, target.center, "gesture {gesture}"); + } + } + #[test] fn zoom_drags_keep_the_press_point_in_rotated_and_flipped_views() { for (scrubby, out, rotation, flip, ppp) in [ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 71f7897434..77ea389caf 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-11 · **Change:** minor (Hand-tool flick panning) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Hand-tool flick panning and animated zoom) · **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, @@ -163,7 +163,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Hold any tool letter for a temporary tool | missing | #2672 | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | -| Animated zoom | missing | unread pref; #566 | +| Animated zoom | done | `canvas.rs`, `zoom_tool.rs`; #566; discrete Zoom clicks and View zoom controls ease over 220 ms by default, with anchored transitions and immediate direct navigation; the existing preference remains hidden | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | | ⌥-wheel zoom in gentle steps | missing | UI-217-18 | | Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | @@ -210,3 +210,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | +| 2026-10-11 | minor | Animated zoom done: 220 ms transitions per view for discrete Zoom clicks and View controls; GPU composites are reused, coordinates follow the displayed camera, and direct navigation interrupts immediately | From 8afe7157edf7a0c1ae7d83764983052c37c8ab65 Mon Sep 17 00:00:00 2001 From: scott <1591011+dottedt@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:16:08 -0500 Subject: [PATCH 0024/1452] docs(control): state the document.pixel / document.sampleColor reply range (#3666) Both reply with four RGBA floats in 0..1, not 0..255, but control-protocol.md gave a range for neither: the Engine commands table row for document.pixel and the Methods line for document.sampleColor. A client that assumes 0..255 mis-reads every probe. --- docs/control-protocol.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 87d1bbf65f..a4f7faa992 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}` - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. -- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. +- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks, as four floats 0..1 (not 0..255). - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. @@ -103,7 +103,7 @@ store expose and persist the same setting; scripts keep using canonical command | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, selection bounds | -| `document.pixel` | `{"x":10,"y":10}`: composite RGBA | +| `document.pixel` | `{"x":10,"y":10}`: composite RGBA, four floats 0..1 (not 0..255) | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | | `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` | From 086a82d547b1fffdb0519298b6b589e9ebbef5d4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 23:50:35 +0200 Subject: [PATCH 0025/1452] Pattern Stamp and Background Eraser draw live; Spot Healing shows its heal area (TOOL-213-7, #3754) Pattern Stamp (aligned through LiveRetouch, Impressionist dab by dab) and Background Eraser now paint during the drag; the release commits exactly the pixels the live stroke showed (per-pixel tests at 8 and 16 bits). Spot Healing keeps healing on release and shows a dark overlay while dragging. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/eraser_cmds.rs | 48 ++++-- crates/engine/src/eraser_cmds/tests.rs | 55 ++++++ crates/engine/src/retouch_cmds.rs | 103 ++++++++++-- .../engine/src/retouch_cmds/pattern_stamp.rs | 156 +++++++++++------- crates/engine/src/retouch_cmds/tests.rs | 71 +++++++- crates/paint/src/bg_erase.rs | 37 +++-- crates/ui-egui/src/canvas.rs | 28 +++- crates/ui-egui/src/eraser_ui.rs | 16 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live_retouch_tests.rs | 145 ++++++++++++++++ crates/ui-egui/src/retouch_ui.rs | 50 ++++-- docs/brush-parity.md | 5 +- docs/gaps.md | 8 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 +- scorecard/tools.toml | 2 +- 16 files changed, 602 insertions(+), 131 deletions(-) create mode 100644 crates/ui-egui/src/live_retouch_tests.rs diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 98169aff72..0d6af87707 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -126,8 +126,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result { Ok(damage(s, dmg, true)) } -/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one. -fn background_eraser(s: &mut Session, p: &Value) -> Result { +/// The stroke, settings and layer of a `paint.backgroundEraser` (shared by the command and its +/// live preview), after the same checks. +fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSettings, LayerId)> { const CMD: &str = "paint.backgroundEraser"; let pts = crate::brush_cmds::parse_points(p, CMD)?; if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite() || q.x.abs() > 1e7 || q.y.abs() > 1e7) { @@ -143,17 +144,21 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { background: s.tools.background, }; let id = active_pixel_layer(s, CMD)?; - { - let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if let Some(e) = pixels_locked(&d.doc, l) { - return Err(e); - } - if d.doc.effective_locks(id).transparency && !is_background(l) { - return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); - } + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if let Some(e) = pixels_locked(&d.doc, l) { + return Err(e); } - let stroke = Stroke { brush, points: pts }; + if d.doc.effective_locks(id).transparency && !is_background(l) { + return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); + } + Ok((Stroke { brush, points: pts }, bs, id)) +} + +/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one. +fn background_eraser(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "paint.backgroundEraser"; + let (stroke, bs, id) = background_eraser_setup(s, p)?; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -164,6 +169,23 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { Ok(damage(s, dmg, true)) } +/// A `paint.backgroundEraser` stroke shown while it is drawn: the same params, checks and +/// erasing per dab as the commit (on a Background already turned into a normal layer), so the +/// preview at the last point is the committed result. +pub fn live_background_eraser(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.backgroundEraser"; + let (stroke, bs, id) = background_eraser_setup(s, p)?; + let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?); + let stroke = background_eraser_stroke(&stroke); + crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, true, |pre| match background_eraser_effect(pre.format(), &bs) { + Some(effect) => Box::new(effect), + // No alpha: the commit erases nothing either. + None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), + })? + .with_tail_preview() +} + /// Eraser-group command specs. pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/eraser_cmds/tests.rs b/crates/engine/src/eraser_cmds/tests.rs index 1946f346f9..07866aa72f 100644 --- a/crates/engine/src/eraser_cmds/tests.rs +++ b/crates/engine/src/eraser_cmds/tests.rs @@ -275,3 +275,58 @@ fn bench_magic_eraser_24mp() { let t3 = t0.elapsed().as_secs_f64() * 1000.0; println!("phases: sample {t1:.1} ms, wand {:.1} ms, erase {:.1} ms", t2 - t1, t3 - t2); } + +/// The live Background Eraser shows exactly what the commit erases, fed point by point: on the +/// Background (which the commit turns into a normal layer first), with every sampling mode, at +/// 8 and 16 bits (the live working pixels are floats between dabs, as the commit's are). +#[test] +fn live_background_eraser_matches_the_commit() { + let all = [[6.0, 20.0], [18.0, 22.0], [30.0, 18.0], [44.0, 24.0], [56.0, 20.0]]; + for depth in [8, 16] { + for sampling in ["continuous", "once", "backgroundSwatch"] { + let mut s = session("rgb", depth); + s.execute("tools.setColors", json!({"background": "#000000"})).unwrap(); + let opts = |pts: &[[f64; 2]]| json!({"points": pts, "size": 14, "hardness": 0.6, "sampling": sampling, "limits": "discontiguous", "tolerance": 30, "seed": 7}); + let (doc, h) = (s.active().unwrap().doc.clone(), history_len(&s)); + let mut live = live_background_eraser(&s, &opts(&all[..1])).unwrap(); + for q in &all[1..] { + live.push(&[photocraft_paint::StrokePoint::new(q[0], q[1], 1.0)]).unwrap(); + } + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc) && history_len(&s) == h, "the preview leaves the document alone"); + let id = st.active_layer.unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().clone(); + s.execute("paint.backgroundEraser", opts(&all)).unwrap(); + assert_eq!(history_len(&s), h + 1, "one History step"); + let mut erased = false; + for y in 0..48 { + for x in 0..64 { + let (got, want) = (shown.rgba(x, y), surface(&s).rgba(x, y)); + erased |= want[3] < 0.5; + // The colour under a fully erased pixel doesn't show. + if got[3] == 0.0 && want[3] == 0.0 { + continue; + } + assert_eq!(got, want, "{sampling} {depth}-bit ({x}, {y})"); + } + } + assert!(erased, "{sampling} {depth}: the stroke erased something"); + } + } +} + +#[test] +fn live_background_eraser_refuses_what_the_command_refuses() { + let mut s = session("rgb", 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + let p = json!({"points": [[20, 20]]}); + assert!(live_background_eraser(&s, &p).is_err()); + assert!(s.execute("paint.backgroundEraser", p).is_err()); + let s = session("rgb", 8); + for p in [json!({}), json!({"points": []}), json!({"points": [[f64::NAN, 0]]}), json!({"points": [[1e300, 0]]}), json!({"points": [[0, 0]], "sampling": 3})] + { + assert!(live_background_eraser(&s, &p).is_err(), "{p}"); + } + assert!(live_background_eraser(&Session::new(), &json!({"points": [[0, 0]]})).is_err()); +} diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 0da474c22b..b96ddea8cb 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -26,6 +26,7 @@ use crate::{EngineError, Result, Session}; mod content_aware_move; mod patch; mod pattern_stamp; +pub use pattern_stamp::live_impressionist as live_pattern_impressionist; mod remove; pub use patch::preview as patch_preview; @@ -315,6 +316,9 @@ enum LivePaint { Clone { which: SampleLayers, map: crate::presets::clone_source::Mapping, cmd: &'static str }, /// The History Brush's history state. History(Surface), + /// The Pattern Stamp's pattern, tiled from its aligned placement (Impressionist strokes are + /// laid dab by dab: `pattern_stamp::live_impressionist`). + Pattern { tile: std::sync::Arc, place: photocraft_compose::pattern::Placement }, } /// A retouching stroke shown while it is drawn: the dabs so far composited onto a copy of the @@ -340,10 +344,19 @@ pub struct LiveRetouch { } impl LiveRetouch { - /// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush` or - /// `paint.historyBrush`) from its params; their `points` are rendered. + /// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush`, + /// `paint.historyBrush` or an aligned, non-Impressionist `paint.patternStamp`) from its + /// params; their `points` are rendered. pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result { has_pixel_layer(s).map_err(EngineError::Other)?; + if cmd == "paint.patternStamp" { + let st = pattern_stamp::Setup::parse(s, p)?; + if st.impressionist { + return Err(bad(cmd, "Impressionist strokes are laid dab by dab (pattern_stamp::live_impressionist)")); + } + let paint = LivePaint::Pattern { place: st.placement(), tile: st.tile }; + return Self::with_paint(s, cmd, p, st.stroke, st.id, paint, st.mode); + } let (stroke, id) = parse_brush(s, p, cmd)?; let mode = blend_param(p, cmd)?; let paint = match cmd { @@ -356,8 +369,13 @@ impl LiveRetouch { } } "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), - other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), + other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush, History Brush and Pattern Stamp")), }; + Self::with_paint(s, cmd, p, stroke, id, paint, mode) + } + + /// Start a live stroke of `cmd` painting `paint` through `stroke`'s coverage. + fn with_paint(s: &Session, cmd: &str, p: &Value, stroke: Stroke, id: Option, paint: LivePaint, mode: BlendMode) -> Result { let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let mut doc = (*pre).clone(); let sel = doc.selection.clone(); @@ -432,6 +450,7 @@ impl LiveRetouch { let paint = match &self.paint { LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?, LivePaint::History(src) => Region::read(src, r), + LivePaint::Pattern { tile, place } => pattern_stamp::paint_region(tile, place, &self.pre_surf.format(), r), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; surf.write_region(r, &self.pre_surf.read_region(r)); @@ -899,6 +918,52 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(json!({ "damage": damage_json(dmg) })) } +/// The working pixels of a [`LiveDab`]: a copy of the target in its own depth, or (`precise`) +/// float pixels copied from it a tile at a time as dabs first reach them, so only the stroke's +/// area is ever copied. +struct WorkPixels { + surf: Surface, + /// Tiles (`TILE` px) copied from the target so far; `None` when `surf` is a full copy. + filled: Option>, +} + +impl WorkPixels { + const TILE: i32 = 64; + + fn new(pre: &Surface, precise: bool) -> Self { + if precise { + let f = pre.format(); + let fmt = PixelFormat::new(f.mode, SampleType::F32, f.alpha); + Self { surf: Surface::new(fmt), filled: Some(std::collections::HashSet::new()) } + } else { + Self { surf: pre.clone(), filled: None } + } + } + + /// The working pixels of `r` (`pre` = the target before the stroke). + fn read(&mut self, pre: &Surface, r: Rect) -> Region { + if let Some(filled) = &mut self.filled + && !r.is_empty() + { + let t = Self::TILE; + for ty in r.y0.div_euclid(t)..=(r.y1 - 1).div_euclid(t) { + for tx in r.x0.div_euclid(t)..=(r.x1 - 1).div_euclid(t) { + if filled.insert((tx, ty)) { + let cell = Rect::new(tx * t, ty * t, tx * t + t, ty * t + t); + // Normalised samples, stored unrounded as floats. + self.surf.write_region(cell, &pre.read_region(cell)); + } + } + } + } + Region::read(&self.surf, r) + } + + fn write(&mut self, region: &Region) { + region.write(&mut self.surf); + } +} + /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -912,7 +977,7 @@ pub struct LiveDab { effect: DabEffect, halo: i32, pre_surf: Surface, - work: Surface, + work: WorkPixels, id: Option, params: Value, opacity: f32, @@ -952,7 +1017,25 @@ impl LiveDab { halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + let doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + Self::with_effect_on(doc, cmd, p, stroke, id, halo, false, effect) + } + + /// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own + /// edit does first (the Background Eraser's Background turned into a normal layer, say). With + /// `precise` the working pixels between dabs are kept as floats, as the commit keeps them, so + /// the preview is the commit at any depth (else they are stored in the target's depth). + #[allow(clippy::too_many_arguments)] + pub(crate) fn with_effect_on( + mut doc: Document, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + halo: i32, + precise: bool, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); @@ -966,7 +1049,7 @@ impl LiveDab { done: 0, effect, halo, - work: pre_surf.clone(), + work: WorkPixels::new(&pre_surf, precise), pre_surf, id, params: p.clone(), @@ -1006,9 +1089,9 @@ impl LiveDab { if r.is_empty() { continue; } - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - region.crop(r).write(&mut self.work); + self.work.write(®ion.crop(r)); dmg = dmg.union(&r); } self.done += new.len(); @@ -1018,7 +1101,7 @@ impl LiveDab { let ds = self.renderer.tail_dabs(); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); for (k, d) in ds.iter().enumerate() { (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); } @@ -1035,7 +1118,7 @@ impl LiveDab { self.bounds = self.bounds.union(&dmg).union(&tail_rect); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; if !shown.is_empty() { - let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown)); + let (orig, work) = (Region::read(&self.pre_surf, shown), self.work.read(&self.pre_surf, shown)); photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); } if let Some((r, work)) = tail { diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index cb9e0adfe9..3c0772baf7 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -6,7 +6,7 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; -use photocraft_doc::Pattern; +use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native, stroke_coverage}; use photocraft_paint::{Stroke, StrokePoint}; @@ -29,53 +29,110 @@ pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> { if crate::pattern_cmds::resolve(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) } } +/// A parsed `paint.patternStamp`: shared by the command and its live preview, so both paint the +/// same stroke (seeded the same way) with the same tile and placement. +pub(super) struct Setup { + pub(super) stroke: Stroke, + pub(super) id: Option, + pub(super) mode: BlendMode, + aligned: bool, + pub(super) impressionist: bool, + pat: Pattern, + pub(super) tile: std::sync::Arc, + phase: (f32, f32), + scale: f32, + angle: f32, + seed: u64, +} + +impl Setup { + pub(super) fn parse(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.patternStamp"; + let (mut stroke, id) = parse_brush(s, p, CMD)?; + let mode = blend_param(p, CMD)?; + let aligned = flag(p, "aligned", true); + let impressionist = flag(p, "impressionist", false); + let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; + if pat.is_empty() { + return Err(bad(CMD, "the pattern is empty")); + } + let scale = stamp_scale(p, CMD)?; + let angle = stamp_angle(p, CMD)?; + let Some(first) = stroke.points.first() else { + return Err(bad(CMD, "`points` is empty")); + }; + let phase = stamp_phase(p, CMD, aligned, first)?; + let tile = std::sync::Arc::new(Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty"))?); + reject_huge_stroke(CMD, &stroke)?; + let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| { + let bytes = p.get("points").map(std::string::ToString::to_string).unwrap_or_default(); + photocraft_paint::rng::seed_from_bytes(bytes.as_bytes()) + }); + stroke.brush.seed = seed; + Ok(Self { stroke, id, mode, aligned, impressionist, pat, tile, phase, scale, angle, seed }) + } + + /// Where the pattern sits (aligned strokes; Impressionist jitters it per dab). + pub(super) fn placement(&self) -> Placement { + placement(self.phase, self.scale, self.angle) + } + + /// One Impressionist dab's edit of a working copy in `fmt`. + fn impressionist_effect(&self, fmt: PixelFormat) -> super::DabEffect { + let (tile, phase, scale, angle, seed, mode) = (self.tile.clone(), self.phase, self.scale, self.angle, self.seed, self.mode); + let (tw, th) = (self.pat.width as f32 * scale, self.pat.height as f32 * scale); + Box::new(move |work, fp| stamp_dab(work, &fmt, &tile, fp, phase, scale, angle, seed, tw, th, mode)) + } +} + pub(super) fn run(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.patternStamp"; - let (mut stroke, id) = parse_brush(s, p, CMD)?; - let mode = blend_param(p, CMD)?; - let aligned = flag(p, "aligned", true); - let impressionist = flag(p, "impressionist", false); - let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; - if pat.is_empty() { - return Err(bad(CMD, "the pattern is empty")); - } - let scale = stamp_scale(p, CMD)?; - let angle = stamp_angle(p, CMD)?; - let Some(first) = stroke.points.first() else { - return Err(bad(CMD, "`points` is empty")); - }; - let phase = stamp_phase(p, CMD, aligned, first)?; - let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty"))?; - reject_huge_stroke(CMD, &stroke)?; - - let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| { - let bytes = p.get("points").map(std::string::ToString::to_string).unwrap_or_default(); - photocraft_paint::rng::seed_from_bytes(bytes.as_bytes()) - }); - stroke.brush.seed = seed; - + let st = Setup::parse(s, p)?; let dmg = s.edit("Pattern Stamp", |doc, _| { - crate::pattern_cmds::ensure_in_doc(doc, &pat); + crate::pattern_cmds::ensure_in_doc(doc, &st.pat); let pre = doc.clone(); - let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); - if impressionist { - paint_impressionist(surf, &stroke, sel, lock, &tile, &pat, phase, scale, angle, seed, mode) + if st.impressionist { + // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. + let effect = st.impressionist_effect(surf.format()); + Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) } else { - paint_aligned(surf, &stroke, sel, lock, &tile, phase, scale, angle, mode, CMD) + paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(dmg); + if let Some(state) = s.active_mut() { + state.last_damage = Some(dmg); } Ok(json!({ "damage": damage_json(dmg), - "phase": [phase.0, phase.1], - "aligned": aligned, - "pattern": pat.id, + "phase": [st.phase.0, st.phase.1], + "aligned": st.aligned, + "pattern": st.pat.id, })) } +/// A Pattern Stamp stroke with Impressionist on, shown while it is drawn: the same jittered dabs, +/// laid one by one as the commit lays them (`apply_dab_stroke`), so the preview at the last point +/// is the committed result. Aligned strokes are live through [`super::LiveRetouch`]. +pub fn live_impressionist(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.patternStamp"; + has_pixel_layer(s).map_err(crate::EngineError::Other)?; + let st = Setup::parse(s, p)?; + if !st.impressionist { + return Err(bad(CMD, "only Impressionist strokes are laid dab by dab; aligned strokes preview through LiveRetouch")); + } + let (stroke, id) = (st.stroke.clone(), st.id); + let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, true, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() +} + +/// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints +/// through its coverage. +pub(super) fn paint_region(tile: &Tile, place: &Placement, fmt: &PixelFormat, rect: Rect) -> Region { + rgba_to_region(fmt, rect, &render(tile, place, rect)) +} + fn stamp_scale(p: &Value, cmd: &str) -> Result { match p.get("scale") { None => Ok(1.0), @@ -188,9 +245,7 @@ fn paint_aligned( sel: Option<&Surface>, lock: bool, tile: &Tile, - phase: (f32, f32), - scale: f32, - angle: f32, + place: Placement, mode: BlendMode, cmd: &str, ) -> Result { @@ -199,35 +254,10 @@ fn paint_aligned( if bounds.is_empty() { return Ok(Rect::EMPTY); } - let place = placement(phase, scale, angle); - let rgba = render(tile, &place, bounds); - let paint = rgba_to_region(&surf.format(), bounds, &rgba); + let paint = paint_region(tile, &place, &surf.format(), bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) } -#[allow(clippy::too_many_arguments)] -fn paint_impressionist( - surf: &mut Surface, - stroke: &Stroke, - sel: Option<&Surface>, - lock: bool, - tile: &Tile, - pat: &Pattern, - phase: (f32, f32), - scale: f32, - angle: f32, - seed: u64, - mode: BlendMode, -) -> Result { - let fmt = surf.format(); - let tw = pat.width as f32 * scale; - let th = pat.height as f32 * scale; - // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. - Ok(apply_dab_stroke(surf, stroke, sel, lock, 0, |work, fp| { - stamp_dab(work, &fmt, tile, fp, phase, scale, angle, seed, tw, th, mode); - })) -} - fn footprint_cov(fp: &Footprint, x: i32, y: i32) -> f32 { if x < fp.rect.x0 || y < fp.rect.y0 { return 0.0; diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 6ceb14c10f..4a28fa9b87 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1296,7 +1296,7 @@ macro_rules! live_coordinate_regression { live_coordinate_regression!( live_retouch_rejects_invalid_batches_without_changing_the_preview, LiveRetouch, - ["paint.cloneStamp", "paint.healingBrush", "paint.historyBrush"] + ["paint.cloneStamp", "paint.healingBrush", "paint.historyBrush", "paint.patternStamp"] ); live_coordinate_regression!( live_dab_rejects_invalid_batches_without_changing_the_preview, @@ -1509,3 +1509,72 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() { } } } + +/// The live Pattern Stamp shows exactly what the commit paints, fed point by point, at 8 and 16 +/// bits: aligned strokes through their coverage (`LiveRetouch`), Impressionist ones dab by dab +/// (`LiveDab` with float working pixels, as the commit's). +#[test] +fn live_pattern_stamp_matches_the_commit() { + let all = [[20.0, 30.0], [34.0, 36.0], [50.0, 28.0], [70.0, 34.0]]; + for depth in [8, 16] { + for (impressionist, aligned) in [(false, true), (false, false), (true, true)] { + let mut s = session(96, 64, depth, "rgb"); + paint_layer(&mut s, texture); + let opts = |pts: &[[f64; 2]]| { + json!({"points": pts, "size": 14, "hardness": 40, "opacity": 70, "pattern": "Checkerboard", "scale": 150, "angle": 20, + "aligned": aligned, "impressionist": impressionist, "mode": "multiply", "seed": 11}) + }; + let (doc, h) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.past_len()); + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + let shown = if impressionist { + assert!(LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).is_err(), "Impressionist is laid dab by dab"); + let mut live = live_pattern_impressionist(&s, &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + live.doc.clone() + } else { + assert!(live_pattern_impressionist(&s, &opts(&all[..1])).is_err()); + let mut live = LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + live.doc.clone() + }; + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc), "the preview leaves the document alone"); + s.execute("paint.patternStamp", opts(&all)).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), h + 1, "one History step"); + let id = s.active().unwrap().active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), s.active().unwrap().doc.layer(id).unwrap().surface().unwrap()); + let mut changed = false; + for y in 0..64 { + for x in 0..96 { + let (got, want) = (a.rgba(x, y), b.rgba(x, y)); + changed |= (0..3).any(|c| (want[c] - texture(x, y)[c]).abs() > 0.02); + assert_eq!(got, want, "impressionist {impressionist} aligned {aligned} {depth}-bit ({x}, {y})"); + } + } + assert!(changed, "the stroke painted"); + } + } +} + +/// A live Pattern Stamp on the layer mask paints the mask, as the commit does, and leaves the +/// layer's pixels alone. +#[test] +fn live_pattern_stamp_paints_the_targeted_mask() { + let mut s = session(64, 48, 8, "rgb"); + paint_layer(&mut s, texture); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let all = [[16.0, 20.0], [40.0, 24.0]]; + let opts = |pts: &[[f64; 2]]| json!({"points": pts, "size": 12, "pattern": "Checkerboard", "target": "mask", "seed": 3}); + let mut live = LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).unwrap(); + live.push(&[StrokePoint::new(40.0, 24.0, 1.0)]).unwrap(); + let shown = live.doc.clone(); + s.execute("paint.patternStamp", opts(&all)).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let (lv, cm) = (shown.layer(id).unwrap(), s.active().unwrap().doc.layer(id).unwrap()); + let (lm, cmask) = (&lv.mask.as_ref().unwrap().surface, &cm.mask.as_ref().unwrap().surface); + let b = Rect::new(0, 0, 64, 48); + assert_eq!(lm.read_region(b), cmask.read_region(b), "the live mask is the committed mask"); + assert!((0..64).any(|x| cmask.rgba(x, 22)[0] < 0.99), "the mask was painted"); + assert_eq!(lv.surface().unwrap().read_region(b), cm.surface().unwrap().read_region(b), "pixels untouched"); +} diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 14b3df3964..c6689fd7bf 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -4,13 +4,14 @@ //! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth), //! so nothing is converted through RGB; only alpha changes. +use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Region, alpha_index, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,16 +50,33 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - let fmt = target.format(); - let Some(a) = alpha_index(&fmt) else { return Rect::EMPTY }; + let Some(effect) = background_eraser_effect(target.format(), bs) else { return Rect::EMPTY }; + apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) +} + +/// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), +/// never the brush's erase flag. Live previews run the same dabs. +pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { + let mut stroke = stroke.clone(); + stroke.brush.opacity = 1.0; + stroke.brush.erase = false; + stroke +} + +/// One Background Eraser dab's edit of a working copy in format `fmt`: shared by +/// [`apply_background_eraser`] and live previews that run the dabs as they come, so both erase +/// the same pixels. `None` when `fmt` has no alpha channel (nothing can be erased). +pub fn background_eraser_effect(fmt: PixelFormat, bs: &BgEraseSettings) -> Option> { + let a = alpha_index(&fmt)?; let nc = a; let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 }; let soft = bs.limits != Limits::FindEdges; let native = |c: [f32; 4]| -> Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; let swatch = native(bs.background); let fg = native(bs.foreground); + let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground); let mut once: Option>> = None; - let match_w = |d: f32| -> f32 { + let match_w = move |d: f32| -> f32 { if d > tol { 0.0 } else if soft && tol > 0.0 { @@ -68,10 +86,7 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra 1.0 } }; - let mut stroke = stroke.clone(); - stroke.brush.opacity = 1.0; - stroke.brush.erase = false; - apply_dab_stroke(target, &stroke, selection, false, 0, |work: &mut Region, fp| { + Some(move |work: &mut Region, fp: &Footprint| { let r = fp.rect.intersect(&work.rect); if r.is_empty() { return; @@ -85,7 +100,7 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra let p = work.px(cx, cy); (p[a] > 0.0).then(|| p[..nc].to_vec()) }; - let sampled = match bs.sampling { + let sampled = match sampling { Sampling::Continuous => hotspot(work), Sampling::Once => once.get_or_insert_with(|| hotspot(work)).clone(), Sampling::BackgroundSwatch => Some(swatch.clone()), @@ -105,13 +120,13 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra continue; } let c = &p[..nc]; - if bs.protect_foreground && dist(c, &fg) <= tol { + if protect_foreground && dist(c, &fg) <= tol { continue; } weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); } } - if bs.limits != Limits::Discontiguous { + if limits != Limits::Discontiguous { keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); } for y in r.y0..r.y1 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f53103881..ea74d435c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -491,13 +491,20 @@ pub(crate) fn strokes_live(tool: Tool) -> bool { ) || live_retouch_command(tool).is_some() } -/// The retouching tools drawn live (`LiveRetouch`), by the command their live stroke previews. -/// The Healing Brush shows its texture while drawing; the blend comes on release. +/// The Spot Healing Brush's stroke while dragging: Photoshop shows the area it will heal as a +/// semi-opaque dark overlay (an image overlay, the same in every UI theme) and heals on release. +pub(crate) const SPOT_HEALING_TRAIL: Color32 = Color32::from_black_alpha(128); + +/// The retouching tools drawn live (`LiveRetouch`; the Background Eraser's and an Impressionist +/// Pattern Stamp's dabs are laid one by one, `LiveDab`), by the command their live stroke +/// previews. The Healing Brush shows its texture while drawing; the blend comes on release. fn live_retouch_command(tool: Tool) -> Option<&'static str> { match tool { Tool::CloneStamp => Some("paint.cloneStamp"), Tool::Healing => Some("paint.healingBrush"), Tool::HistoryBrush => Some("paint.historyBrush"), + Tool::PatternStamp => Some("paint.patternStamp"), + Tool::BackgroundEraser => Some("paint.backgroundEraser"), _ => None, } } @@ -615,6 +622,20 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) + } else if d.tool == Tool::PatternStamp { + // The params `retouch_ui::finish_stroke` commits. + let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points); + p["points"] = json!(d.points); + p["target"] = paint_target(app); + if app.ui.tool_options.pattern_stamp_impressionist { + EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?) + } else { + EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?) + } + } else if d.tool == Tool::BackgroundEraser { + // The params `eraser_ui::finish_stroke` commits (layer pixels only). + let p = crate::eraser_ui::background_eraser_params(app, &d.points); + EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?) } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; @@ -4041,6 +4062,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View Tool::QuickSelection => Color32::from_white_alpha(40), // The Remove Tool tints what it will remove, like Photoshop's. Tool::Remove => crate::theme::Tokens::get(painter.ctx()).danger.gamma_multiply(0.45), + // The Spot Healing Brush darkens the area it will heal, as Photoshop's does while + // dragging; the heal itself is computed on release. + Tool::SpotHealing => SPOT_HEALING_TRAIL, _ => Color32::from_white_alpha(60), }; let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index 59fc4d4926..a6cd686d89 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -40,17 +40,23 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - if tool != Tool::BackgroundEraser { return false; } + let r = app.run("paint.backgroundEraser", background_eraser_params(app, points)); + report(app, r); + true +} + +/// `paint.backgroundEraser` params for a stroke along `points`: shared by the live preview and +/// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). +pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - let p = json!({ + json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, - }); - let r = app.run("paint.backgroundEraser", p); - report(app, r); - true + "seed": crate::retouch_ui::stroke_seed(points), + }) } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e662d8660..255b82b22f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2179,6 +2179,8 @@ mod pen_variants_tests; #[cfg(test)] mod color_replacement_tests; #[cfg(test)] +mod live_retouch_tests; +#[cfg(test)] mod pencil_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/live_retouch_tests.rs b/crates/ui-egui/src/live_retouch_tests.rs new file mode 100644 index 0000000000..dff475cde4 --- /dev/null +++ b/crates/ui-egui/src/live_retouch_tests.rs @@ -0,0 +1,145 @@ +//! Pattern Stamp and Background Eraser strokes draw live (TOOL-213-7, #3754): the canvas shows +//! the engine's real result while dragging, the release commits exactly those pixels as one +//! History step, and the commit is what the one-shot command paints for the same stroke. The Spot +//! Healing Brush shows the area it will heal and heals on release, as Photoshop does. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, display_doc, strokes_live, tool_event}; +use crate::state::Tool; + +const STROKE: [(f64, f64); 4] = [(12.0, 20.0), (30.0, 24.0), (48.0, 18.0), (66.0, 22.0)]; + +/// An 80×40 document at `depth` bits: a white Background with a black block on its left half, +/// with `tool` picked (14 px, soft, no smoothing). +fn app(depth: u32, tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 80, "height": 40, "depth": depth})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 14, "hardness": 0.5, "pressureSize": false, "smoothing": {"amount": 0.0}}})).unwrap(); + app +} + +/// The active layer's pixels (or its mask) in `doc`. +fn surface(doc: &photocraft_doc::Document, app: &PhotocraftApp, mask: bool) -> Vec { + let l = doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap(); + let s = if mask { &l.mask.as_ref().unwrap().surface } else { l.surface().unwrap() }; + s.read_region(photocraft_geom::Rect::new(0, 0, 80, 40)) +} + +/// Drag `STROKE` with the picked tool. Returns what the canvas showed at the last move (the +/// targeted surface) and the params the release committed. +fn live_drag(app: &mut PhotocraftApp, cmd: &str, mask: bool) -> (Vec, Value) { + let (before, steps) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: STROKE[0].0, y: STROKE[0].1, pressure: 1.0 }, m); + for &(x, y) in &STROKE[1..] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + assert!(app.live_stroke.is_some(), "{cmd}: the canvas shows the stroke while dragging"); + let shown = display_doc(app, 0).0; + let shown = surface(&shown, app, mask); + assert!(shown != surface(&before, app, mask), "{cmd}: the preview paints as it goes"); + let st = app.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc) && st.history.past_len() == steps, "{cmd}: nothing committed while dragging"); + let (x, y) = STROKE[3]; + tool_event(app, ToolEvent::Up { x, y }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(app.live_stroke.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "{cmd}: one History step"); + let params = app.session.journal.iter().rev().find(|(name, _)| name == cmd).unwrap().1.clone(); + (shown, params) +} + +/// The release committed what the drag showed, and the one-shot command gives the same pixels +/// again; without the live stroke's seed too where nothing is jittered (`keep_seed` false: a +/// brush without jitter paints the same whatever the seed; Impressionist jitters every dab). +fn check_commit(app: &mut PhotocraftApp, cmd: &str, shown: &[f32], mut params: Value, mask: bool, keep_seed: bool) { + let committed = app.session.active().unwrap().doc.clone(); + assert!(surface(&committed, app, mask) == shown, "{cmd}: the release commits the pixels the drag showed"); + app.run("edit.undo", json!({})).unwrap(); + if !keep_seed { + params.as_object_mut().unwrap().remove("seed"); + } + app.run(cmd, params).unwrap(); + assert!(surface(&app.session.active().unwrap().doc, app, mask) == shown, "{cmd}: the one-shot command paints the same"); +} + +#[test] +fn pattern_stamp_draws_live_and_commits_what_it_showed() { + for depth in [8, 16] { + for impressionist in [false, true] { + let mut app = app(depth, Tool::PatternStamp); + assert!(strokes_live(Tool::PatternStamp)); + app.ui.tool_options.pattern_stamp_impressionist = impressionist; + app.ui.tool_options.pattern_stamp_scale = 150.0; + let (shown, params) = live_drag(&mut app, "paint.patternStamp", false); + assert_eq!(params["impressionist"], json!(impressionist)); + check_commit(&mut app, "paint.patternStamp", &shown, params, false, impressionist); + } + } +} + +#[test] +fn a_live_pattern_stamp_on_the_layer_mask_paints_the_mask() { + let mut app = app(8, Tool::PatternStamp); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + let pixels = surface(&app.session.active().unwrap().doc, &app, false); + let (shown, params) = live_drag(&mut app, "paint.patternStamp", true); + assert_eq!(params["target"], json!("mask")); + assert!(surface(&app.session.active().unwrap().doc, &app, false) == pixels, "the layer's pixels are untouched"); + check_commit(&mut app, "paint.patternStamp", &shown, params, true, false); +} + +#[test] +fn background_eraser_draws_live_and_commits_what_it_showed() { + for depth in [8, 16] { + for sampling in ["continuous", "once"] { + let mut app = app(depth, Tool::BackgroundEraser); + assert!(strokes_live(Tool::BackgroundEraser)); + app.ui.tool_options.bg_sampling = sampling.into(); + let (shown, params) = live_drag(&mut app, "paint.backgroundEraser", false); + // The release turned the Background into a normal layer, as the command does. + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "Layer 0"); + check_commit(&mut app, "paint.backgroundEraser", &shown, params, false, false); + } + } +} + +#[test] +fn a_refused_background_eraser_stroke_shows_nothing_and_reports_on_release() { + let mut app = app(8, Tool::BackgroundEraser); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 40.0, y: 20.0, pressure: 1.0 }, m); + assert!(app.live_stroke.is_none(), "the engine refuses the stroke, so nothing is drawn live"); + tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 20.0 }, m); + assert!(app.ui.status_error && app.ui.status.contains("transparency"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +#[test] +fn spot_healing_shows_the_area_it_heals_and_heals_on_release() { + let mut app = app(8, Tool::SpotHealing); + assert!(!strokes_live(Tool::SpotHealing), "the heal needs the whole stroke"); + assert_eq!(crate::canvas::SPOT_HEALING_TRAIL.r(), 0, "a dark overlay, as Photoshop's"); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 20.0, pressure: 1.0 }, m); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 20.0 }, m); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); +} diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 8e78a5dd98..11f576f0b8 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -78,6 +78,38 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { }) } +/// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and +/// `target`): shared by the live preview and the commit. The jitter `seed` comes from the stroke's +/// first point, so the live stroke (which starts with only that point) and the commit agree. +pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { + let o = &app.ui.tool_options; + let mut p = json!({ + "aligned": o.pattern_stamp_aligned, + "impressionist": o.pattern_stamp_impressionist, + "scale": o.pattern_stamp_scale, + "angle": o.pattern_stamp_angle, + "seed": stroke_seed(points), + }); + if o.pattern_stamp_aligned + && let Some(ph) = app.ui.pattern_stamp_phase + { + p["phase"] = json!(ph); + } + if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { + p["pattern"] = json!(id); + } + p +} + +/// The jitter seed of a stroke drawn live and committed on release: from its first position, which +/// the live stroke and the commit share (the engine's default hashes every point, which the live +/// stroke doesn't have yet). +pub(crate) fn stroke_seed(points: &[[f64; 3]]) -> u64 { + // Position only: a pen tap raises the first point's pressure on release (#1798). + let first = points.first().map(|p| json!([p[0], p[1]]).to_string()).unwrap_or_default(); + photocraft_engine::paint::rng::seed_from_bytes(first.as_bytes()) +} + /// Finish a stroke with a retouching tool. Returns false if `tool` isn't one. pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers) -> bool { let o = app.ui.tool_options.clone(); @@ -87,23 +119,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})), Tool::MixerBrush => ("paint.mixerBrush", json!({})), Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), - Tool::PatternStamp => { - let mut p = json!({ - "aligned": o.pattern_stamp_aligned, - "impressionist": o.pattern_stamp_impressionist, - "scale": o.pattern_stamp_scale, - "angle": o.pattern_stamp_angle, - }); - if o.pattern_stamp_aligned - && let Some(ph) = app.ui.pattern_stamp_phase - { - p["phase"] = json!(ph); - } - if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { - p["pattern"] = json!(id); - } - ("paint.patternStamp", p) - } + Tool::PatternStamp => ("paint.patternStamp", pattern_stamp_params(app, points)), Tool::Healing | Tool::CloneStamp => { let Some(p) = clone_params(app) else { // Option-click on the Mac, Alt-click on Windows. diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 75575abd7e..dbe1fd719d 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: brush engine, dynamics, pressure, smoothing, cursors, retouch tools and feel audited from source and open issues) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Pattern Stamp and Background Eraser draw live, TOOL-213-7; 2026-10-10: major, first version: brush engine, dynamics, pressure, smoothing, cursors, retouch tools and feel audited from source and open issues) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -58,7 +58,7 @@ tablets against Photoshop side by side. | Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | -| Live stroke for every brush-based retouch tool | missing | TOOL-213-7 | +| Live stroke for every brush-based retouch tool | partial | TOOL-213-7: all live but the Mixer Brush (PR #3627); Spot Healing shows the area it heals and heals on release, as Photoshop does | | No OS arrow cursor over the canvas with a pen | partial | TOOL-213-14 | ## Gestures and cursors @@ -110,4 +110,5 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Pattern Stamp and Background Eraser draw live (#3754); live retouch row now partial (Mixer Brush left) | | 2026-10-10 | major | First version, from a source audit and the open issues | diff --git a/docs/gaps.md b/docs/gaps.md index 8a2b90cbea..0aa1d6d61a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Tilt-Shift evidence added to G2; earlier the same day: major, every known shortfall ranked from the re-measure) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; 2026-10-10: Tilt-Shift evidence added to G2, and major, every known shortfall ranked from the re-measure) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -128,8 +128,9 @@ what an agent can't finish alone. - **Missing:** lag with big brushes (#2619, #1994) and under the cursor (#2566, #1343); jumpy pressure on Windows (#2046) and tap sensitivity (#1933); stair-stepped line weights (#1776), interpolation and smoothing problems (#2138), edge falloff (#2149), pixelated cursor vs - Photoshop (#1433), brush rotation not driven by pen rotation (#1301); live preview only for - Brush and Eraser (TOOL-213-7); no image file as a brush tip (TOOL-213-12); per-control pressure + Photoshop (#1433), brush rotation not driven by pen rotation (#1301); no live preview for the + Mixer Brush yet (TOOL-213-7, in PR #3627; the other brush tools draw live, Spot Healing and Remove + compute on release as in Photoshop); no image file as a brush tip (TOOL-213-12); per-control pressure curves (TOOL-213-11); 4 of Photoshop's symmetry modes. - **Detail:** [`brush-parity.md`](brush-parity.md). - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. @@ -218,6 +219,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | G4: Pattern Stamp and Background Eraser draw live, Spot Healing shows a dark overlay while dragging (#3754); only the Mixer Brush is left of TOOL-213-7 | | 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/scorecard.md b/docs/scorecard.md index fdba39bd4d..91a53e48ac 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -136,7 +136,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows the outline only) | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | -| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | +| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627) | | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping and auto-hide; W/H link, custom-tip clipping and overlay blend modes remain missing | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 472a0f7cfc..8ac3e16289 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -170,7 +170,7 @@ parity for the area and are already contained in the dimension rows above. | Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) | | Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | -| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | +| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); no live stroke preview for the Mixer Brush yet (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | @@ -560,6 +560,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Retouching evidence: Pattern Stamp and Background Eraser draw live (#3754), only the Mixer Brush is left of TOOL-213-7; percentages unchanged | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51e3771925..4dd8144191 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -52,7 +52,7 @@ id = "TOOL-213-7" target = "Live stroke preview for every brush-based retouch tool" status = "partial" issue = 213 -note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp" +note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627)" [[item]] id = "TOOL-213-8" From 474a4375eb1fe94df73ba414d6d1b9be96d2ee31 Mon Sep 17 00:00:00 2001 From: Ahmed Tammam Date: Mon, 12 Oct 2026 04:30:26 +0300 Subject: [PATCH 0026/1452] feat(transform): add 9-point reference point anchor widget to options bar Add Photoshop's classic 3x3 reference point grid to the Free Transform options bar, enabling quick anchor point selection (corners, edge midpoints, and center). Snaps pivot using projective homography to maintain exact reference positioning across scale, skew, and perspective adjustments. --- crates/ui-egui/src/transform_tool.rs | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 062cdbdcce..5ab5c3c8ec 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1558,6 +1558,43 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods } } +fn anchor_uv(anchor: usize) -> (f64, f64) { + match anchor { + 0 => (0.0, 0.0), + 1 => (0.5, 0.0), + 2 => (1.0, 0.0), + 3 => (0.0, 0.5), + 4 => (0.5, 0.5), + 5 => (1.0, 0.5), + 6 => (0.0, 1.0), + 7 => (0.5, 1.0), + 8 => (1.0, 1.0), + _ => (0.5, 0.5), + } +} + +fn point_on_quad(quad: [[f64; 2]; 4], anchor: usize) -> [f64; 2] { + let (u, v) = anchor_uv(anchor); + if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], quad) { + let (x, y) = h.apply(u, v); + if x.is_finite() && y.is_finite() { + return [x, y]; + } + } + match anchor { + 0 => quad[0], + 1 => [(quad[0][0] + quad[1][0]) / 2.0, (quad[0][1] + quad[1][1]) / 2.0], + 2 => quad[1], + 3 => [(quad[0][0] + quad[3][0]) / 2.0, (quad[0][1] + quad[3][1]) / 2.0], + 4 => [(quad[0][0] + quad[1][0] + quad[2][0] + quad[3][0]) / 4.0, (quad[0][1] + quad[1][1] + quad[2][1] + quad[3][1]) / 4.0], + 5 => [(quad[1][0] + quad[2][0]) / 2.0, (quad[1][1] + quad[2][1]) / 2.0], + 6 => quad[3], + 7 => [(quad[2][0] + quad[3][0]) / 2.0, (quad[2][1] + quad[3][1]) / 2.0], + 8 => quad[2], + _ => [(quad[0][0] + quad[1][0] + quad[2][0] + quad[3][0]) / 4.0, (quad[0][1] + quad[1][1] + quad[2][1] + quad[3][1]) / 4.0], + } +} + /// Where the reference point lands when the box goes from `quad0` to `quad`: the same place /// within the box, as in Photoshop, where it is attached to the bounding box (#1329). A box /// that has collapsed leaves it where it was. @@ -2297,6 +2334,34 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes } } +fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: usize) -> Option { + let size = egui::vec2(16.0, 16.0); + let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::click()); + let tk = crate::theme::Tokens::get(ui.ctx()); + let painter = ui.painter(); + painter.rect_stroke(rect, 1.0, egui::Stroke::new(1.0, tk.card_border), egui::StrokeKind::Inside); + let mut clicked = None; + let pointer = resp.interact_pointer_pos(); + for row in 0..3 { + for col in 0..3 { + let idx = row * 3 + col; + let cx = rect.min.x + (col as f32 + 0.5) * (rect.width() / 3.0); + let cy = rect.min.y + (row as f32 + 0.5) * (rect.height() / 3.0); + let pt_rect = egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(3.5, 3.5)); + if resp.clicked() && pointer.is_some_and(|p| (p.x - cx).abs() <= 3.0 && (p.y - cy).abs() <= 3.0) { + clicked = Some(idx); + } + if idx == current_anchor { + painter.rect_filled(pt_rect, 0.5, tk.accent); + } else { + painter.rect_filled(pt_rect, 0.5, tk.text_dim); + } + } + } + resp.on_hover_text(tl!("Reference point location")); + clicked +} + /// The reference point's X/Y and the W/H scale fields (with the aspect link) of the options bar. pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) { let tk = crate::theme::Tokens::get(ui.ctx()); @@ -2304,6 +2369,15 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let lbl = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0)); }; + let anchor_id = egui::Id::new("transform-anchor"); + let current_anchor: usize = ui.data(|d| d.get_temp(anchor_id)).unwrap_or(4); + if let Some(new_anchor) = anchor_grid_widget(ui, current_anchor) { + ui.data_mut(|d| d.insert_temp(anchor_id, new_anchor)); + if let Some(s) = app.ui.transform.as_mut() { + s.pivot = point_on_quad(s.quad, new_anchor); + } + } + ui.add_space(4.0); let mut px = t.pivot[0] as f32; let mut py = t.pivot[1] as f32; lbl(ui, "X:"); @@ -4355,4 +4429,24 @@ mod tests { assert!(matches!(&st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Text(t) if t.warp.is_none())); assert_eq!(past(&app), before + 2); } + + #[test] + fn anchor_grid_point_on_quad_and_carry_pivot() { + let quad = [[0.0, 0.0], [100.0, 0.0], [100.0, 50.0], [0.0, 50.0]]; + assert_eq!(point_on_quad(quad, 0), [0.0, 0.0]); + assert_eq!(point_on_quad(quad, 1), [50.0, 0.0]); + assert_eq!(point_on_quad(quad, 2), [100.0, 0.0]); + assert_eq!(point_on_quad(quad, 3), [0.0, 25.0]); + assert_eq!(point_on_quad(quad, 4), [50.0, 25.0]); + assert_eq!(point_on_quad(quad, 5), [100.0, 25.0]); + assert_eq!(point_on_quad(quad, 6), [0.0, 50.0]); + assert_eq!(point_on_quad(quad, 7), [50.0, 50.0]); + assert_eq!(point_on_quad(quad, 8), [100.0, 50.0]); + + let quad_scaled = [[0.0, 0.0], [200.0, 0.0], [200.0, 100.0], [0.0, 100.0]]; + let p_top_right = carry_pivot(quad, quad_scaled, [100.0, 0.0]); + assert_eq!(p_top_right, [200.0, 0.0]); + let p_center = carry_pivot(quad, quad_scaled, [50.0, 25.0]); + assert_eq!(p_center, [100.0, 50.0]); + } } From 22f85209741738d0918a8f2f937331648b93a045 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:00:08 +0700 Subject: [PATCH 0027/1452] feat(layer-style): expose Bevel & Emboss highlight and shadow shading --- crates/engine/src/layer_style.rs | 46 ++++++++++++++++++++++++++++++- crates/ui-egui/src/layer_style.rs | 40 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index bf2d55a5a5..a4bcc8b94d 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -86,7 +86,9 @@ pub fn effect_defaults(kind: &str) -> Value { } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { - json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) + json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "highlightBlend": "Screen", "highlightColor": "#ffffff", "highlightOpacity": 75, + "shadowBlend": "Multiply", "shadowColor": "#000000", "shadowOpacity": 75, + "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false) } "satin" => { json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true}) @@ -559,6 +561,21 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { bv.use_global_light = x; } set_contour(p, "glossContour", &mut bv.gloss_contour); + // Photoshop exposes separate shading controls for each side of the bevel. + if let Some(blend) = p.get("highlightBlend").and_then(Value::as_str).and_then(blend_from_str) { + bv.highlight.blend = blend; + } + if let Some(blend) = p.get("shadowBlend").and_then(Value::as_str).and_then(blend_from_str) { + bv.shadow.blend = blend; + } + set_color(p, "highlightColor", &mut bv.highlight_color); + set_color(p, "shadowColor", &mut bv.shadow_color); + if let Some(x) = f_of(p, "highlightOpacity") { + bv.highlight.opacity = (x / 100.0).clamp(0.0, 1.0); + } + if let Some(x) = f_of(p, "shadowOpacity") { + bv.shadow.opacity = (x / 100.0).clamp(0.0, 1.0); + } // The Contour element: `contour` turns it on/off; its curve, Range and // Anti-aliased edit the element in place, so an imported curve survives. match b_of(p, "contour") { @@ -910,6 +927,33 @@ mod tests { assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true)); } + #[test] + fn bevel_shading_fields_are_independent_and_preserved_on_partial_edits() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({ + "highlightBlend": "Normal", "highlightColor": "#ff0000", "highlightOpacity": 35, + "shadowBlend": "Color Burn", "shadowColor": "#0000ff", "shadowOpacity": 60 + })).unwrap(); + let fx = effects(&s); + let Effect::BevelEmboss(bevel) = &fx[0] else { panic!("{fx:?}") }; + assert_eq!(bevel.highlight.blend, BlendMode::Normal); + assert_eq!(bevel.highlight_color, Color::rgb(1.0, 0.0, 0.0)); + assert!((bevel.highlight.opacity - 0.35).abs() < 0.001); + assert_eq!(bevel.shadow.blend, BlendMode::ColorBurn); + assert_eq!(bevel.shadow_color, Color::rgb(0.0, 0.0, 1.0)); + assert!((bevel.shadow.opacity - 0.60).abs() < 0.001); + let mut changed = fx[0].clone(); + overlay_effect(&mut changed, &json!({"size": 12})); + let Effect::BevelEmboss(partial) = &changed else { panic!("expected bevel") }; + assert_eq!(partial.highlight, bevel.highlight); + assert_eq!(partial.highlight_color, bevel.highlight_color); + assert_eq!(partial.shadow, bevel.shadow); + assert_eq!(partial.shadow_color, bevel.shadow_color); + overlay_effect(&mut changed, &json!({"highlightOpacity": 120, "shadowOpacity": -30})); + let Effect::BevelEmboss(limits) = &changed else { panic!("expected bevel") }; + assert_eq!((limits.highlight.opacity, limits.shadow.opacity), (1.0, 0.0)); + } + #[test] fn every_style_command_adds_its_effect() { for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 93f22c479b..5412d5c0ad 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -171,6 +171,12 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("useGlobalLight", "Use Global Light", P::Check), ("altitude", "Altitude", P::Slider(0.0, 90.0, "°")), ("glossContour", "Gloss Contour", P::Contour(None)), + ("highlightBlend", "Highlight Mode", P::Blend), + ("highlightColor", "Highlight Color", P::Color), + ("highlightOpacity", "Highlight Opacity", P::Slider(0.0, 100.0, "%")), + ("shadowBlend", "Shadow Mode", P::Blend), + ("shadowColor", "Shadow Color", P::Color), + ("shadowOpacity", "Shadow Opacity", P::Slider(0.0, 100.0, "%")), // Bevel & Emboss › Contour element. ("contour", "Contour", P::Check), ("contourName", "", P::Contour(Some("contourAntiAlias"))), @@ -350,6 +356,12 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "altitude", json!(b.altitude)); set(&mut v, "direction", json!(if b.up { "up" } else { "down" })); set(&mut v, "glossContour", photocraft_engine::layer_style::contour_param(&b.gloss_contour)); + set(&mut v, "highlightBlend", json!(b.highlight.blend.label())); + set(&mut v, "highlightColor", json!(hex(&b.highlight_color))); + set(&mut v, "highlightOpacity", json!((b.highlight.opacity * 100.0).round())); + set(&mut v, "shadowBlend", json!(b.shadow.blend.label())); + set(&mut v, "shadowColor", json!(hex(&b.shadow_color))); + set(&mut v, "shadowOpacity", json!((b.shadow.opacity * 100.0).round())); set(&mut v, "contour", json!(b.contour.is_some())); if let Some(c) = &b.contour { set(&mut v, "contourName", photocraft_engine::layer_style::contour_param(&c.contour)); @@ -1889,6 +1901,34 @@ mod tests { } } + #[test] + fn bevel_shading_round_trips_through_dialog_preview_and_confirm() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({ + "highlightBlend": "Normal", "highlightColor": "#e08020", "highlightOpacity": 25, + "shadowBlend": "Multiply", "shadowColor": "#204080", "shadowOpacity": 40 + })).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut fields = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + let values = &entry(&fields, "fx1").unwrap()["params"]; + assert_eq!(values["highlightColor"], "#e08020"); + assert_eq!(values["highlightOpacity"], 25.0); + assert_eq!(values["shadowColor"], "#204080"); + assert_eq!(values["shadowOpacity"], 40.0); + set_param(&mut fields, "fx1", "highlightBlend", json!("Screen")); + set_param(&mut fields, "fx1", "highlightOpacity", json!(80.0)); + set_param(&mut fields, "fx1", "shadowColor", json!("#ff0000")); + assert!(preview_document(&st.doc, &s.patterns, &fields).is_ok()); + apply(&fields, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let effect = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0]; + let Effect::BevelEmboss(bevel) = effect else { panic!("expected bevel") }; + assert_eq!(bevel.highlight.blend, photocraft_doc::BlendMode::Screen); + assert!((bevel.highlight.opacity - 0.8).abs() < 0.001); + assert_eq!(hex(&bevel.shadow_color), "#ff0000"); + assert_eq!(hex(&bevel.highlight_color), "#e08020"); + } + #[test] fn preview_off_shows_the_original_until_reenabled() { let mut s = session(); From cf7120bd4640f52d3a03705ba4ad6fe03d474fe4 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Mon, 12 Oct 2026 02:08:32 -0300 Subject: [PATCH 0028/1452] fix(ci): move zoom tests after canvas production code --- crates/ui-egui/src/canvas.rs | 136 +++++++++++++++++------------------ 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cf324e04c7..7ae81f787b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -941,74 +941,6 @@ fn animated_view(view: &mut View, xf: ViewXform, ppp: f32, time: f64) -> View { shown } -#[cfg(test)] -mod zoom_animation_tests { - use super::*; - - fn camera(view: &View) -> ViewXform { - ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)), zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 } - } - - fn view() -> View { - View { zoom: 1.0, center: [200.0, 150.0], doc_size: [400, 300], fit_pending: false, ..Default::default() } - } - - #[test] - fn zoom_animation_obeys_clamped_targets_and_eases_recentering() { - let mut v = view(); - let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); - let pointer = xf.to_screen(390.0, 150.0); - zoom_about(&mut v, &xf, pointer, 2.0, false, 1.0); - v.zoom_anchor = Some(([390.0, 150.0], [pointer.x, pointer.y])); - v.zoom_animating = true; - crate::scrollbars::clamp_view(&mut v, xf.rect.size(), 1.0); - let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); - assert!(v.zoom_anchor.is_none()); - let done = animated_view(&mut v, xf, 1.0, 0.22); - assert_eq!((done.zoom, done.center), (v.zoom, v.center)); - assert!(!v.zoom_animating); - - let mut v = view(); - let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); - zoom_about(&mut v, &xf, pointer, 2.0, true, 1.0); - v.zoom_animating = true; - let target = v.center; - let xf = camera(&v); - let start = animated_view(&mut v, xf, 1.0, 0.0); - assert_eq!(start.center, [200.0, 150.0], "recentering must not teleport"); - let halfway = animated_view(&mut v, xf, 1.0, 0.11); - assert!(halfway.center[0] > start.center[0] && halfway.center[0] < target[0]); - assert_eq!(animated_view(&mut v, xf, 1.0, 0.22).center, target); - } - - #[test] - fn zoom_animation_views_are_independent_and_direct_navigation_snaps() { - let mut a = view(); - let mut b = view(); - let xf = camera(&a); - animated_view(&mut a, xf, 1.0, 0.0); - animated_view(&mut b, xf, 1.0, 0.0); - a.zoom = 2.0; - a.zoom_animating = true; - animated_view(&mut a, xf, 1.0, 0.0); - b.zoom = 3.0; - b.zoom_animating = true; - animated_view(&mut b, xf, 1.0, 0.11); - assert_eq!(animated_view(&mut a, xf, 1.0, 0.22).zoom, 2.0); - let shown = animated_view(&mut b, xf, 1.0, 0.22); - assert!(shown.zoom > 1.0 && shown.zoom < 3.0); - assert!(b.zoom_animating && !a.zoom_animating); - b.zoom_animating = false; - b.center = [220.0, 170.0]; - let shown = animated_view(&mut b, xf, 1.0, 0.23); - assert_eq!((shown.zoom, shown.center), (b.zoom, b.center)); - assert!(b.zoom_transition.is_none() && b.zoom_anchor.is_none()); - } -} - /// Centre the document and zoom until it fills the canvas. One axis can extend beyond the /// viewport, matching the Hand tool's Fill Screen action. pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f32) { @@ -7070,3 +7002,71 @@ mod rotation_preview_isolation_tests { assert!(ensure_filter_preview(&mut app, 0, &egui::Context::default()).is_none(), "an inactive tab must never inherit the command preview"); } } + +#[cfg(test)] +mod zoom_animation_tests { + use super::*; + + fn camera(view: &View) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)), zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 } + } + + fn view() -> View { + View { zoom: 1.0, center: [200.0, 150.0], doc_size: [400, 300], fit_pending: false, ..Default::default() } + } + + #[test] + fn zoom_animation_obeys_clamped_targets_and_eases_recentering() { + let mut v = view(); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + let pointer = xf.to_screen(390.0, 150.0); + zoom_about(&mut v, &xf, pointer, 2.0, false, 1.0); + v.zoom_anchor = Some(([390.0, 150.0], [pointer.x, pointer.y])); + v.zoom_animating = true; + crate::scrollbars::clamp_view(&mut v, xf.rect.size(), 1.0); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + assert!(v.zoom_anchor.is_none()); + let done = animated_view(&mut v, xf, 1.0, 0.22); + assert_eq!((done.zoom, done.center), (v.zoom, v.center)); + assert!(!v.zoom_animating); + + let mut v = view(); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, 0.0); + zoom_about(&mut v, &xf, pointer, 2.0, true, 1.0); + v.zoom_animating = true; + let target = v.center; + let xf = camera(&v); + let start = animated_view(&mut v, xf, 1.0, 0.0); + assert_eq!(start.center, [200.0, 150.0], "recentering must not teleport"); + let halfway = animated_view(&mut v, xf, 1.0, 0.11); + assert!(halfway.center[0] > start.center[0] && halfway.center[0] < target[0]); + assert_eq!(animated_view(&mut v, xf, 1.0, 0.22).center, target); + } + + #[test] + fn zoom_animation_views_are_independent_and_direct_navigation_snaps() { + let mut a = view(); + let mut b = view(); + let xf = camera(&a); + animated_view(&mut a, xf, 1.0, 0.0); + animated_view(&mut b, xf, 1.0, 0.0); + a.zoom = 2.0; + a.zoom_animating = true; + animated_view(&mut a, xf, 1.0, 0.0); + b.zoom = 3.0; + b.zoom_animating = true; + animated_view(&mut b, xf, 1.0, 0.11); + assert_eq!(animated_view(&mut a, xf, 1.0, 0.22).zoom, 2.0); + let shown = animated_view(&mut b, xf, 1.0, 0.22); + assert!(shown.zoom > 1.0 && shown.zoom < 3.0); + assert!(b.zoom_animating && !a.zoom_animating); + b.zoom_animating = false; + b.center = [220.0, 170.0]; + let shown = animated_view(&mut b, xf, 1.0, 0.23); + assert_eq!((shown.zoom, shown.center), (b.zoom, b.center)); + assert!(b.zoom_transition.is_none() && b.zoom_anchor.is_none()); + } +} From a33b7929a040541bd6725f417d7a5cad1f40c98b Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:47 +0300 Subject: [PATCH 0029/1452] Remove shared main repairs from Smart Filters feature scope --- Cargo.toml | 5 ----- crates/automation/src/workspace.rs | 19 +------------------ crates/engine/src/adjust_params.rs | 13 ++++--------- docs/control-protocol.md | 2 +- 4 files changed, 6 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 36e608b71e..7502ff4fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,11 +100,6 @@ opt-level = "s" [profile.wasm-release.package.photocraft-ui-egui] opt-level = "z" -# Prefer compact command dispatch and parameter handling in the browser. Native profiles -# and the speed-oriented pixel-processing crates below keep their existing settings. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" - [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 400c0d9e15..d8e82503eb 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,8 +307,7 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - // Installed profiles scan OS and user directories even without a request path. - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -753,22 +752,6 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } - #[test] - fn installed_profile_discovery_requires_filesystem_authority() { - for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] - { - assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); - } - // Built-in profiles remain usable without reading installed profile files. - for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { - assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - } - } - #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 8bc99a40bc..00fb67dd03 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,12 +1240,9 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents. A 32-bit document evaluates the signed power curve beyond both - // ranges, retaining negative values and highlights above 1. The float expectations - // below were evaluated independently from the configured curve, not a clipped LUT. + // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for depth in [8, 16, 32] { - let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; + for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1253,10 +1250,8 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); - assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); - assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); + assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cf4a1159a3..87d1bbf65f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From 3ad6011bc502cd58d5acda6d75866a9f60cb054d Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:48 +0300 Subject: [PATCH 0030/1452] Remove shared main repairs from Layers Panel feature scope --- Cargo.toml | 5 ----- crates/automation/src/workspace.rs | 19 +------------------ crates/engine/src/adjust_params.rs | 13 ++++--------- docs/control-protocol.md | 2 +- 4 files changed, 6 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 36e608b71e..7502ff4fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,11 +100,6 @@ opt-level = "s" [profile.wasm-release.package.photocraft-ui-egui] opt-level = "z" -# Prefer compact command dispatch and parameter handling in the browser. Native profiles -# and the speed-oriented pixel-processing crates below keep their existing settings. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" - [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 400c0d9e15..d8e82503eb 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,8 +307,7 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - // Installed profiles scan OS and user directories even without a request path. - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -753,22 +752,6 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } - #[test] - fn installed_profile_discovery_requires_filesystem_authority() { - for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] - { - assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); - } - // Built-in profiles remain usable without reading installed profile files. - for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { - assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - } - } - #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 8bc99a40bc..00fb67dd03 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,12 +1240,9 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents. A 32-bit document evaluates the signed power curve beyond both - // ranges, retaining negative values and highlights above 1. The float expectations - // below were evaluated independently from the configured curve, not a clipped LUT. + // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for depth in [8, 16, 32] { - let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; + for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1253,10 +1250,8 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); - assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); - assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); + assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cf4a1159a3..87d1bbf65f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From 44c52a013ad710a64069070361960fa104ec76b0 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:17:55 +0300 Subject: [PATCH 0031/1452] Keep Layers Panel menu and options; extract sub-row scaling and fill masks --- crates/engine/src/commands.rs | 14 ++---- crates/engine/src/layer_multi_cmds.rs | 13 ----- crates/engine/src/pattern_cmds.rs | 13 ++--- crates/engine/src/pattern_fill_cmds.rs | 32 +----------- crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/i18n/de.tsv | 1 - crates/ui-egui/src/i18n/el.tsv | 1 - crates/ui-egui/src/i18n/es.tsv | 1 - crates/ui-egui/src/i18n/fr.tsv | 1 - crates/ui-egui/src/i18n/id.tsv | 1 - crates/ui-egui/src/i18n/it.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/ko.tsv | 1 - crates/ui-egui/src/i18n/nl.tsv | 1 - crates/ui-egui/src/i18n/pl.tsv | 1 - crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - crates/ui-egui/src/i18n/uk.tsv | 1 - crates/ui-egui/src/i18n/zh-hans.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 1 - crates/ui-egui/src/layers_panel_ui.rs | 23 --------- crates/ui-egui/src/lib.rs | 21 ++------ crates/ui-egui/src/panels.rs | 7 ++- crates/ui-egui/src/smart_ui.rs | 68 +++----------------------- crates/ui-egui/src/state.rs | 2 - 25 files changed, 23 insertions(+), 186 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ad78fe6dfa..9bb9ed37dd 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -282,7 +282,7 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -293,9 +293,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => { - l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; - } + None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?), } Ok(()) } @@ -309,7 +307,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); - mask_new_layer(doc, &mut l, p, cmd, true)?; + mask_new_layer(doc, &mut l, p, cmd)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -800,11 +798,10 @@ fn build() -> Vec { has_doc, |s, p| { let c = color_param(p, "color", s.tools.foreground); - let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -825,7 +822,6 @@ fn build() -> Vec { let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); - let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -836,7 +832,7 @@ fn build() -> Vec { ) .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 654c2c8ec4..f5fccfc3a3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2518,19 +2518,6 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&target)); } - #[test] - fn fill_layers_respect_use_default_masks_option() { - let (mut s, _) = new_session(); - option(&mut s, "useDefaultMasksOnFillLayers", false); - let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); - let id = LayerId(new["layer"].as_u64().unwrap()); - assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); - option(&mut s, "useDefaultMasksOnFillLayers", true); - let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); - let id = LayerId(new["layer"].as_u64().unwrap()); - assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); - } - #[test] fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { let (mut s, ids) = new_session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index a56590ad2b..21bee1283c 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -307,18 +307,12 @@ pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { /// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks, /// above `active`. The Pattern Fill dialog previews it on a copy of the document. -pub(crate) fn insert_fill_layer( - doc: &mut Document, - active: &mut Option, - pat: &Pattern, - p: &Value, - default_mask: bool, -) -> Result { +pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option, pat: &Pattern, p: &Value) -> Result { let (scale, angle, link, phase) = placement(p); ensure_in_doc(doc, pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); - crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -326,8 +320,7 @@ pub(crate) fn insert_fill_layer( fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; - let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); - let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?; + let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?; Ok(json!({"layer": id.0, "pattern": pat.id})) } diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index 36adffcf0c..daa7e9660f 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -124,8 +124,7 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { CREATE => { let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?; let mut active = st.active_layer; - let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); - crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?; + crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?; } other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }), } @@ -217,35 +216,6 @@ mod tests { assert!(preview(&s, "edit.fill", &json!({})).is_err()); } - #[test] - fn create_preview_and_commit_honor_default_masks_option() { - for depth in [8, 16, 32] { - for default_mask in [false, true] { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); - s.edit_prefs(|prefs| { - prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask})); - }); - let before = s.active().unwrap().doc.clone(); - let count = s.active().unwrap().history.entries().len(); - let p = json!({"pattern": "Grid", "scale": 50}); - let shown = preview(&s, CREATE, &p).unwrap(); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - assert_eq!(s.active().unwrap().history.entries().len(), count); - let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap(); - assert_eq!(previewed.mask.is_some(), default_mask); - let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap()); - let committed = s.active().unwrap().doc.layer(id).unwrap(); - assert_eq!(committed.mask, previewed.mask); - assert_eq!(committed.content, previewed.content); - s.undo(); - assert_eq!(*s.active().unwrap().doc, *before); - s.redo(); - assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask); - } - } - } - #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bc0113ebf7..bcb7e0070c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2594,7 +2594,6 @@ cameraData Lens Objektiv Show Layers Zobrazit vrstvy Thumbnail Contents Obsah miniatur Thumbnail Size Velikost miniatur - Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně Expand New Effects Rozbalovat nové efekty Add “copy” to Copied Layers and Groups Přidávat „kopie“ ke kopírovaným vrstvám a skupinám Preview on canvas when reordering layers Zobrazovat náhled na plátně při změně pořadí vrstev diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 73d817922c..fa5e8d5cbb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2624,7 +2624,6 @@ cameraData Lens Objektiv Show Layers Ebenen einblenden Thumbnail Contents Miniaturinhalt Thumbnail Size Miniaturgröße - Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden Expand New Effects Neue Effekte aufklappen Add “copy” to Copied Layers and Groups „Kopie“ zu kopierten Ebenen und Gruppen hinzufügen Preview on canvas when reordering layers Vorschau auf der Arbeitsfläche beim Neuanordnen von Ebenen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ed2d9ea23d..789a9c7a98 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3681,7 +3681,6 @@ cameraData Lens Φακός Show Layers Εμφάνιση επιπέδων Thumbnail Contents Περιεχόμενο μικρογραφιών Thumbnail Size Μέγεθος μικρογραφιών - Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος Expand New Effects Ανάπτυξη νέων εφέ Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7ea34ccc95..a729292a92 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2665,7 +2665,6 @@ cameraData Lens Objetivo Show Layers Mostrar capas Thumbnail Contents Contenido de miniaturas Thumbnail Size Tamaño de miniaturas - Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno Expand New Effects Expandir nuevos efectos Add “copy” to Copied Layers and Groups Añadir «copia» a las capas y grupos duplicados Preview on canvas when reordering layers Previsualizar en el lienzo al reordenar capas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 846ba14b0f..3ee30e25b6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3000,7 +3000,6 @@ cameraData Lens Objectif Show Layers Afficher les calques Thumbnail Contents Contenu des vignettes Thumbnail Size Taille des vignettes - Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage Expand New Effects Développer les nouveaux effets Add “copy” to Copied Layers and Groups Ajouter « copie » aux calques et groupes dupliqués Preview on canvas when reordering layers Prévisualiser sur la zone de travail lors du réagencement des calques diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ec089c2a36..f3b54c2437 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2987,7 +2987,6 @@ cameraData Lens Lensa Show Layers Tampilkan Lapisan Thumbnail Contents Isi Gambar Mini Thumbnail Size Ukuran Gambar Mini - Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian Expand New Effects Perluas Efek Baru Add “copy” to Copied Layers and Groups Tambahkan “salinan” ke Lapisan dan Grup yang Disalin Preview on canvas when reordering layers Pratinjau pada kanvas saat mengurutkan ulang lapisan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index afa0df18ff..57332dd07a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2656,7 +2656,6 @@ cameraData Lens Obiettivo Show Layers Mostra livelli Thumbnail Contents Contenuto miniature Thumbnail Size Dimensione miniature - Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento Expand New Effects Espandi nuovi effetti Add “copy” to Copied Layers and Groups Aggiungi «copia» ai livelli e ai gruppi duplicati Preview on canvas when reordering layers Anteprima sulla tela durante il riordino dei livelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 700f3919e9..88fa61f2e1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3595,7 +3595,6 @@ cameraData Lens レンズ Show Layers レイヤーを表示 Thumbnail Contents サムネールの内容 Thumbnail Size サムネールのサイズ - Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 Expand New Effects 新しい効果を展開 Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e8d6c3ffb8..e31501b8df 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3167,7 +3167,6 @@ cameraData Lens 렌즈 Show Layers 레이어 표시 Thumbnail Contents 축소판 내용 Thumbnail Size 축소판 크기 - Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 Expand New Effects 새 효과 펼치기 Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c3dd81b300..fe08dd0753 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3740,7 +3740,6 @@ cameraData Lens Lens Show Layers Lagen tonen Thumbnail Contents Inhoud van miniaturen Thumbnail Size Grootte van miniaturen - Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen Expand New Effects Nieuwe effecten uitvouwen Add “copy” to Copied Layers and Groups „kopie” toevoegen aan gekopieerde lagen en groepen Preview on canvas when reordering layers Voorvertoning op canvas bij herschikken van lagen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e273ba32bc..3bb0dbe81e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3597,7 +3597,6 @@ cameraData Lens Obiektyw Show Layers Pokaż warstwy Thumbnail Contents Zawartość miniaturek Thumbnail Size Rozmiar miniaturek - Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia Expand New Effects Rozwijaj nowe efekty Add “copy” to Copied Layers and Groups Dodawaj „kopia” do skopiowanych warstw i grup Preview on canvas when reordering layers Podgląd na obszarze roboczym podczas zmiany kolejności warstw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7365a389d7..25331f5f27 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3004,7 +3004,6 @@ cameraData Lens Lente Show Layers Mostrar camadas Thumbnail Contents Conteúdo das miniaturas Thumbnail Size Tamanho das miniaturas - Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento Expand New Effects Expandir novos efeitos Add “copy” to Copied Layers and Groups Adicionar “cópia” às camadas e grupos copiados Preview on canvas when reordering layers Pré-visualizar na tela ao reordenar camadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5a7abd5b4e..a36277b34a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2923,7 +2923,6 @@ cameraData Lens Объектив Show Layers Показать слои Thumbnail Contents Содержимое миниатюр Thumbnail Size Размер миниатюр - Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок Expand New Effects Раскрывать новые эффекты Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b55771d337..afb006e3bb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3671,7 +3671,6 @@ cameraData Lens Об'єктив Show Layers Показати шари Thumbnail Contents Вміст мініатюр Thumbnail Size Розмір мініатюр - Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки Expand New Effects Розгортати нові ефекти Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27b5e0de63..052f096fbc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2595,7 +2595,6 @@ cameraData Lens 镜头 Show Layers 显示图层 Thumbnail Contents 缩略图内容 Thumbnail Size 缩略图大小 - Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 Expand New Effects 展开新效果 Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” Preview on canvas when reordering layers 重新排列图层时在画布上预览 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b69bd18fd0..79a67f681e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2565,7 +2565,6 @@ cameraData Lens 鏡頭 Show Layers 顯示圖層 Thumbnail Contents 縮圖內容 Thumbnail Size 縮圖大小 - Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 Expand New Effects 展開新效果 Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index 181f60adf1..43c2df5c08 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -232,8 +232,6 @@ fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { ui.add_space(4.0); }); ui.add_space(9.0); - ui.checkbox(&mut draft.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); - ui.add_space(3.0); ui.checkbox(&mut draft.expand_new_effects, tl!("Expand New Effects")); ui.add_space(3.0); ui.checkbox(&mut draft.add_copy_to_copied_layers_and_groups, tl!("Add “copy” to Copied Layers and Groups")); @@ -344,7 +342,6 @@ mod tests { assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium); assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument); assert!(options.show_filters); - assert!(options.use_default_masks_on_fill_layers); assert!(options.expand_new_effects); assert!(options.add_copy_to_copied_layers_and_groups); assert!(options.preview_on_canvas_when_reordering_layers); @@ -384,25 +381,6 @@ mod tests { assert!(restored.layers_panel_dialog.is_none()); } - #[test] - fn disabling_default_fill_masks_never_targets_an_absent_mask() { - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); - let mut app = PhotocraftApp::new(session, crate::Services::default()); - app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; - persist_options(&mut app); - app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); - let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); - assert!(layer.mask.is_none()); - assert!(!app.ui.mask_target && !app.ui.vector_mask_target); - app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; - persist_options(&mut app); - app.run("layer.newFillLayer.gradient", json!({})).unwrap(); - let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); - assert!(layer.mask.is_some()); - assert!(app.ui.mask_target); - } - #[test] fn prefs_options_save_and_restore_all_switches() { let mut session = photocraft_engine::Session::new(); @@ -412,7 +390,6 @@ mod tests { thumbnail_size: LayerThumbnailSize::Large, thumbnail_contents: LayerThumbnailContents::LayerBounds, show_filters: false, - use_default_masks_on_fill_layers: false, expand_new_effects: false, add_copy_to_copied_layers_and_groups: false, preview_on_canvas_when_reordering_layers: false, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 08851883a9..8438551596 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -954,24 +954,13 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { - // Adding an explicit mask targets it, as in Photoshop (#2166). + if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { + // Adding a layer mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; - self.ui.properties_layer = None; - } else if r.is_ok() && creates_adjustment_or_fill { - // Panel Options may suppress a fill layer's default mask. Never target a - // nonexistent mask; an active path creates a vector mask instead. - let masks = self - .session - .active() - .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) - .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) - .unwrap_or((false, false)); - self.ui.mask_target = masks.0; - self.ui.vector_mask_target = !masks.0 && masks.1; - // Keep Properties on the new layer contents, as on main (#3369). - self.ui.properties_layer = self.session.active().and_then(|st| st.active_layer).map(|l| l.0); + // Properties still shows the new adjustment or fill layer's settings, not its mask, + // as in Photoshop (#3369): clicking the mask thumbnail switches it to the mask. + self.ui.properties_layer = if creates_adjustment_or_fill { self.session.active().and_then(|st| st.active_layer).map(|l| l.0) } else { None }; } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 483539ae44..87336ce045 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3859,7 +3859,6 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); - let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3867,7 +3866,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3884,7 +3883,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3906,7 +3905,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(font_size), + egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 89b70e927b..99deaa60fe 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,11 +1,10 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; -use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -33,18 +32,6 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } -/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column -/// remains independently clickable, while the filter mask and text keep their -/// own alignment and avoid overlap when the thumbnail is set to None. -pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { - let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); - let height = row.max(22.0); - let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; - let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; - let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; - (height, mask, eye, font) -} - /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -53,11 +40,10 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); - let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -75,7 +61,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -84,24 +70,12 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: }); } let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() && mask_size > 0.0 { - // The Smart Filters mask scales along with the thumbnail size, not - // independently of the selected Layers panel display preference. - let sw = Rect::from_center_size(pos2(x + mask_size / 2.0, rect.center().y), vec2(mask_size, mask_size)); + if index.is_none() { + let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - let text_x = if index.is_none() && mask_size > 0.0 { x + mask_size + 7.0 } else { x }; - crate::layer_row_ui::label( - ui.painter(), - text_x, - rect.center().y, - rect.right() - crate::layer_row_ui::RIGHT_PAD, - &name, - egui::FontId::proportional(font_size), - if on { t.text_dim } else { t.text_faint }, - ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); if let Some(i) = index && resp.double_clicked() { @@ -147,33 +121,3 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } - -#[cfg(test)] -mod layer_thumbnail_scaling_tests { - use super::*; - use egui_kittest::{Harness, kittest::Queryable}; - - #[test] - fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { - for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); - session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); - session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); - session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); - session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); - assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); - let mut h = Harness::builder().with_size(vec2(1440.0, 950.0)).with_max_steps(64).build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - PhotocraftApp::new(session, crate::Services::default()) - }); - h.state_mut().ui.layers_panel_options.thumbnail_size = setting; - h.run_steps(8); - let expected = smart_filter_row_geometry(setting, true).0; - for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask"] { - let rect = h.get_by_label(label).rect(); - assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); - } - } - } -} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9a257c7290..4fb519ef64 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -28,7 +28,6 @@ pub struct LayersPanelOptions { pub thumbnail_size: LayerThumbnailSize, pub thumbnail_contents: LayerThumbnailContents, pub show_filters: bool, - pub use_default_masks_on_fill_layers: bool, pub expand_new_effects: bool, pub add_copy_to_copied_layers_and_groups: bool, pub preview_on_canvas_when_reordering_layers: bool, @@ -41,7 +40,6 @@ impl LayersPanelOptions { thumbnail_size: LayerThumbnailSize::Medium, thumbnail_contents: LayerThumbnailContents::EntireDocument, show_filters: true, - use_default_masks_on_fill_layers: true, expand_new_effects: true, add_copy_to_copied_layers_and_groups: true, preview_on_canvas_when_reordering_layers: true, From 475a0745d8f1aeb0f097fe4d9e31224f2b5c75d2 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:19:25 +0300 Subject: [PATCH 0032/1452] Scale Smart Filter and effect rows with Layers thumbnails --- crates/ui-egui/src/panels.rs | 7 ++-- crates/ui-egui/src/smart_ui.rs | 69 +++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 87336ce045..483539ae44 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3859,6 +3859,7 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); + let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3866,7 +3867,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3883,7 +3884,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3905,7 +3906,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(11.5), + egui::FontId::proportional(font_size), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..a563853df9 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,10 +1,11 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; +use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -32,6 +33,18 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } +/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column +/// remains independently clickable, while the filter mask and text keep their +/// own alignment and avoid overlap when the thumbnail is set to None. +pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { + let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); + let height = row.max(22.0); + let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; + let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; + let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; + (height, mask, eye, font) +} + /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -40,10 +53,11 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); + let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -61,7 +75,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -70,12 +84,24 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: }); } let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() { - let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); + if index.is_none() && mask_size > 0.0 { + // The Smart Filters mask scales along with the thumbnail size, not + // independently of the selected Layers panel display preference. + let sw = Rect::from_center_size(pos2(x + mask_size / 2.0, rect.center().y), vec2(mask_size, mask_size)); ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + let text_x = if index.is_none() && mask_size > 0.0 { x + mask_size + 7.0 } else { x }; + crate::layer_row_ui::label( + ui.painter(), + text_x, + rect.center().y, + rect.right() - crate::layer_row_ui::RIGHT_PAD, + &name, + egui::FontId::proportional(font_size), + if on { t.text_dim } else { t.text_faint }, + ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if let Some(i) = index && resp.double_clicked() { @@ -121,3 +147,34 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod layer_thumbnail_scaling_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { + for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); + session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); + session.execute("layer.layerStyle.dropShadow", json!({"size": 2})).unwrap(); + assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); + let mut h = Harness::builder().with_size(vec2(1440.0, 1400.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.state_mut().ui.layers_panel_options.thumbnail_size = setting; + h.run_steps(8); + let expected = smart_filter_row_geometry(setting, true).0; + for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask", "Effects", "Drop Shadow"] { + let rect = h.get_by_label(label).rect(); + assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); + } + } + } +} From e1b2d499738b87f7b94d1f4ed9a8e775435b3a2b Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:19:25 +0300 Subject: [PATCH 0033/1452] Honor default fill masks consistently in creation and Pattern Fill preview --- crates/engine/src/commands.rs | 14 +++++++---- crates/engine/src/layer_multi_cmds.rs | 13 ++++++++++ crates/engine/src/pattern_cmds.rs | 13 +++++++--- crates/engine/src/pattern_fill_cmds.rs | 35 +++++++++++++++++++++++++- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/layers_panel_ui.rs | 23 +++++++++++++++++ crates/ui-egui/src/lib.rs | 21 ++++++++++++---- crates/ui-egui/src/state.rs | 2 ++ 23 files changed, 123 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9bb9ed37dd..ad78fe6dfa 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -282,7 +282,7 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -293,7 +293,9 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?), + None => { + l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; + } } Ok(()) } @@ -307,7 +309,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); - mask_new_layer(doc, &mut l, p, cmd)?; + mask_new_layer(doc, &mut l, p, cmd, true)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -798,10 +800,11 @@ fn build() -> Vec { has_doc, |s, p| { let c = color_param(p, "color", s.tools.foreground); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -822,6 +825,7 @@ fn build() -> Vec { let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -832,7 +836,7 @@ fn build() -> Vec { ) .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index f5fccfc3a3..654c2c8ec4 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2518,6 +2518,19 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&target)); } + #[test] + fn fill_layers_respect_use_default_masks_option() { + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); + option(&mut s, "useDefaultMasksOnFillLayers", true); + let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); + } + #[test] fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { let (mut s, ids) = new_session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 21bee1283c..a56590ad2b 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -307,12 +307,18 @@ pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { /// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks, /// above `active`. The Pattern Fill dialog previews it on a copy of the document. -pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option, pat: &Pattern, p: &Value) -> Result { +pub(crate) fn insert_fill_layer( + doc: &mut Document, + active: &mut Option, + pat: &Pattern, + p: &Value, + default_mask: bool, +) -> Result { let (scale, angle, link, phase) = placement(p); ensure_in_doc(doc, pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); - crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -320,7 +326,8 @@ pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; - let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?; + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?; Ok(json!({"layer": id.0, "pattern": pat.id})) } diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index daa7e9660f..7797de8846 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -124,7 +124,8 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { CREATE => { let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?; let mut active = st.active_layer; - crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?; + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?; } other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }), } @@ -216,6 +217,38 @@ mod tests { assert!(preview(&s, "edit.fill", &json!({})).is_err()); } + #[test] + fn create_preview_and_commit_honor_default_masks_option() { + for depth in [8, 16, 32] { + for default_mask in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.edit_prefs(|prefs| { + prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask})); + }); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.entries().len(); + let p = json!({"pattern": "Grid", "scale": 50}); + let shown = preview(&s, CREATE, &p).unwrap(); + assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); + assert_eq!(s.active().unwrap().history.entries().len(), count); + let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap(); + assert_eq!(previewed.mask.is_some(), default_mask); + if let Some(mask) = &previewed.mask { + assert_eq!(mask.surface.format(), photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, shown.depth, false)); + } + let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap()); + let committed = s.active().unwrap().doc.layer(id).unwrap(); + assert_eq!(committed.mask, previewed.mask); + assert_eq!(committed.content, previewed.content); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask); + } + } + } + #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bcb7e0070c..bc0113ebf7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2594,6 +2594,7 @@ cameraData Lens Objektiv Show Layers Zobrazit vrstvy Thumbnail Contents Obsah miniatur Thumbnail Size Velikost miniatur + Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně Expand New Effects Rozbalovat nové efekty Add “copy” to Copied Layers and Groups Přidávat „kopie“ ke kopírovaným vrstvám a skupinám Preview on canvas when reordering layers Zobrazovat náhled na plátně při změně pořadí vrstev diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fa5e8d5cbb..73d817922c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2624,6 +2624,7 @@ cameraData Lens Objektiv Show Layers Ebenen einblenden Thumbnail Contents Miniaturinhalt Thumbnail Size Miniaturgröße + Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden Expand New Effects Neue Effekte aufklappen Add “copy” to Copied Layers and Groups „Kopie“ zu kopierten Ebenen und Gruppen hinzufügen Preview on canvas when reordering layers Vorschau auf der Arbeitsfläche beim Neuanordnen von Ebenen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 789a9c7a98..ed2d9ea23d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3681,6 +3681,7 @@ cameraData Lens Φακός Show Layers Εμφάνιση επιπέδων Thumbnail Contents Περιεχόμενο μικρογραφιών Thumbnail Size Μέγεθος μικρογραφιών + Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος Expand New Effects Ανάπτυξη νέων εφέ Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a729292a92..7ea34ccc95 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2665,6 +2665,7 @@ cameraData Lens Objetivo Show Layers Mostrar capas Thumbnail Contents Contenido de miniaturas Thumbnail Size Tamaño de miniaturas + Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno Expand New Effects Expandir nuevos efectos Add “copy” to Copied Layers and Groups Añadir «copia» a las capas y grupos duplicados Preview on canvas when reordering layers Previsualizar en el lienzo al reordenar capas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3ee30e25b6..846ba14b0f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3000,6 +3000,7 @@ cameraData Lens Objectif Show Layers Afficher les calques Thumbnail Contents Contenu des vignettes Thumbnail Size Taille des vignettes + Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage Expand New Effects Développer les nouveaux effets Add “copy” to Copied Layers and Groups Ajouter « copie » aux calques et groupes dupliqués Preview on canvas when reordering layers Prévisualiser sur la zone de travail lors du réagencement des calques diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f3b54c2437..ec089c2a36 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2987,6 +2987,7 @@ cameraData Lens Lensa Show Layers Tampilkan Lapisan Thumbnail Contents Isi Gambar Mini Thumbnail Size Ukuran Gambar Mini + Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian Expand New Effects Perluas Efek Baru Add “copy” to Copied Layers and Groups Tambahkan “salinan” ke Lapisan dan Grup yang Disalin Preview on canvas when reordering layers Pratinjau pada kanvas saat mengurutkan ulang lapisan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57332dd07a..afa0df18ff 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2656,6 +2656,7 @@ cameraData Lens Obiettivo Show Layers Mostra livelli Thumbnail Contents Contenuto miniature Thumbnail Size Dimensione miniature + Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento Expand New Effects Espandi nuovi effetti Add “copy” to Copied Layers and Groups Aggiungi «copia» ai livelli e ai gruppi duplicati Preview on canvas when reordering layers Anteprima sulla tela durante il riordino dei livelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 88fa61f2e1..700f3919e9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3595,6 +3595,7 @@ cameraData Lens レンズ Show Layers レイヤーを表示 Thumbnail Contents サムネールの内容 Thumbnail Size サムネールのサイズ + Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 Expand New Effects 新しい効果を展開 Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e31501b8df..e8d6c3ffb8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3167,6 +3167,7 @@ cameraData Lens 렌즈 Show Layers 레이어 표시 Thumbnail Contents 축소판 내용 Thumbnail Size 축소판 크기 + Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 Expand New Effects 새 효과 펼치기 Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fe08dd0753..c3dd81b300 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3740,6 +3740,7 @@ cameraData Lens Lens Show Layers Lagen tonen Thumbnail Contents Inhoud van miniaturen Thumbnail Size Grootte van miniaturen + Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen Expand New Effects Nieuwe effecten uitvouwen Add “copy” to Copied Layers and Groups „kopie” toevoegen aan gekopieerde lagen en groepen Preview on canvas when reordering layers Voorvertoning op canvas bij herschikken van lagen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3bb0dbe81e..e273ba32bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3597,6 +3597,7 @@ cameraData Lens Obiektyw Show Layers Pokaż warstwy Thumbnail Contents Zawartość miniaturek Thumbnail Size Rozmiar miniaturek + Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia Expand New Effects Rozwijaj nowe efekty Add “copy” to Copied Layers and Groups Dodawaj „kopia” do skopiowanych warstw i grup Preview on canvas when reordering layers Podgląd na obszarze roboczym podczas zmiany kolejności warstw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 25331f5f27..7365a389d7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3004,6 +3004,7 @@ cameraData Lens Lente Show Layers Mostrar camadas Thumbnail Contents Conteúdo das miniaturas Thumbnail Size Tamanho das miniaturas + Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento Expand New Effects Expandir novos efeitos Add “copy” to Copied Layers and Groups Adicionar “cópia” às camadas e grupos copiados Preview on canvas when reordering layers Pré-visualizar na tela ao reordenar camadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a36277b34a..5a7abd5b4e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2923,6 +2923,7 @@ cameraData Lens Объектив Show Layers Показать слои Thumbnail Contents Содержимое миниатюр Thumbnail Size Размер миниатюр + Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок Expand New Effects Раскрывать новые эффекты Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index afb006e3bb..b55771d337 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3671,6 +3671,7 @@ cameraData Lens Об'єктив Show Layers Показати шари Thumbnail Contents Вміст мініатюр Thumbnail Size Розмір мініатюр + Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки Expand New Effects Розгортати нові ефекти Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 052f096fbc..27b5e0de63 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2595,6 +2595,7 @@ cameraData Lens 镜头 Show Layers 显示图层 Thumbnail Contents 缩略图内容 Thumbnail Size 缩略图大小 + Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 Expand New Effects 展开新效果 Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” Preview on canvas when reordering layers 重新排列图层时在画布上预览 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 79a67f681e..b69bd18fd0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2565,6 +2565,7 @@ cameraData Lens 鏡頭 Show Layers 顯示圖層 Thumbnail Contents 縮圖內容 Thumbnail Size 縮圖大小 + Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 Expand New Effects 展開新效果 Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index 43c2df5c08..181f60adf1 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -232,6 +232,8 @@ fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { ui.add_space(4.0); }); ui.add_space(9.0); + ui.checkbox(&mut draft.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); + ui.add_space(3.0); ui.checkbox(&mut draft.expand_new_effects, tl!("Expand New Effects")); ui.add_space(3.0); ui.checkbox(&mut draft.add_copy_to_copied_layers_and_groups, tl!("Add “copy” to Copied Layers and Groups")); @@ -342,6 +344,7 @@ mod tests { assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium); assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument); assert!(options.show_filters); + assert!(options.use_default_masks_on_fill_layers); assert!(options.expand_new_effects); assert!(options.add_copy_to_copied_layers_and_groups); assert!(options.preview_on_canvas_when_reordering_layers); @@ -381,6 +384,25 @@ mod tests { assert!(restored.layers_panel_dialog.is_none()); } + #[test] + fn disabling_default_fill_masks_never_targets_an_absent_mask() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; + persist_options(&mut app); + app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_none()); + assert!(!app.ui.mask_target && !app.ui.vector_mask_target); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; + persist_options(&mut app); + app.run("layer.newFillLayer.gradient", json!({})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_some()); + assert!(app.ui.mask_target); + } + #[test] fn prefs_options_save_and_restore_all_switches() { let mut session = photocraft_engine::Session::new(); @@ -390,6 +412,7 @@ mod tests { thumbnail_size: LayerThumbnailSize::Large, thumbnail_contents: LayerThumbnailContents::LayerBounds, show_filters: false, + use_default_masks_on_fill_layers: false, expand_new_effects: false, add_copy_to_copied_layers_and_groups: false, preview_on_canvas_when_reordering_layers: false, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8438551596..08851883a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -954,13 +954,24 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { - // Adding a layer mask targets it, as in Photoshop (#2166). + if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { + // Adding an explicit mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; - // Properties still shows the new adjustment or fill layer's settings, not its mask, - // as in Photoshop (#3369): clicking the mask thumbnail switches it to the mask. - self.ui.properties_layer = if creates_adjustment_or_fill { self.session.active().and_then(|st| st.active_layer).map(|l| l.0) } else { None }; + self.ui.properties_layer = None; + } else if r.is_ok() && creates_adjustment_or_fill { + // Panel Options may suppress a fill layer's default mask. Never target a + // nonexistent mask; an active path creates a vector mask instead. + let masks = self + .session + .active() + .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) + .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) + .unwrap_or((false, false)); + self.ui.mask_target = masks.0; + self.ui.vector_mask_target = !masks.0 && masks.1; + // Keep Properties on the new layer contents, as on main (#3369). + self.ui.properties_layer = self.session.active().and_then(|st| st.active_layer).map(|l| l.0); } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4fb519ef64..9a257c7290 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -28,6 +28,7 @@ pub struct LayersPanelOptions { pub thumbnail_size: LayerThumbnailSize, pub thumbnail_contents: LayerThumbnailContents, pub show_filters: bool, + pub use_default_masks_on_fill_layers: bool, pub expand_new_effects: bool, pub add_copy_to_copied_layers_and_groups: bool, pub preview_on_canvas_when_reordering_layers: bool, @@ -40,6 +41,7 @@ impl LayersPanelOptions { thumbnail_size: LayerThumbnailSize::Medium, thumbnail_contents: LayerThumbnailContents::EntireDocument, show_filters: true, + use_default_masks_on_fill_layers: true, expand_new_effects: true, add_copy_to_copied_layers_and_groups: true, preview_on_canvas_when_reordering_layers: true, From cfe712c34017198bdbb0cedd4fa1c447da53da43 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:22:30 +0300 Subject: [PATCH 0034/1452] Apply panel copy-name setting to the new artboard duplication path --- crates/engine/src/artboard_cmds.rs | 3 ++- crates/engine/src/artboard_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..7b7fb8ea90 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -424,6 +424,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result { }; let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32; let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" }; + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (nid, rect) = s.edit(label, |doc, active| { if (sx, sy) != (0, 0) { crate::image_cmds::translate_doc(doc, cmd, sx, sy)?; @@ -438,7 +439,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result { let nid = if duplicate { let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?; let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0)); - let copy = crate::commands::layer_copy(doc, id)?; + let copy = crate::commands::layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), copy); // Grow the canvas first, so shapes on the board re-render uncut where they land. let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32; diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index 1faf4cb75d..dc48dd3669 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -585,3 +585,23 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() { assert_eq!(doc(&s).size, before); assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err()); } + +#[test] +fn add_beside_duplicate_honors_panel_copy_name_preference() { + for add_copy in [false, true] { + let mut s = session(8); + let id = board_with_square(&mut s); + let name = doc(&s).layer(id).unwrap().name.clone(); + s.edit_prefs(|prefs| { + prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"addCopyToCopiedLayersAndGroups": add_copy})); + }); + let before = doc(&s).clone(); + let result = s.execute("layer.artboard.addBeside", json!({"layer": id.0, "side": "bottom", "duplicate": true})).unwrap(); + let copy = LayerId(result["layer"].as_u64().unwrap()); + let expected = if add_copy { before.copy_name(&name) } else { name }; + assert_eq!(doc(&s).layer(copy).unwrap().name, expected); + assert!(red_at(&s, 15, 155), "the copied artboard retains its contents"); + s.undo(); + assert_eq!(doc(&s), &before); + } +} From a36b365fa55dcb674159fce7f6f3bd2c2d44f776 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:59:13 +0800 Subject: [PATCH 0035/1452] scorecard: FILE-216-6 note names all three task files (36 MCP tasks) --- docs/scorecard.md | 4 ++-- scorecard/file_compat.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 0fa742fae0..c1cb82e1ca 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 12 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | @@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | | FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | -| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | done | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/{agent_tasks,agent_tasks_extended,agent_tasks_more}.rs: 36 MCP tasks | +| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks | ## Tools diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 090a0b85bc..8dfd07fc9d 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -190,6 +190,6 @@ note = "Every save path that exists is tested by saving and reading back: layere [[item]] id = "FILE-216-6" target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands" -status = "done" +status = "partial" issue = 216 -note = "automation/tests/{agent_tasks,agent_tasks_extended,agent_tasks_more}.rs: 36 MCP tasks" +note = "automation/tests/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks" From 5cd134f3539c8e737fef0b9ab199d070ea82448e Mon Sep 17 00:00:00 2001 From: AlastorIRL <100581152+AlastorIRL@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:00:13 -0300 Subject: [PATCH 0036/1452] fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback --- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/timeline_ui.rs | 79 ++++++++++++++++++++++++++- crates/ui-egui/src/view_cmds.rs | 32 +++++++++++ crates/ui-egui/src/view_cmds/tests.rs | 25 +++++++++ 8 files changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6836a381dc..1a944c9a20 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2687,3 +2687,4 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Stop Stopp diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9cfe73f89..7f8d9bc032 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2728,3 +2728,4 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Stop Detener diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 62495a8d44..cd7ff45cf1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3217,3 +3217,4 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Stop 정지 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 187f63e18d..e01b9a3d48 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2652,3 +2652,4 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Stop 停止 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3e0f8902cd..8c2a27efab 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2637,3 +2637,4 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Stop 停止 diff --git a/crates/ui-egui/src/timeline_ui.rs b/crates/ui-egui/src/timeline_ui.rs index 22d8408abe..282f88e5de 100644 --- a/crates/ui-egui/src/timeline_ui.rs +++ b/crates/ui-egui/src/timeline_ui.rs @@ -49,6 +49,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut act: Option<(&str, Value)> = None; let mut close = false; let mut toggle_play = false; + let mut stop_play = false; let playing = app.ui.timeline.playing; let t = Tokens::get(ctx); @@ -84,7 +85,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::icons::button(ui, play_icon, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() { toggle_play = true; } + if crate::icons::button(ui, "square", 22.0, false, tl!("Stop")).clicked() { + if !playing { + act = Some(("timeline.setFrame", json!({ "frame": 0 }))); + } + stop_play = true; + } if crate::icons::button(ui, "chevron-left", 22.0, false, tl!("Previous frame")).clicked() { + stop_play = true; act = Some(("timeline.previousFrame", json!({}))); } ui.label( @@ -96,12 +104,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { .monospace(), ); if crate::icons::button(ui, "chevron-right", 22.0, false, tl!("Next frame")).clicked() { + stop_play = true; act = Some(("timeline.nextFrame", json!({}))); } ui.separator(); ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::icons::button(ui, "trash", 20.0, false, tl!("Delete Timeline")).clicked() { + stop_play = true; act = Some(("timeline.delete", json!({}))); } ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim)); @@ -113,6 +123,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut frame = tl.current as f64; let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead")); if resp.changed() { + stop_play = true; act = Some(("timeline.setFrame", json!({ "frame": frame as u64 }))); } } @@ -124,7 +135,9 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { app.ui.timeline.open = false; app.ui.timeline.playing = false; } - if toggle_play { + if stop_play { + app.ui.timeline.playing = false; + } else if toggle_play { app.ui.timeline.playing = !app.ui.timeline.playing; app.ui.timeline.last_time = ctx.input(|i| i.time); } @@ -147,3 +160,67 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn stop_step_and_scrub_pause_playback_and_second_stop_rewinds() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("timeline.create", json!({"duration": 10, "fps": 30.0})).unwrap(); + app.ui.timeline.open = true; + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0)); + + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.001).build_ui_state(|ui, app| windows(app, ui.ctx()), app); + h.run_steps(2); + + // Start playback via Play button. + h.get_by_label("Play").click(); + h.run_steps(2); + assert!(h.state().ui.timeline.playing); + + // Move to frame 5 while playing; first Stop pauses at the current frame without rewinding. + h.state_mut().run("timeline.setFrame", json!({"frame": 5})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "first Stop pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 5); + + // Second Stop while already paused rewinds to frame 0. + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0); + + // Next frame and Previous frame pause playback. + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + h.get_by_label("Next frame").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "Next frame pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 1); + + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + h.get_by_label("Previous frame").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "Previous frame pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0); + + // Scrubbing the playhead slider pauses playback and updates the current frame. + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let target = egui::pos2(slider.right() - 8.0, slider.center().y); + h.event(egui::Event::PointerMoved(target)); + h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "scrubbing pauses playback"); + assert!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current > 0); + } +} diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f0404633cf..cba9978761 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -461,6 +461,11 @@ fn wraps(id: &str) -> bool { | "image.calculations" | "select.saveSelection" | "select.loadSelection" + | "layer.videoLayers.newVideoLayerFromFile" + | "layer.videoLayers.replaceFootage" + | "layer.videoLayers.interpretFootage" + | "file.import.videoFramesToLayers" + | "file.export.renderVideo" ) } @@ -1033,6 +1038,33 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { + let doc = match app.active_doc_id() { + Ok(doc) => doc, + Err(e) => return Some(Err(e)), + }; + let cmd = id.to_string(); + Some(app.pick_file_bytes(move |app, path, _bytes| { + app.with_document(doc, |app| { + let r = app.run(&cmd, json!({"path": path})); + if r.is_ok() && app.session.active().is_some_and(|d| d.doc.timeline.is_some()) { + app.ui.timeline.open = true; + } + app.sync_views(); + r + }) + })) + } + "layer.videoLayers.interpretFootage" => { + let st = app.session.active()?; + let id_layer = st.active_layer?; + let fps = st.doc.layer(id_layer).and_then(|l| l.video.as_ref()).map_or(30.0, |v| v.fps); + dialog(app, json!({"fps": fps}), json!({})) + } + "file.export.renderVideo" => { + let _ = doc?; + dialog(app, json!({"dir": dir, "format": "gif"}), json!({"format": ["gif", "png", "jpg", "webp", "bmp", "tiff"]})) + } "file.fileInfo" => { let info = app.session.execute("file.fileInfo", json!({})).ok()?; let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 34c93ccc25..b073ed8db3 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -369,3 +369,28 @@ fn load_selection_without_alpha_channels_offers_the_composite() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); crate::dialogs::confirm(&mut app, dialog).unwrap(); } + +#[test] +fn video_menu_commands_open_pickers_and_dialogs() { + let (mut app, ctx) = app_with(1); + app.services.file_dialog = Some(Box::new(|_, _, _| {})); + + for id in ["layer.videoLayers.newVideoLayerFromFile", "file.import.videoFramesToLayers", "layer.videoLayers.replaceFootage"] { + assert!(is_live(id), "{id}"); + assert_eq!(menu(&mut app, &ctx, id, json!({})).unwrap(), json!({"fileDialog": "open"}), "{id}"); + app.file_dialog = None; + } + + let interpret = menu(&mut app, &ctx, "layer.videoLayers.interpretFootage", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(interpret).unwrap().fields; + assert_eq!(fields["__command"], "layer.videoLayers.interpretFootage"); + assert_eq!(fields["fps"], 30.0); + app.ui.close_dialog(interpret); + + let render = menu(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(render).unwrap().fields; + assert_eq!(fields["__command"], "file.export.renderVideo"); + assert_eq!(fields["format"], "gif"); + assert_eq!(fields["__choices"]["format"], json!(["gif", "png", "jpg", "webp", "bmp", "tiff"])); + app.ui.close_dialog(render); +} From 52417730228001bba3d382b2dda289ae37a0b62b Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 13:27:24 +0200 Subject: [PATCH 0037/1452] Merge fixups: Vietnamese History panel menu labels main added a complete Vietnamese catalog; the panel menu's new labels need rows there too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/vi.tsv | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6fb20b3d84..0c641a909a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3531,3 +3531,9 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + +# History panel menu (#2966) + Step Forward Tiến một bước + New Snapshot… Ảnh chụp trạng thái mới… + Clear History Xóa lịch sử + History Options… Tùy chọn lịch sử… From 684f211a72ebacb90e93d900a4be742fb5183913 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 14:10:23 +0200 Subject: [PATCH 0038/1452] Merge fixups: translate the History Brush source strings i18n::tests::every_tl_literal_is_translated needs every tl! string in the complete catalogs: the options bar hint and the History panel source column tooltip get rows in all 17. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/cs.tsv | 4 ++++ crates/ui-egui/src/i18n/de.tsv | 4 ++++ crates/ui-egui/src/i18n/el.tsv | 4 ++++ crates/ui-egui/src/i18n/es.tsv | 4 ++++ crates/ui-egui/src/i18n/fr.tsv | 4 ++++ crates/ui-egui/src/i18n/id.tsv | 4 ++++ crates/ui-egui/src/i18n/it.tsv | 4 ++++ crates/ui-egui/src/i18n/ja.tsv | 4 ++++ crates/ui-egui/src/i18n/ko.tsv | 4 ++++ crates/ui-egui/src/i18n/nl.tsv | 4 ++++ crates/ui-egui/src/i18n/pl.tsv | 4 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++++ crates/ui-egui/src/i18n/ru.tsv | 4 ++++ crates/ui-egui/src/i18n/uk.tsv | 4 ++++ crates/ui-egui/src/i18n/vi.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++++ 17 files changed, 68 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bb7f34d11d..65ee091d40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3661,3 +3661,7 @@ navigator Zoom Zvětšení Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maluje ze zdrojového stavu panelu Historie + Sets the source for the history brush Nastaví zdroj pro štětec historie diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6836a381dc..9fb3f63fa5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2687,3 +2687,7 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Malt aus dem Quellzustand des Protokollbedienfelds + Sets the source for the history brush Legt die Quelle für den Protokollpinsel fest diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e994a26bd5..219d4e06c2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3745,3 +3745,7 @@ curves Smooth Εξομάλυνση Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε Light Gray Ανοιχτό γκρι + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Βάφει από την κατάσταση πηγής του πίνακα Ιστορικό + Sets the source for the history brush Ορίζει την πηγή για το πινέλο ιστορικού diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9cfe73f89..78ee3aa3d6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2728,3 +2728,7 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Pinta a partir del estado de origen del panel Historia + Sets the source for the history brush Establece el origen del pincel de historia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 90c075c426..1dbd93fe06 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3048,3 +3048,7 @@ curves Smooth Lisser Light Red Rouge clair Medium Blue Bleu moyen Light Gray Gris clair + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Peint à partir de l'état source du panneau Historique + Sets the source for the history brush Définit la source du pinceau d'historique diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4ef02d9ecc..4d3da2407c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3035,3 +3035,7 @@ curves Smooth Haluskan Light Red Merah Terang Medium Blue Biru Sedang Light Gray Abu-abu Muda + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Melukis dari status sumber panel Riwayat + Sets the source for the history brush Menetapkan sumber untuk kuas riwayat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ad8dc89f5f..dcd9adcdbe 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4011,3 +4011,7 @@ navigator Zoom Zoom {index} of {total} {index} di {total} {shortcut} assigned. {shortcut} assegnata. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Dipinge dallo stato di origine del pannello Cronologia + Sets the source for the history brush Imposta l'origine del pennello storia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4cc90fa0b4..501797aa0a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3657,3 +3657,7 @@ curves Smooth スムーズ Light Red ライトレッド Medium Blue ミディアムブルー Light Gray ライトグレー + +# History panel › History Brush source (#2966) + Paints from the History panel's source state ヒストリーパネルのソース状態からペイントします + Sets the source for the history brush ヒストリーブラシのソースを設定します diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 62495a8d44..c1c16bea94 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3217,3 +3217,7 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + +# History panel › History Brush source (#2966) + Paints from the History panel's source state 작업 내역 패널의 소스 상태에서 칠합니다 + Sets the source for the history brush 작업 내역 브러시의 소스를 설정합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c0abbe55ed..aaf886236f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3807,3 +3807,7 @@ curves Smooth Vloeiend maken Light Red Lichtrood Medium Blue Middelblauw Light Gray Lichtgrijs + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Schildert vanuit de bronstatus van het deelvenster Geschiedenis + Sets the source for the history brush Stelt de bron voor het historiepenseel in diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 557b193e11..5a4f9c166e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3644,3 +3644,7 @@ curves Smooth Wygładź Light Red Jasnoczerwony Medium Blue Średnioniebieski Light Gray Jasnoszary + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maluje ze stanu źródłowego panelu Historia + Sets the source for the history brush Ustawia źródło dla pędzla historii diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7aea50bf0..1b3c6bf45f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3837,3 +3837,7 @@ layerLabel No Color Sem cor Light Red Vermelho-claro Medium Blue Azul médio Light Gray Cinza-claro + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Pinta a partir do estado de origem do painel Histórico + Sets the source for the history brush Define a origem do pincel do histórico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 75d3dd8a84..927797db28 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2992,3 +2992,7 @@ curves Smooth Сгладить Light Red Светло-красный Medium Blue Средне-синий Light Gray Светло-серый + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Рисует из исходного состояния панели «История» + Sets the source for the history brush Задаёт источник для кисти истории diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ffaaca59c6..5fcdcf3d94 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3745,3 +3745,7 @@ curves Smooth Згладити Light Red Світло-червоний Medium Blue Середньо-синій Light Gray Світло-сірий + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Малює з вихідного стану панелі «Історія» + Sets the source for the history brush Задає джерело для пензля історії diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6fb20b3d84..3b01f39b60 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3531,3 +3531,7 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + +# History panel › History Brush source (#2966) + Paints from the History panel's source state Vẽ từ trạng thái nguồn của bảng Lịch sử + Sets the source for the history brush Đặt nguồn cho cọ lịch sử diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 187f63e18d..840cbc4557 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2652,3 +2652,7 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + +# History panel › History Brush source (#2966) + Paints from the History panel's source state 从历史记录面板的源状态绘画 + Sets the source for the history brush 设置历史记录画笔的源 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3e0f8902cd..aa00562651 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2637,3 +2637,7 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + +# History panel › History Brush source (#2966) + Paints from the History panel's source state 從步驟記錄面板的來源狀態繪畫 + Sets the source for the history brush 設定步驟記錄筆刷的來源 From 07b5a0bd6a125b7caf77f068e4d6aabfec13e6e1 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Mon, 12 Oct 2026 10:50:48 -0300 Subject: [PATCH 0039/1452] docs: restore History snapshots parity entry --- docs/ui-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 087ddf9d3a..6cf8951f22 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -212,4 +212,5 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | +| 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | | 2026-10-11 | minor | Animated zoom done: 220 ms transitions per view for discrete Zoom clicks and View controls; GPU composites are reused, coordinates follow the displayed camera, and direct navigation interrupts immediately | From 8c1548109161f539c0c84657fe8a203a3677e7cc Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Mon, 12 Oct 2026 11:20:45 -0300 Subject: [PATCH 0040/1452] fix: hook Animated Zoom preference --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/actions.rs | 8 +-- crates/ui-egui/src/artboard_ui.rs | 2 +- crates/ui-egui/src/canvas.rs | 91 +++++++++++++++++++++----- crates/ui-egui/src/canvas_tool_menu.rs | 28 +++++++- crates/ui-egui/src/panels.rs | 64 ++++++++++++++++-- crates/ui-egui/src/prefs_ui.rs | 1 + crates/ui-egui/src/state.rs | 13 ++++ crates/ui-egui/src/view_cmds.rs | 7 +- crates/ui-egui/src/zoom_tool.rs | 77 ++++++++++++++++++++++ docs/scorecard.md | 8 +-- docs/ui-parity.md | 3 +- scorecard/bugs.toml | 2 +- 13 files changed, 263 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index cffc39bccf..8f82bc0331 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1049,7 +1049,6 @@ pub const SECTIONS: [(&str, &str); 18] = [ /// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", - "general.animatedZoom", "general.zoomResizesWindows", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 2f7dd960e9..5a52212a2a 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -144,22 +144,22 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu match id { "view.fitOnScreen" => { v.fit_pending = true; - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } "view.zoomIn" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, 1, v.doc_size); - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } "view.zoomOut" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, -1, v.doc_size); - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } "view.actualPixels" => { v.fit_pending = false; v.zoom = 1.0; - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } _ => return Err(format!("unsupported view command: {id}")), } diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 1dda153ff2..fe7f5f77ea 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -73,7 +73,7 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result { // overflows an i32 sum (#981). v.center = [((f64::from(b.x0) + f64::from(b.x1)) / 2.0) as f32, ((f64::from(b.y0) + f64::from(b.y1)) / 2.0) as f32]; v.fit_pending = false; - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "artboard": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c2682e6b9..ac29caeec3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -804,7 +804,8 @@ impl ViewXform { /// The main canvas's mapping for the active document as last laid out (inside the rulers). pub fn active(app: &PhotocraftApp) -> Option { let v = app.ui.views.get(app.session.active_index()?)?; - let (zoom, center) = if v.zoom_animating { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; + let (zoom, center) = + if v.zoom_animating && app.session.prefs().general.animated_zoom { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; Some(Self { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: zoom / app.canvas_ppp(), @@ -897,7 +898,10 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f } /// Ease a transient presentation camera over 220 ms without changing the requested camera. -fn animated_view(view: &mut View, xf: ViewXform, ppp: f32, time: f64) -> View { +fn animated_view(view: &mut View, xf: ViewXform, ppp: f32, animated_zoom: bool, time: f64) -> View { + if !animated_zoom { + view.request_zoom_animation(false); + } let target = (view.zoom, view.center); // Clamping, a canvas resize, or another camera command can make the original anchor // incompatible with the requested camera. The target must always win. @@ -2674,7 +2678,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let target_xf = ViewXform { rect, zoom: view.zoom / ppp, center: view.center, flip, rotation: view.rotation, aspect }; let render_zoom = view.zoom; let render_visible = visible_doc_rect(&target_xf); - let mut display_view = animated_view(&mut view, target_xf, ppp, ctx.input(|i| i.time)); + let mut display_view = animated_view(&mut view, target_xf, ppp, app.session.prefs().general.animated_zoom, ctx.input(|i| i.time)); if primary && let Some(stored) = app.ui.views.get_mut(idx) { stored.zoom_animating = view.zoom_animating; stored.display_camera = view.display_camera; @@ -3343,7 +3347,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let doc_anchor = xf.to_doc(p); let screen_anchor = p; zoom_about(&mut view, &xf, p, nz, center, ppp); - view.zoom_animating = nz != display_view.zoom; + view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_view.zoom); view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y])); } // A click with the (temporary) Hand does nothing, never the tool underneath. @@ -7268,35 +7272,86 @@ mod zoom_animation_tests { View { zoom: 1.0, center: [200.0, 150.0], doc_size: [400, 300], fit_pending: false, ..Default::default() } } + #[test] + fn disabling_animated_zoom_snaps_a_secondary_canvas_on_its_next_frame() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let mut secondary = view(); + let frame = |app: &mut PhotocraftApp, view: View, time: f64| { + let input = egui::RawInput { screen_rect: Some(rect), time: Some(time), ..Default::default() }; + let mut shown = view.clone(); + let mut out = ctx.run_ui(input, |ui| { + shown = canvas_view(app, ui, 0, rect, view.clone(), false); + }); + out.textures_delta.clear(); + shown + }; + secondary = frame(&mut app, secondary, 0.0); + secondary.zoom = 2.0; + secondary.request_zoom_animation(true); + secondary = frame(&mut app, secondary, 0.01); + secondary = frame(&mut app, secondary, 0.12); + assert!(secondary.zoom_animating); + assert!(secondary.display_camera.unwrap().0 < secondary.zoom); + app.run("prefs.set", serde_json::json!({"values": {"general.animatedZoom": false}})).unwrap(); + secondary = frame(&mut app, secondary, 0.13); + assert_eq!(secondary.display_camera, Some((secondary.zoom, secondary.center))); + assert_eq!(secondary.zoom, 2.0); + assert!(!secondary.zoom_animating && secondary.zoom_transition.is_none() && secondary.zoom_anchor.is_none()); + } + + #[test] + fn animated_zoom_off_cancels_each_views_presentation_camera() { + for target in [(2.0, [210.0, 170.0]), (3.0, [250.0, 180.0])] { + let mut v = view(); + let xf = camera(&v); + animated_view(&mut v, xf, 1.0, true, 0.0); + v.zoom = target.0; + v.center = target.1; + v.request_zoom_animation(true); + animated_view(&mut v, xf, 1.0, true, 0.0); + let halfway = animated_view(&mut v, xf, 1.0, true, 0.11); + assert_ne!((halfway.zoom, halfway.center), target); + v.zoom_anchor = Some(([200.0, 150.0], [500.0, 350.0])); + let shown = animated_view(&mut v, xf, 1.0, false, 0.11); + assert_eq!((shown.zoom, shown.center), target); + assert_eq!(v.display_camera, Some(target)); + assert!(!v.zoom_animating && v.zoom_anchor.is_none() && v.zoom_transition.is_none()); + } + } + #[test] fn zoom_animation_obeys_clamped_targets_and_eases_recentering() { let mut v = view(); let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); + animated_view(&mut v, xf, 1.0, true, 0.0); let pointer = xf.to_screen(390.0, 150.0); zoom_about(&mut v, &xf, pointer, 2.0, false, 1.0); v.zoom_anchor = Some(([390.0, 150.0], [pointer.x, pointer.y])); v.zoom_animating = true; crate::scrollbars::clamp_view(&mut v, xf.rect.size(), 1.0); let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); + animated_view(&mut v, xf, 1.0, true, 0.0); assert!(v.zoom_anchor.is_none()); - let done = animated_view(&mut v, xf, 1.0, 0.22); + let done = animated_view(&mut v, xf, 1.0, true, 0.22); assert_eq!((done.zoom, done.center), (v.zoom, v.center)); assert!(!v.zoom_animating); let mut v = view(); let xf = camera(&v); - animated_view(&mut v, xf, 1.0, 0.0); + animated_view(&mut v, xf, 1.0, true, 0.0); zoom_about(&mut v, &xf, pointer, 2.0, true, 1.0); v.zoom_animating = true; let target = v.center; let xf = camera(&v); - let start = animated_view(&mut v, xf, 1.0, 0.0); + let start = animated_view(&mut v, xf, 1.0, true, 0.0); assert_eq!(start.center, [200.0, 150.0], "recentering must not teleport"); - let halfway = animated_view(&mut v, xf, 1.0, 0.11); + let halfway = animated_view(&mut v, xf, 1.0, true, 0.11); assert!(halfway.center[0] > start.center[0] && halfway.center[0] < target[0]); - assert_eq!(animated_view(&mut v, xf, 1.0, 0.22).center, target); + assert_eq!(animated_view(&mut v, xf, 1.0, true, 0.22).center, target); } #[test] @@ -7304,21 +7359,21 @@ mod zoom_animation_tests { let mut a = view(); let mut b = view(); let xf = camera(&a); - animated_view(&mut a, xf, 1.0, 0.0); - animated_view(&mut b, xf, 1.0, 0.0); + animated_view(&mut a, xf, 1.0, true, 0.0); + animated_view(&mut b, xf, 1.0, true, 0.0); a.zoom = 2.0; a.zoom_animating = true; - animated_view(&mut a, xf, 1.0, 0.0); + animated_view(&mut a, xf, 1.0, true, 0.0); b.zoom = 3.0; b.zoom_animating = true; - animated_view(&mut b, xf, 1.0, 0.11); - assert_eq!(animated_view(&mut a, xf, 1.0, 0.22).zoom, 2.0); - let shown = animated_view(&mut b, xf, 1.0, 0.22); + animated_view(&mut b, xf, 1.0, true, 0.11); + assert_eq!(animated_view(&mut a, xf, 1.0, true, 0.22).zoom, 2.0); + let shown = animated_view(&mut b, xf, 1.0, true, 0.22); assert!(shown.zoom > 1.0 && shown.zoom < 3.0); assert!(b.zoom_animating && !a.zoom_animating); b.zoom_animating = false; b.center = [220.0, 170.0]; - let shown = animated_view(&mut b, xf, 1.0, 0.23); + let shown = animated_view(&mut b, xf, 1.0, true, 0.23); assert_eq!((shown.zoom, shown.center), (b.zoom, b.center)); assert!(b.zoom_transition.is_none() && b.zoom_anchor.is_none()); } diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 9bfd0f9f3a..41171a5558 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -494,7 +494,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co let v = app.ui.views.get_mut(i).ok_or("no document view")?; v.fit_pending = false; v.fill_pending = true; - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); v.zoom_anchor = None; return Ok(()); } @@ -511,7 +511,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co // from the centre shrinks (or grows) by the zoom ratio, whatever the rotation or flip. let ratio = zoom0 / v.zoom; v.center = if center_on_point || !ratio.is_finite() { d } else { [d[0] - (d[0] - c[0]) * ratio, d[1] - (d[1] - c[1]) * ratio] }; - v.zoom_anchor = (!center_on_point).then_some(([d[0] as f64, d[1] as f64], menu.pos)); + v.zoom_anchor = (v.zoom_animating && !center_on_point).then_some(([d[0] as f64, d[1] as f64], menu.pos)); Ok(()) } @@ -1150,6 +1150,30 @@ mod tests { assert!((screen_of(h.state(), doc) - p).length() < 1.0); } + #[test] + fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { + for command in ["view.zoomIn", "view.zoomOut", FILL_SCREEN] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false}})).unwrap(); + let mut h = harness(app); + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + assert!(!h.state().ui.views[0].zoom_animating, "{command}"); + h.step(); + let v = &h.state().ui.views[0]; + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); + assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); + if command != FILL_SCREEN { + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: preserve the clicked point"); + } + } + } + #[test] fn view_menu_zoom_keeps_its_anchor_during_the_animation() { let mut app = zoom_app(400, 300); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4a2fa63c80..baec9ba90f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1348,14 +1348,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; } if widgets::secondary_button(ui, "100%", 0.0).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; @@ -1384,7 +1384,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; @@ -1393,7 +1393,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; app.ui.views[i].fill_pending = false; } @@ -1401,7 +1401,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fill_pending = true; - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } @@ -1878,14 +1878,14 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { app.ui.views[idx].zoom = z; - app.ui.views[idx].zoom_animating = true; + app.ui.views[idx].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[idx].zoom_anchor = None; app.ui.views[idx].fit_pending = false; } } if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { app.ui.views[idx].fit_pending = true; - app.ui.views[idx].zoom_animating = true; + app.ui.views[idx].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[idx].zoom_anchor = None; } }); @@ -5719,3 +5719,53 @@ mod properties_target_regressions { assert_eq!(after, before, "pending width belongs to the old document/layer"); } } + +#[cfg(test)] +mod navigator_animated_zoom_tests { + use super::*; + + #[test] + fn animated_zoom_off_navigator_buttons_jump_without_a_transition() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + app.sync_views(); + app.ui.views[0].fit_pending = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(430.0, 460.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + navigator(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let width = |label: &str| h.ctx.fonts_mut(|f| f.layout_no_wrap(label.into(), crate::theme::medium(12.5), Color32::WHITE).size().x + 20.0); + let spacing = h.ctx.style_of(h.ctx.theme()).spacing.item_spacing.x; + let widths = [width("200%"), width("100%"), width("Fit")]; + let controls = last_navigator_controls(&h.ctx).unwrap(); + let mut right = controls.right(); + for (index, zoom) in [2.0, 1.0].into_iter().enumerate() { + let pointer = pos2(right - widths[index] / 2.0, controls.top() + 16.0); + h.hover_at(pointer); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: pointer, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + right -= widths[index] + spacing; + let view = &h.state().ui.views[0]; + assert_eq!(view.zoom, zoom); + assert_eq!(view.display_camera, Some((view.zoom, view.center))); + assert!(!view.zoom_animating && view.zoom_anchor.is_none() && view.zoom_transition.is_none()); + } + let pointer = pos2(right - widths[2] / 2.0, controls.top() + 16.0); + h.hover_at(pointer); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: pointer, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + assert!(h.state().ui.views[0].fit_pending); + assert!(!h.state().ui.views[0].zoom_animating); + } +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 202944b58a..04c992d983 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -2749,6 +2749,7 @@ mod tests { assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); assert!(!prefs::is_hidden("general.autoShowHomeScreen")); + assert!(!prefs::is_hidden("general.animatedZoom")); assert!(!prefs::is_hidden("interface.uiScale")); assert!(!prefs::is_hidden("interface.uiFontSize")); // Hidden values still round-trip through the dialog untouched. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e6178aa8d0..b854025043 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -499,6 +499,19 @@ pub(crate) struct ZoomTransition { pub started: f64, } +impl View { + /// Start a discrete zoom only when Animated Zoom is enabled; otherwise discard the + /// presentation camera so the requested camera takes effect immediately. + pub(crate) fn request_zoom_animation(&mut self, enabled: bool) { + self.zoom_animating = enabled; + self.zoom_anchor = None; + if !enabled { + self.zoom_transition = None; + self.display_camera = Some((self.zoom, self.center)); + } + } +} + impl Default for View { fn default() -> Self { Self { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 6d062fc7ce..9d3fe9a32e 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -709,7 +709,7 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res let screen = app.session.prefs().units_and_rulers.screen_resolution.max(1.0) as f32; let z = if id == "view.twoHundredPercent" { 2.0 } else { screen / dpi }; app.ui.views[i].zoom = crate::zoom_levels::clamp(z, size); - app.ui.views[i].zoom_animating = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); app.ui.views[i].zoom_anchor = None; Ok(json!({"zoom": app.ui.views[i].zoom})) } @@ -742,7 +742,7 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result { v.zoom = crate::zoom_levels::clamp(points * ppp, size); v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0]; v.fit_pending = false; - v.zoom_animating = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } @@ -815,10 +815,11 @@ fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result let src = app.ui.views.get(i).cloned().ok_or("no document")?; let (zoom, loc, rot) = (matches!(k, "matchZoom" | "matchAll"), matches!(k, "matchLocation" | "matchAll"), matches!(k, "matchRotation" | "matchAll")); + let animated_zoom = app.session.prefs().general.animated_zoom; let apply = |v: &mut crate::state::View| { if zoom { v.zoom = src.zoom; - v.zoom_animating = true; + v.request_zoom_animation(animated_zoom); v.zoom_anchor = None; } if loc { diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index c892041e76..7ace657bcb 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -196,6 +196,71 @@ mod tests { assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(zc, 1, h.state().ui.views[0].doc_size)); } + #[test] + fn animated_zoom_off_click_jumps_to_the_anchored_target() { + let mut h = harness(true, 1.0); + h.state_mut() + .run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let pointer = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + let anchor = view_xf(&h).to_doc(pointer); + let start = h.state().current_zoom(); + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + let target = crate::zoom_levels::step(start, 1, [400, 300]); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, target); + assert_eq!(view_xf(&h).zoom, target); + assert!(!v.zoom_animating && v.zoom_anchor.is_none() && v.zoom_transition.is_none()); + assert!(view_xf(&h).to_screen(anchor[0] as f32, anchor[1] as f32).distance(pointer) < 0.5); + } + + #[test] + fn animated_zoom_off_view_commands_show_the_target_on_the_first_frame() { + for command in + ["view.zoomIn", "view.zoomOut", "view.actualPixels", "view.fitOnScreen", "view.twoHundredPercent", "view.printSize", "view.fitLayersOnScreen"] + { + let mut h = harness(true, 1.0); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + if crate::view_cmds::handles(command) { + crate::view_cmds::invoke(h.state_mut(), &egui::Context::default(), command, &json!({})).unwrap().unwrap(); + } else { + h.state_mut().run(command, json!({})).unwrap(); + } + assert!(!h.state().ui.views[0].zoom_animating, "{command}"); + h.step(); + let v = &h.state().ui.views[0]; + let xf = view_xf(&h); + assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); + assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); + } + } + + #[test] + fn disabling_animated_zoom_during_a_transition_snaps_and_does_not_resume() { + let mut h = harness(true, 1.0); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.11; + h.input_mut().time = Some(time); + h.step(); + assert!(h.state().ui.views[0].zoom_animating); + let target = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + assert_ne!(view_xf(&h).zoom, target.0); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target, "pointer mapping switches immediately"); + h.step(); + let v = &h.state().ui.views[0]; + assert!(!v.zoom_animating && v.zoom_anchor.is_none() && v.zoom_transition.is_none()); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": true}})).unwrap(); + h.step(); + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target); + } + #[test] fn discrete_zoom_animates_and_keeps_its_document_anchor_fixed() { let mut h = harness(true, 1.0); @@ -285,6 +350,18 @@ mod tests { } assert!(!h.state().ui.views[0].zoom_animating); assert_eq!(view_xf(&h).zoom, target); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.state_mut().perf.last_refresh = ""; + h.step(); + assert_eq!(view_xf(&h).zoom, h.state().current_zoom()); + assert!(!h.state().ui.views[0].zoom_animating); + assert!(h.state().perf.gpu); + assert_eq!(h.state().perf.last_refresh, "", "immediate zoom reuses the GPU composite"); + let image = h.render().unwrap(); + if let Some(directory) = &directory { + image.save(directory.join("photocraft-zoom-disabled.png")).unwrap(); + } } #[test] diff --git a/docs/scorecard.md b/docs/scorecard.md index 78a0864549..3096ac15e4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 27 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -279,7 +279,7 @@ Never-crash attribute on 28 of 28 crate roots. | BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures | | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | | BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | -| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED | +| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom gates discrete canvas zoom transitions and is exposed in General preferences | | BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | | BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2afb2b0d04..634bce8f13 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -164,7 +164,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Hold any tool letter for a temporary tool | missing | #2672 | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | -| Animated zoom | done | `canvas.rs`, `zoom_tool.rs`; #566; discrete Zoom clicks and View zoom controls ease over 220 ms by default, with anchored transitions and immediate direct navigation; the existing preference remains hidden | +| Animated zoom | done | `canvas.rs`, `zoom_tool.rs`; #566; discrete Zoom clicks and View zoom controls ease over 220 ms by default, with anchored transitions and immediate direct navigation; General › Animated Zoom (default on) controls transitions; off snaps immediately, including when disabled mid-transition | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | | ⌥-wheel zoom in gentle steps | missing | UI-217-18 | | Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | @@ -220,3 +220,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | +| 2026-10-12 | minor | General › Animated Zoom is visible and gates every discrete zoom transition; disabling it snaps all views to their target on the next frame | diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index a4620bf7d6..d60c29cf9a 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -60,7 +60,7 @@ id = "BUG-204-2" target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden" status = "done" issue = 204 -note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED" +note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom gates discrete canvas zoom transitions and is exposed in General preferences" [[item]] id = "BUG-204-3" From 89791873f9c6b2c6e008103df1e8293e93964c2a Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 03:24:57 +1100 Subject: [PATCH 0041/1452] Codecs: open uncompressed DICOM single-frame images (#1048) Add a pure-Rust DICOM reader: the file meta group's transfer syntax (implicit VR little-endian, explicit VR little/big-endian), MONOCHROME1/2 and RGB/YBR_FULL, 8/16-bit allocated samples, interleaved or planar, BitsStored/HighBit scaling and signed pixel representation. Compressed syntaxes, sequences, multi-frame data and other photometric interpretations are refused with Unsupported/Malformed, never wrong pixels. DICOM is read-only; .dcm/.dicom/.dc3 join the open filters (and .pcx is added to the web filter, where it was missing). --- apps/photocraft-web/src/web.rs | 3 +- apps/photocraft/src/services.rs | 3 +- crates/codecs/README.md | 9 + crates/codecs/src/codecs/dicom.rs | 364 +++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 16 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/detect_caps.rs | 17 +- docs/file-format-parity.md | 2 +- 9 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 crates/codecs/src/codecs/dicom.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..c4b89dd4ef 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,7 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", + "ppm", "pam", "pfm", "pcx", "dcm", "dicom", "dc3", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..107b6324f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,7 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", + "pgm", "ppm", "pam", "pfm", "pcx", "dcm", "dicom", "dc3", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e44890a0d9..42edd522db 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -78,6 +79,14 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. +* **DICOM (read-only).** Single-frame medical images over the uncompressed transfer syntaxes + (implicit VR little-endian, explicit VR little/big-endian): MONOCHROME1/2 gray (with the + 1 = white inversion) and RGB/YBR_FULL (8-bit), 8- and 16-bit allocated samples, interleaved or + planar, scaled from `BitsStored`/`HighBit` and mapped up when `PixelRepresentation` is signed. + Compressed transfer syntaxes, sequences, overlays, multi-frame pixel data and other photometric + interpretations return `Unsupported`/`Malformed`. There is no DICOM writer + (`ASYMMETRIC_EXCEPTIONS`). + * **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose). Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF diff --git a/crates/codecs/src/codecs/dicom.rs b/crates/codecs/src/codecs/dicom.rs new file mode 100644 index 0000000000..e86142b2af --- /dev/null +++ b/crates/codecs/src/codecs/dicom.rs @@ -0,0 +1,364 @@ +//! DICOM (medical imaging) reader, uncompressed transfer syntaxes only. +//! +//! Supports what a viewer needs from a single-frame image: MONOCHROME1/2 (gray) and RGB, +//! 8- and 16-bit samples, interleaved or planar, both byte orders and explicit/implicit VR. +//! Compressed transfer syntaxes, sequences, overlays and multi-frame pixel data are refused +//! with a clear error. Read-only in this crate. + +use std::collections::HashMap; + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Dicom; +const PREAMBLE: usize = 132; // 128 zero bytes + "DICM" + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +/// A DICOM file begins with a 128-byte preamble and the `DICM` magic. +pub(crate) fn is_dicom(b: &[u8]) -> bool { + b.get(128..132) == Some(b"DICM") +} + +#[derive(Clone, Copy)] +struct Endian { + big: bool, +} + +/// The transfer syntax's byte order and whether value representations are explicit. +struct Syntax { + endian: Endian, + explicit: bool, +} + +/// The value representations whose length field is 2 reserved bytes + `u32` in explicit VR. +fn long_vr(vr: &[u8]) -> bool { + matches!(vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN" | b"OD" | b"OL" | b"OV" | b"UC" | b"UR") +} + +/// A cursor over the data set. +struct Cur<'a> { + b: &'a [u8], + pos: usize, + big: bool, +} + +impl<'a> Cur<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(s) + } + fn u16(&mut self) -> Option { + let c = self.take(2)?; + Some(if self.big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) + } + fn u32(&mut self) -> Option { + let c = self.take(4)?; + Some(if self.big { u32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { u32::from_le_bytes([c[0], c[1], c[2], c[3]]) }) + } +} + +/// A parsed element: its VR and the byte range of its value. +struct Elem { + vr: [u8; 2], + start: usize, + len: usize, +} + +/// Walk the elements from `start`, calling `f` with each `(group, element, value)`. Undefined +/// lengths (sequences) are refused, keeping the decoder single-frame and bounded. +fn elements(b: &[u8], start: usize, syn: &Syntax, mut f: impl FnMut(u16, u16, &Elem) -> Result<(), CodecError>) -> Result { + let mut c = Cur { b, pos: start, big: syn.endian.big }; + loop { + let Some(group) = c.u16() else { return Ok(c.pos - start) }; + let elem = c.u16().ok_or_else(|| bad("truncated element tag"))?; + let (vr, len) = if syn.explicit { + let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?; + let vr2 = [vr[0], vr[1]]; + let len = if long_vr(&vr2) { + c.take(2).ok_or_else(|| bad("truncated VR"))?; // reserved + c.u32().ok_or_else(|| bad("truncated length"))? + } else { + u32::from(c.u16().ok_or_else(|| bad("truncated length"))?) + }; + (vr2, len) + } else { + ([0, 0], c.u32().ok_or_else(|| bad("truncated length"))?) + }; + if len == 0xFFFF_FFFF { + return Err(CodecError::unsupported(F, "undefined-length (sequence) elements")); + } + let len = len as usize; + let start = c.pos; + if b.len() < start.saturating_add(len) { + return Err(bad("element value runs past the end of the file")); + } + f(group, elem, &Elem { vr, start, len })?; + c.pos = start + len; + } +} + +fn u16_val(b: &[u8], e: &Elem, big: bool) -> Option { + let c = b.get(e.start..e.start + 2)?; + Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) +} + +/// The transfer syntax (byte order and explicit/implicit VR). The file meta group is always +/// explicit VR little-endian; the data set follows its `(0002,0010)` TransferSyntaxUID. +fn transfer_syntax(b: &[u8]) -> Result<(Syntax, usize), CodecError> { + let meta = Syntax { endian: Endian { big: false }, explicit: true }; + let mut ts: Option = None; + let mut data_start = b.len(); + let mut c = Cur { b, pos: PREAMBLE, big: false }; + loop { + let save = c.pos; + let Some(group) = c.u16() else { break }; + let _ = c.u16().ok_or_else(|| bad("truncated element tag"))?; + if group != 0x0002 { + // A non-meta group begins the data set. + data_start = save; + break; + } + let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?; + let vr2 = [vr[0], vr[1]]; + let len = if long_vr(&vr2) { + c.take(2).ok_or_else(|| bad("truncated VR"))?; + c.u32().ok_or_else(|| bad("truncated length"))? + } else { + u32::from(c.u16().ok_or_else(|| bad("truncated length"))?) + }; + if len == 0xFFFF_FFFF { + return Err(CodecError::unsupported(F, "undefined-length meta element")); + } + let start = c.pos; + if b.len() < start.saturating_add(len as usize) { + return Err(bad("meta element runs past the end of the file")); + } + if (group, 0x0010) == (0x0002, 0x0010) { + ts = Some(String::from_utf8_lossy(&b[start..start + len as usize]).trim_end_matches(['\0', ' ']).to_string()); + } + c.pos = start + len as usize; + } + let _ = elements(b, PREAMBLE, &meta, |_, _, _| Ok(()))?; // validate the meta group parses + let syn = match ts.as_deref() { + Some("1.2.840.10008.1.2") => Syntax { endian: Endian { big: false }, explicit: false }, + Some("1.2.840.10008.1.2.1") => Syntax { endian: Endian { big: false }, explicit: true }, + Some("1.2.840.10008.1.2.2") => Syntax { endian: Endian { big: true }, explicit: true }, + Some(other) => return Err(CodecError::unsupported(F, format!("transfer syntax {other} (only uncompressed is read)"))), + // No meta: the historical default is implicit VR little-endian. + None => Syntax { endian: Endian { big: false }, explicit: false }, + }; + Ok((syn, data_start)) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !is_dicom(b) { + return Err(bad("not a DICOM file (no DICM magic)")); + } + let (syn, start) = transfer_syntax(b)?; + let mut tags: HashMap<(u16, u16), Elem> = HashMap::new(); + elements(b, start, &syn, |g, e, el| { + // Later duplicates must not silently override the first (DICOM forbids duplicates). + tags.entry((g, e)).or_insert_with(|| Elem { vr: el.vr, start: el.start, len: el.len }); + Ok(()) + })?; + let big = syn.endian.big; + let get_u16 = |tag: (u16, u16)| tags.get(&tag).and_then(|e| u16_val(b, e, big)); + let rows = get_u16((0x0028, 0x0010)).ok_or_else(|| bad("missing Rows (0028,0010)"))?; + let cols = get_u16((0x0028, 0x0011)).ok_or_else(|| bad("missing Columns (0028,0011)"))?; + let samples = get_u16((0x0028, 0x0002)).unwrap_or(1); + let bits = get_u16((0x0028, 0x0100)).ok_or_else(|| bad("missing BitsAllocated (0028,0100)"))?; + let bits_stored = get_u16((0x0028, 0x0101)).unwrap_or(bits); + let high_bit = get_u16((0x0028, 0x0102)).unwrap_or(bits_stored.saturating_sub(1)); + let signed = get_u16((0x0028, 0x0103)).unwrap_or(0) == 1; + let planar = get_u16((0x0028, 0x0006)).unwrap_or(0); + let photometric = tags + .get(&(0x0028, 0x0004)) + .and_then(|e| b.get(e.start..e.start + e.len)) + .map(|v| String::from_utf8_lossy(v).trim_end_matches(['\0', ' ']).to_string()) + .ok_or_else(|| bad("missing PhotometricInterpretation (0028,0004)"))?; + let pixel = tags.get(&(0x7FE0, 0x0010)).ok_or_else(|| CodecError::unsupported(F, "no pixel data (0028,0004/7FE0,0010)"))?; + if !matches!(bits, 8 | 16) || !matches!(bits_stored, 1..=16) { + return Err(CodecError::unsupported(F, format!("{bits}-bit allocated / {bits_stored}-bit stored samples"))); + } + let mono = matches!(photometric.as_str(), "MONOCHROME1" | "MONOCHROME2"); + let rgb = photometric == "RGB"; + let ybr = photometric == "YBR_FULL" || photometric == "YBR_FULL_422"; + if !(mono || rgb || ybr) { + return Err(CodecError::unsupported(F, format!("photometric interpretation {photometric}"))); + } + if mono && samples != 1 { + return Err(CodecError::unsupported(F, format!("{samples} samples per pixel with {photometric}"))); + } + if ybr && (bits != 8 || samples != 3) { + return Err(CodecError::unsupported(F, format!("{bits}-bit {photometric} ({samples} samples)"))); + } + let layout = if mono { ChannelLayout::Gray } else { ChannelLayout::Rgb }; + let out_sample = if bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(u32::from(cols), u32::from(rows), layout, out_sample)?; + let n = usize::from(cols) * usize::from(rows) * usize::from(samples); + let bytes_per = if bits == 16 { 2 } else { 1 }; + let need = n.checked_mul(bytes_per).ok_or_else(|| bad("pixel size overflow"))?; + let data = b.get(pixel.start..pixel.start + need).ok_or_else(|| bad("pixel data is truncated"))?; + let out_channels = layout.channels(); + let mut out = Vec::new(); + out.try_reserve_exact(usize::from(cols) * usize::from(rows) * out_channels * bytes_per) + .map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + // Sample `i` of `samples` channels at pixel `p`, honouring the planar configuration. + let sample_at = |p: usize, ch: usize, s: usize| -> u32 { + let idx = if planar == 1 { ch * usize::from(cols) * usize::from(rows) + p } else { s + p * usize::from(samples) + ch }; + if bits == 16 { + let off = idx * 2; + let c = [data[off], data[off + 1]]; + u32::from(if big { u16::from_be_bytes(c) } else { u16::from_le_bytes(c) }) + } else { + u32::from(data[idx]) + } + }; + let stored_max = f64::from((1u32 << bits_stored) - 1); + // Extract the `bits_stored` samples (the least significant one at `high_bit`) and scale to + // [0, 1]; signed values are shifted up by half a range first. `high_bit` is 1-based here. + let shift = (u32::from(high_bit) + 1).saturating_sub(u32::from(bits_stored)); + let mask = (1u32 << bits_stored) - 1; + let half = 1u32 << (bits_stored - 1); + let norm = move |raw: u32| -> f64 { + let v = (raw >> shift) & mask; + // Signed: flip the sign bit to map [-2^(b-1), 2^(b-1)-1] onto [0, 2^b-1]. + let v = if signed { v ^ half } else { v }; + (f64::from(v) / stored_max).clamp(0.0, 1.0) + }; + let q = |x: f64| -> u16 { (x * 65535.0).round() as u16 }; + let push = |out: &mut Vec, x: f64| { + if bits == 8 { + out.push((x * 255.0).round() as u8); + } else { + out.extend_from_slice(&q(x).to_ne_bytes()); + } + }; + let px = usize::from(cols) * usize::from(rows); + for p in 0..px { + if mono { + let x = norm(sample_at(p, 0, 0)); + push(&mut out, if photometric == "MONOCHROME1" { 1.0 - x } else { x }); + } else if rgb { + for ch in 0..3 { + push(&mut out, norm(sample_at(p, ch, ch))); + } + } else { + // YBR_FULL (8-bit): BT.601 full-range to RGB. + let y = sample_at(p, 0, 0) as f64; + let cb = sample_at(p, 1, 1) as f64 - 128.0; + let cr = sample_at(p, 2, 2) as f64 - 128.0; + let r = (y + 1.402 * cr).clamp(0.0, 255.0); + let g = (y - 0.344_136 * cb - 0.714_136 * cr).clamp(0.0, 255.0); + let bl = (y + 1.772 * cb).clamp(0.0, 255.0); + for v in [r, g, bl] { + push(&mut out, v / 255.0); + } + } + } + Image::from_raw(u32::from(cols), u32::from(rows), layout, out_sample, out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A little-endian explicit-VR DICOM with meta group and one frame. + fn synth(rows: u16, cols: u16, samples: u16, bits: u16, photometric: &str, pixels: &[u8]) -> Vec { + fn us(b: &mut Vec, g: u16, e: u16, v: u16) { + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + b.extend_from_slice(b"US"); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&v.to_le_bytes()); + } + fn cs(b: &mut Vec, g: u16, e: u16, v: &str) { + let mut s = v.as_bytes().to_vec(); + if s.len() % 2 == 1 { + s.push(0); + } + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + b.extend_from_slice(b"CS"); + b.extend_from_slice(&(s.len() as u16).to_le_bytes()); + b.extend_from_slice(&s); + } + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + // File meta group: TransferSyntaxUID explicit VR LE => explicit VR little endian. + let ts = b"1.2.840.10008.1.2.1\0"; + b.extend_from_slice(&0x0002u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + b.extend_from_slice(b"UI"); + b.extend_from_slice(&(ts.len() as u16).to_le_bytes()); + b.extend_from_slice(ts); + // Data set. + us(&mut b, 0x0028, 0x0010, rows); + us(&mut b, 0x0028, 0x0011, cols); + us(&mut b, 0x0028, 0x0002, samples); + cs(&mut b, 0x0028, 0x0004, photometric); + us(&mut b, 0x0028, 0x0100, bits); + us(&mut b, 0x0028, 0x0101, bits); + us(&mut b, 0x0028, 0x0102, bits - 1); + us(&mut b, 0x0028, 0x0103, 0); + // Pixel data (OB, long form). + b.extend_from_slice(&0x7FE0u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + b.extend_from_slice(b"OB"); + b.extend_from_slice(&0u16.to_le_bytes()); + b.extend_from_slice(&(pixels.len() as u32).to_le_bytes()); + b.extend_from_slice(pixels); + b + } + + #[test] + fn decodes_8bit_monochrome() { + let b = synth(2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (2, 2, ChannelLayout::Gray, SampleType::U8)); + assert_eq!(img.data(), &[0, 51, 102, 255]); + } + + #[test] + fn decodes_16bit_monochrome_and_scales() { + let px: Vec = [0u16, 32768, 65535].iter().flat_map(|v| v.to_le_bytes()).collect(); + let b = synth(3, 1, 1, 16, "MONOCHROME2", &px); + let img = decode(&b, &Limits::default()).unwrap(); + let v = img.to_u16_samples().unwrap(); + assert_eq!(v[0], 0); + assert!((i32::from(v[1]) - 32768).abs() <= 1); + assert_eq!(v[2], 65535); + } + + #[test] + fn monochrome1_is_inverted() { + let b = synth(2, 1, 1, 8, "MONOCHROME1", &[0, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[255, 0]); + } + + #[test] + fn truncated_and_bad_is_error_not_panic() { + let b = synth(2, 2, 1, 8, "MONOCHROME2", &[0, 1, 2, 3]); + for cut in [0, 4, 128, 132, 140, b.len() - 1] { + assert!(decode(&b[..cut.min(b.len())], &Limits::default()).is_err(), "cut {cut}"); + } + assert!(decode(b"not dicom", &Limits::default()).is_err()); + } + + #[test] + fn rejects_compressed_transfer_syntax() { + let mut b = synth(1, 1, 1, 8, "MONOCHROME2", &[0]); + let uncompressed = b"1.2.840.10008.1.2.1\0"; + let compressed = b"1.2.840.10008.1.2.4\0"; // same length, a compressed family + let at = b.windows(uncompressed.len()).position(|w| w == uncompressed).expect("transfer syntax in the fixture"); + b[at..at + compressed.len()].copy_from_slice(compressed); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 22845d930b..8b9e024d0b 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod deep_exr; +pub(crate) mod dicom; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ddb7587141..9aad8a35d2 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -27,6 +27,8 @@ pub enum Format { Heif, /// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data. Fits, + /// DICOM single-frame images (uncompressed): read-only. + Dicom, } /// What a format can hold **and** what this crate reads/writes for it. @@ -74,6 +76,10 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \ unsupported. Without the feature HEIF is detected but neither read nor written.", ), + ( + Format::Dicom, + "DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or PNG instead.", + ), ]; use ChannelLayout as L; @@ -84,7 +90,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 16] = [ + pub const ALL: [Format; 17] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +107,7 @@ impl Format { Format::Avif, Format::Heif, Format::Fits, + Format::Dicom, ]; pub fn caps(self) -> FormatCaps { @@ -131,6 +138,7 @@ impl Format { Format::Avif => "AVIF", Format::Heif => "HEIF", Format::Fits => "FITS", + Format::Dicom => "DICOM", } } @@ -154,6 +162,7 @@ impl Format { Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], + Format::Dicom => &["dcm", "dicom", "dc3"], } } @@ -175,6 +184,7 @@ impl Format { Format::Avif => "image/avif", Format::Heif => "image/heif", Format::Fits => "image/fits", + Format::Dicom => "application/dicom", } } @@ -226,6 +236,7 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, + Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -277,6 +288,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if crate::codecs::dicom::is_dicom(b) { + return Some(Format::Dicom); + } if crate::codecs::pcx::looks_like_pcx(b) { return Some(Format::Pcx); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 16025a5a46..2424dcde76 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dicom, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -75,6 +75,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), + Format::Dicom => dicom::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), @@ -223,6 +224,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), + Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 367a8bd813..049e0c1389 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -101,7 +101,9 @@ fn caps_are_internally_consistent() { let c = caps(f); assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}"); assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts"); - assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/")); + // DICOM's registered media type is `application/dicom`; every other format is an image. + let mime_ok = f.mime_type().starts_with("image/") || (f == Format::Dicom && f.mime_type() == "application/dicom"); + assert!(!f.extensions().is_empty() && !f.name().is_empty() && mime_ok, "{f:?}"); } } @@ -192,6 +194,19 @@ fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand"); assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif"); } +#[test] +fn detect_magic_dicom() { + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + assert_eq!(detect(&b), Some(Format::Dicom)); + assert_eq!(from_extension("scan.DCM"), Some(Format::Dicom)); + assert_eq!(from_extension("scan.dc3"), Some(Format::Dicom)); + assert!(caps(Format::Dicom).read && !caps(Format::Dicom).write); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Dicom)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Dicom, &b).is_err()); +} + #[test] fn detect_tga_heuristic() { // Uncompressed true-colour 2x2, 24 bpp. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 024d596f1e..2c8beadbe2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -44,7 +44,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | | Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | -| DICOM | R/W | no | no | | +| DICOM | R/W | yes (#1048) | no | Read-only, uncompressed syntaxes: MONOCHROME1/2, RGB, YBR_FULL | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | | Amiga IFF | R/W | no | no | | From 99ba7f2688a7f7ea2cb73bfed454d41927497e37 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:53:16 +0200 Subject: [PATCH 0042/1452] =?UTF-8?q?Layer=20Style:=20user=20contour=20lib?= =?UTF-8?q?rary=20with=20New=E2=80=A6,=20Load/Replace/Save=20Contours=20(#?= =?UTF-8?q?4391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 1 + crates/engine/src/contour_lib_cmds.rs | 300 ++++++++++++++++++++++ crates/engine/src/layer_style.rs | 27 +- crates/engine/src/lib.rs | 1 + crates/engine/src/presets/mod.rs | 10 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/layer_style.rs | 2 +- crates/ui-egui/src/layer_style/contour.rs | 156 ++++++++++- crates/ui-egui/src/swatches_ui.rs | 2 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 29 files changed, 602 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/contour_lib_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5524388c71..c445532b19 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1178,6 +1178,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::contour_lib_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); diff --git a/crates/engine/src/contour_lib_cmds.rs b/crates/engine/src/contour_lib_cmds.rs new file mode 100644 index 0000000000..4b1b0a0a29 --- /dev/null +++ b/crates/engine/src/contour_lib_cmds.rs @@ -0,0 +1,300 @@ +//! The user contour library of the Layer Style dialog (`layer.layerStyle.contours.*`). +//! +//! Photoshop's Contour Editor "New…" saves the edited curve under a name, and the contour picker +//! lists those contours after its standard ones (picker menu: Load, Save and Replace Contours). +//! The library is [`PresetState::contours`](crate::presets::PresetState); like the other preset +//! sets it lives in the preferences document and changes the preferences, not a document's +//! history. A user contour is applied to an effect as a `contour` curve object (see +//! [`contour_param`]); `.shc` files go through `photocraft_psd::shc` (see [`contours_from_shc`]). + +use photocraft_doc::Contour; +use photocraft_doc::adjust::CurvePoint; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::layer_style::{builtin_contour, contour_from_param, contour_param, contour_to_shc_all, contours_from_shc}; +use crate::presets::{always, bad, str_param}; +use crate::{Result, Session}; + +/// Most user contours kept (further ones are refused / dropped on load). +pub const MAX_USER_CONTOURS: usize = 1000; +/// Longest contour name kept, in characters. +pub const MAX_NAME: usize = 255; + +fn name_of(c: &Contour) -> &str { + match c { + Contour::Linear => "Linear", + Contour::Custom { name, .. } => name, + } +} + +fn clip(s: &str) -> String { + s.chars().take(MAX_NAME).collect() +} + +/// `c` as a named custom contour (Linear becomes the identity curve), so it can be stored. +fn named(c: Contour, name: &str) -> Contour { + match c { + Contour::Linear => Contour::Custom { + name: name.to_string(), + points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], + corners: Vec::new(), + }, + Contour::Custom { points, corners, .. } => Contour::Custom { name: name.to_string(), points, corners }, + } +} + +/// `base`, or `base 2`, `base 3`… so a name is neither a standard contour's nor a user contour's +/// (a name alone selects a standard contour, so a user contour may not shadow one). +fn unique(lib: &[Contour], base: &str) -> String { + let taken = |n: &str| builtin_contour(n).is_some() || lib.iter().any(|c| name_of(c) == n); + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +/// Adds `c` under `name` (made unique); the stored name. +fn add(lib: &mut Vec, c: Contour, name: &str) -> String { + let n = unique(lib, &clip(name)); + lib.push(named(c, &n)); + n +} + +/// The library as saved with the preferences. +pub fn to_json(lib: &[Contour]) -> Value { + Value::Array(lib.iter().map(contour_param).collect()) +} + +/// Restores a saved library, skipping entries that aren't usable contours; never refuses. +pub fn from_json(v: &Value) -> Vec { + let mut lib = Vec::new(); + for e in v.as_array().into_iter().flatten().take(MAX_USER_CONTOURS) { + // Entries are curve objects; a bare name would only name a standard contour. + if let Some(c) = e.is_object().then(|| contour_from_param(e)).flatten() { + add(&mut lib, c.clone(), name_of(&c)); + } + } + lib +} + +fn list(s: &mut Session, _: &Value) -> Result { + let lib = &s.presets.contours; + Ok(json!({"count": lib.len(), "contours": lib.iter().map(|c| json!({"name": name_of(c), "contour": contour_param(c)})).collect::>()})) +} + +fn new(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.new"; + let c = contour_from_param(p.get("contour").ok_or_else(|| bad(CMD, "missing `contour` (a standard contour's name or a curve object)"))?) + .ok_or_else(|| bad(CMD, "`contour` is not a usable contour (unknown name, or a curve with fewer than 2 or more than 64 points)"))?; + if s.presets.contours.len() >= MAX_USER_CONTOURS { + return Err(bad(CMD, format!("the contour library is full ({MAX_USER_CONTOURS} contours)"))); + } + let name = match p.get("name") { + None | Some(Value::Null) => format!("Contour {}", s.presets.contours.len() + 1), + Some(Value::String(n)) if !n.trim().is_empty() => n.trim().to_string(), + Some(_) => return Err(bad(CMD, "`name` must be a non-empty string")), + }; + let name = add(&mut s.presets.contours, c, &name); + s.presets_changed(); + let contour = s.presets.contours.last().map(contour_param).unwrap_or(Value::Null); + Ok(json!({"name": name, "contour": contour, "count": s.presets.contours.len()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing `name`"))?; + let at = s + .presets + .contours + .iter() + .position(|c| name_of(c) == name) + .ok_or_else(|| bad(CMD, format!("no user contour \"{name}\" (see layer.layerStyle.contours.list)")))?; + s.presets.contours.remove(at); + s.presets_changed(); + Ok(json!({"deleted": name, "count": s.presets.contours.len()})) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.load"; + let replace = match str_param(p, "mode") { + None | Some("append") => false, + Some("replace") => true, + Some(m) => return Err(bad(CMD, format!("unknown mode `{m}` (append|replace)"))), + }; + let (bytes, _) = crate::preset_import_cmds::file_bytes(p, CMD, ".shc file")?; + let found = contours_from_shc(&bytes)?; + let loaded: Vec = found.iter().filter_map(contour_from_param).collect(); + if loaded.is_empty() { + return Err(bad(CMD, "the file holds no usable contours")); + } + let mut lib = if replace { Vec::new() } else { s.presets.contours.clone() }; + let mut names = Vec::new(); + for c in loaded { + if lib.len() >= MAX_USER_CONTOURS { + break; + } + let n = name_of(&c).to_string(); + names.push(add(&mut lib, c, &n)); + } + s.presets.contours = lib; + s.presets_changed(); + Ok(json!({"loaded": names, "count": s.presets.contours.len(), "skipped": found.len() - names.len()})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.save"; + let lib: Vec<&Contour> = match p.get("names") { + None | Some(Value::Null) => s.presets.contours.iter().collect(), + Some(Value::Array(a)) => { + let mut v = Vec::new(); + for n in a { + let n = n.as_str().ok_or_else(|| bad(CMD, "`names` must be strings"))?; + v.push(s.presets.contours.iter().find(|c| name_of(c) == n).ok_or_else(|| bad(CMD, format!("no user contour \"{n}\"")))?); + } + v + } + Some(_) => return Err(bad(CMD, "`names` must be a list of contour names")), + }; + if lib.is_empty() { + return Err(bad(CMD, "there are no user contours to save")); + } + let bytes = contour_to_shc_all(&lib)?; + let mut out = json!({"count": lib.len(), "bytes": bytes.len()}); + match str_param(p, "path") { + Some(path) => { + crate::file_cmds::write_file(path, &bytes)?; + out["path"] = json!(path); + } + None => out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)), + } + Ok(out) +} + +/// Contour library command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "layer.layerStyle.contours.list", + label: "List User Contours", + menu: &[], + shortcut: None, + params: "{} → {count, contours:[{name, contour: curve object usable as any `contour` param}]}", + enabled: always, + run: list, + journal: false, + }, + CommandSpec { + id: "layer.layerStyle.contours.new", + label: "New Contour…", + menu: &[], + shortcut: None, + params: r##"{"contour":name|{"name":str,"points":[[in 0..1,out 0..1],…],"corners":[bool,…]},"name":str?="Contour N"} → {name (made unique, never a standard contour's name), contour, count} (Contour Editor › New…)"##, + enabled: always, + run: new, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.delete", + label: "Delete Contour", + menu: &[], + shortcut: None, + params: r##"{"name":str} → {deleted, count}"##, + enabled: always, + run: delete, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.load", + label: "Load Contours…", + menu: &[], + shortcut: None, + params: r##"{"path":".shc file"?,"data":base64 bytes?,"mode":"append"|"replace"="append"} → {loaded, count, skipped} (picker menu: Load Contours… / Replace Contours…)"##, + enabled: always, + run: load, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.save", + label: "Save Contours…", + menu: &[], + shortcut: None, + params: r##"{"path":".shc file"?,"names":[str]?=all} → {count, bytes, path | data (base64 when no path)}"##, + enabled: always, + run: save, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn curve(name: &str) -> Value { + json!({"name": name, "points": [[0.0, 0.0], [0.3, 0.9], [1.0, 1.0]], "corners": [false, true, false]}) + } + + #[test] + fn new_save_load_round_trips_and_names_stay_unique() { + let mut s = Session::new(); + let r = new(&mut s, &json!({"contour": curve("x"), "name": "Ramp"})).unwrap(); + assert_eq!(r["name"], "Ramp"); + // A standard contour's name can't be shadowed; repeats get a suffix. + assert_eq!(new(&mut s, &json!({"contour": curve("x"), "name": "Cone"})).unwrap()["name"], "Cone 2"); + assert_eq!(new(&mut s, &json!({"contour": "Ring", "name": "Ramp"})).unwrap()["name"], "Ramp 2"); + let saved = save(&mut s, &json!({})).unwrap(); + let data = saved["data"].clone(); + assert_eq!(saved["count"], 3); + // Replace swaps the library for the file's contours; the curve and its corner survive. + delete(&mut s, &json!({"name": "Ramp"})).unwrap(); + let r = load(&mut s, &json!({"data": data, "mode": "replace"})).unwrap(); + assert_eq!(r["count"], 3); + let c = contour_from_param(&list(&mut s, &json!({})).unwrap()["contours"][0]["contour"]).unwrap(); + assert!(c.is_corner(1)); + // Appending the same file again keeps every name unique. + let r = load(&mut s, &json!({"data": data})).unwrap(); + assert_eq!(r["count"], 6); + let names: std::collections::HashSet<_> = s.presets.contours.iter().map(|c| name_of(c).to_string()).collect(); + assert_eq!(names.len(), 6); + } + + #[test] + fn library_persists_with_the_presets() { + let mut s = Session::new(); + new(&mut s, &json!({"contour": curve("x"), "name": "Mine"})).unwrap(); + let saved = s.presets.to_json(&s); + let mut t = Session::new(); + t.load_presets_json(saved); + assert_eq!(t.presets.contours.len(), 1); + assert_eq!(name_of(&t.presets.contours[0]), "Mine"); + assert_eq!(t.presets.contours, s.presets.contours); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + for p in + [json!({}), json!({"contour": 5}), json!({"contour": "Nope"}), json!({"contour": {"points": [[0, 0]]}}), json!({"contour": curve("x"), "name": 3})] + { + assert!(new(&mut s, &p).is_err(), "{p}"); + } + assert!(delete(&mut s, &json!({"name": "ghost"})).is_err()); + assert!(delete(&mut s, &json!({})).is_err()); + assert!(save(&mut s, &json!({})).is_err(), "empty library"); + for p in [ + json!({}), + json!({"data": 3}), + json!({"data": "!!"}), + json!({"data": "AAAA"}), + json!({"data": "AAAA", "mode": "x"}), + json!({"path": "/nonexistent/x.shc"}), + ] { + assert!(load(&mut s, &p).is_err(), "{p}"); + } + assert!(s.presets.contours.is_empty()); + let big = from_json(&json!([{"points": [[0, 0]]}, "Ring", 7, {"name": "Cone", "points": [[0, 0], [1, 1]]}])); + assert_eq!(big.len(), 1); + assert_eq!(name_of(&big[0]), "Cone 2"); + } +} diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 878bcaca0a..5ea402840f 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -448,16 +448,27 @@ pub fn contours_from_shc(bytes: &[u8]) -> Result> { /// An `.shc` file holding `contour` (Contour Editor › Save…). pub fn contour_to_shc(contour: &Contour) -> Result> { - let (name, points) = match contour { - Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]), - Contour::Custom { name, points, .. } => (name.clone(), points.clone()), - }; - let points = points + contour_to_shc_all(&[contour]) +} + +/// An `.shc` file holding every contour of `list` (Save Contours…). +pub fn contour_to_shc_all(list: &[&Contour]) -> Result> { + let file: Vec = list .iter() - .enumerate() - .map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) + .map(|contour| { + let (name, points) = match contour { + Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]), + Contour::Custom { name, points, .. } => (name.clone(), points.clone()), + }; + let points = points + .iter() + .enumerate() + .map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) + .collect(); + photocraft_psd::shc::ShcContour { name, points } + }) .collect(); - photocraft_psd::shc::write(&[photocraft_psd::shc::ShcContour { name, points }]).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) + photocraft_psd::shc::write(&file).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) } /// An effect of `kind` with its factory settings (before params are applied). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9cb725518..e0b7f199f3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub mod color_cmds; pub mod color_to_alpha_cmds; pub mod commands; pub mod comps_cmds; +pub mod contour_lib_cmds; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 5a72f781fc..8d8f61d5ba 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -89,6 +89,8 @@ pub struct PresetState { pub layer_defaults: BTreeMap, /// Swatches panel library. pub swatches: Vec>, + /// The user contour library of the Layer Style dialog (`layer.layerStyle.contours.*`). + pub contours: Vec, /// Bumped on every preset change (UIs key thumbnail caches on it). pub rev: u64, } @@ -108,6 +110,7 @@ impl Default for PresetState { clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), + contours: Vec::new(), rev: 0, } } @@ -139,6 +142,9 @@ struct Persisted { #[serde(default)] layer_defaults: Option>, swatches: Option>>, + // Read entry by entry: a bad contour must not discard the other presets. + #[serde(default)] + contours: Option, } impl PresetState { @@ -156,6 +162,7 @@ impl PresetState { custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), + contours: Some(crate::contour_lib_cmds::to_json(&self.contours)), }; serde_json::to_value(p).unwrap_or(Value::Null) } @@ -202,6 +209,9 @@ impl Session { crate::swatch_cmds::cap_library(&mut g); self.presets.swatches = g; } + if let Some(c) = p.contours { + self.presets.contours = crate::contour_lib_cmds::from_json(&c); + } self.presets.rev += 1; } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e7de3409af..10e403c3e4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3665,3 +3665,9 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Load Contours… Načíst obrysy… + Replace Contours… Nahradit obrysy… + Save Contours… Uložit obrysy… + Delete Contour Smazat obrys + Loaded contours from {file} Obrysy načteny ze souboru {file} + Saved contours to {file} Obrysy uloženy do souboru {file} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ac313154db..84bd096ddb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2691,3 +2691,9 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Load Contours… Konturen laden… + Replace Contours… Konturen ersetzen… + Save Contours… Konturen speichern… + Delete Contour Kontur löschen + Loaded contours from {file} Konturen aus {file} geladen + Saved contours to {file} Konturen in {file} gespeichert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e5be105597..21d9817f89 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3749,3 +3749,9 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Load Contours… Φόρτωση περιγραμμάτων… + Replace Contours… Αντικατάσταση περιγραμμάτων… + Save Contours… Αποθήκευση περιγραμμάτων… + Delete Contour Διαγραφή περιγράμματος + Loaded contours from {file} Φορτώθηκαν περιγράμματα από {file} + Saved contours to {file} Τα περιγράμματα αποθηκεύτηκαν στο {file} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b986956544..188e5396be 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2732,3 +2732,9 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + Load Contours… Cargar contornos… + Replace Contours… Reemplazar contornos… + Save Contours… Guardar contornos… + Delete Contour Eliminar contorno + Loaded contours from {file} Contornos cargados de {file} + Saved contours to {file} Contornos guardados en {file} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f426b28c56..f51b1f0083 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3052,3 +3052,9 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Load Contours… Charger des contours… + Replace Contours… Remplacer les contours… + Save Contours… Enregistrer les contours… + Delete Contour Supprimer le contour + Loaded contours from {file} Contours chargés depuis {file} + Saved contours to {file} Contours enregistrés dans {file} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 080e204aaa..e198e47d06 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3970,3 +3970,9 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + Load Contours… Kontúrok betöltése… + Replace Contours… Kontúrok cseréje… + Save Contours… Kontúrok mentése… + Delete Contour Kontúr törlése + Loaded contours from {file} Kontúrok betöltve: {file} + Saved contours to {file} Kontúrok mentve: {file} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 420600cf3f..51ecd6305c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3039,3 +3039,9 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Load Contours… Muat kontur… + Replace Contours… Ganti kontur… + Save Contours… Simpan kontur… + Delete Contour Hapus kontur + Loaded contours from {file} Kontur dimuat dari {file} + Saved contours to {file} Kontur disimpan ke {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 410ab8f609..b102959d28 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4015,3 +4015,9 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Load Contours… Carica profili… + Replace Contours… Sostituisci profili… + Save Contours… Salva profili… + Delete Contour Elimina profilo + Loaded contours from {file} Profili caricati da {file} + Saved contours to {file} Profili salvati in {file} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7e5e65a639..7fcf17895d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3661,3 +3661,9 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Load Contours… カーブを読み込み… + Replace Contours… カーブを置き換え… + Save Contours… カーブを保存… + Delete Contour カーブを削除 + Loaded contours from {file} {file} からカーブを読み込みました + Saved contours to {file} カーブを {file} に保存しました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f48633c2cb..447ef4bfb4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3221,3 +3221,9 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Load Contours… 윤곽선 불러오기… + Replace Contours… 윤곽선 바꾸기… + Save Contours… 윤곽선 저장… + Delete Contour 윤곽선 삭제 + Loaded contours from {file} {file}에서 윤곽선을 불러왔습니다 + Saved contours to {file} 윤곽선을 {file}에 저장했습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3778a8a1de..9340e71b3b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3811,3 +3811,9 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Load Contours… Contouren laden… + Replace Contours… Contouren vervangen… + Save Contours… Contouren opslaan… + Delete Contour Contour verwijderen + Loaded contours from {file} Contouren geladen uit {file} + Saved contours to {file} Contouren opgeslagen in {file} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ce9387b623..c51d0aeaa6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3648,3 +3648,9 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Load Contours… Wczytaj kontury… + Replace Contours… Zamień kontury… + Save Contours… Zapisz kontury… + Delete Contour Usuń kontur + Loaded contours from {file} Wczytano kontury z {file} + Saved contours to {file} Zapisano kontury w {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 92e9c238b1..e70106ce8e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3841,3 +3841,9 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Load Contours… Carregar contornos… + Replace Contours… Substituir contornos… + Save Contours… Salvar contornos… + Delete Contour Excluir contorno + Loaded contours from {file} Contornos carregados de {file} + Saved contours to {file} Contornos salvos em {file} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e1d12bbb1..b5e27f5f53 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2996,3 +2996,9 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Load Contours… Загрузить контуры… + Replace Contours… Заменить контуры… + Save Contours… Сохранить контуры… + Delete Contour Удалить контур + Loaded contours from {file} Контуры загружены из {file} + Saved contours to {file} Контуры сохранены в {file} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 617ad81fd9..85b8ac944a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3782,3 +3782,9 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + Load Contours… Konturları yükle… + Replace Contours… Konturları değiştir… + Save Contours… Konturları kaydet… + Delete Contour Konturu sil + Loaded contours from {file} Konturlar {file} dosyasından yüklendi + Saved contours to {file} Konturlar {file} dosyasına kaydedildi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 11a4caa9cf..3fbeb9bca5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3749,3 +3749,9 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Load Contours… Завантажити контури… + Replace Contours… Замінити контури… + Save Contours… Зберегти контури… + Delete Contour Видалити контур + Loaded contours from {file} Контури завантажено з {file} + Saved contours to {file} Контури збережено в {file} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca091c14d8..2762fb3e16 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3535,3 +3535,9 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Load Contours… Tải đường viền… + Replace Contours… Thay thế đường viền… + Save Contours… Lưu đường viền… + Delete Contour Xóa đường viền + Loaded contours from {file} Đã tải đường viền từ {file} + Saved contours to {file} Đã lưu đường viền vào {file} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d0309166ce..36c742cd50 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2656,3 +2656,9 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Load Contours… 载入等高线… + Replace Contours… 替换等高线… + Save Contours… 存储等高线… + Delete Contour 删除等高线 + Loaded contours from {file} 已从 {file} 载入等高线 + Saved contours to {file} 已将等高线存储到 {file} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8698dfc078..dd4261db89 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2641,3 +2641,9 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Load Contours… 載入等高線… + Replace Contours… 取代等高線… + Save Contours… 儲存等高線… + Delete Contour 刪除等高線 + Loaded contours from {file} 已從 {file} 載入等高線 + Saved contours to {file} 已將等高線儲存到 {file} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index b350cef521..c8cd96cd46 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -987,7 +987,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map contour::row(ui, f, &selected, key, label, aa, &mut disp), + P::Contour(aa) => contour::row(app, ui, f, &selected, key, label, aa, &mut disp), P::Check => { let mut b = disp.get(key).and_then(Value::as_bool).unwrap_or(false); if widgets::checkbox(ui, &mut b, label).changed() { diff --git a/crates/ui-egui/src/layer_style/contour.rs b/crates/ui-egui/src/layer_style/contour.rs index 2f43c676c0..d6e8eb490e 100644 --- a/crates/ui-egui/src/layer_style/contour.rs +++ b/crates/ui-egui/src/layer_style/contour.rs @@ -70,13 +70,36 @@ fn picker_target(effect: &str, key: &str) -> Value { /// Picks a preset for `effect`'s `key` (the picker's click). pub(super) fn pick(f: &mut Map, effect: &str, key: &str, name: &str) { - set_param(f, effect, key, json!(name)); + pick_param(f, effect, key, json!(name)); +} + +/// Picks a contour param (a preset name, or a user contour's curve object). +pub(super) fn pick_param(f: &mut Map, effect: &str, key: &str, param: Value) { + set_param(f, effect, key, param); f.remove(PICKER); } +/// What the picker's menu asked for (Photoshop's Load, Save and Replace Contours). +#[derive(Clone, Copy, PartialEq)] +enum Act { + Load, + Replace, + Save, +} + /// The Contour row of an effect page: label, thumbnail (opens the Contour Editor), the preset /// picker arrow and, when the effect has one, Anti-aliased. -pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, key: &str, label: &str, aa_key: Option<&str>, disp: &mut Value) { +#[allow(clippy::too_many_arguments)] +pub(super) fn row( + app: &mut PhotocraftApp, + ui: &mut egui::Ui, + f: &mut Map, + effect: &str, + key: &str, + label: &str, + aa_key: Option<&str>, + disp: &mut Value, +) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { if label.is_empty() { @@ -105,6 +128,10 @@ pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, k open = !open; } let mut chosen: Option<&str> = None; + let mut chosen_user: Option = None; + let mut delete: Option = None; + let mut act: Option = None; + let user = app.session.presets.contours.clone(); egui::Popup::from_response(&arrow).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.spacing_mut().item_spacing = vec2(4.0, 4.0); egui::Grid::new(("contour-presets", effect, key)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { @@ -123,7 +150,55 @@ pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, k } } }); + // The user's own contours follow the standard ones (Contour Editor › New…). + if !user.is_empty() { + ui.add_space(4.0); + egui::Grid::new(("contour-user", effect, key)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, u) in user.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); + draw(ui.painter(), r, u, t.text, &t); + if *u == c || resp.hovered() { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } + let name = name_of(u).to_string(); + let resp = resp.on_hover_text(name.as_str()); + if resp.clicked() { + chosen_user = Some(contour_param(u)); + } + resp.context_menu(|ui| { + if ui.button(tl!("Delete Contour")).clicked() { + delete = Some(name.clone()); + ui.close(); + } + }); + if i % 4 == 3 { + ui.end_row(); + } + } + }); + } + ui.separator(); + if ui.button(tl!("Load Contours…")).clicked() { + act = Some(Act::Load); + } + if ui.button(tl!("Replace Contours…")).clicked() { + act = Some(Act::Replace); + } + if ui.add_enabled(!user.is_empty(), egui::Button::new(tl!("Save Contours…"))).clicked() { + act = Some(Act::Save); + } }); + if let Some(name) = delete { + let r = app.run("layer.layerStyle.contours.delete", json!({"name": name})); + report(app, r); + } + if let Some(act) = act { + library_action(app, act); + } + if let Some(param) = chosen_user { + disp[key] = param.clone(); + pick_param(f, effect, key, param); + } if let Some(name) = chosen { disp[key] = json!(name); pick(f, effect, key, name); @@ -259,6 +334,10 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; let mut load = false; let mut save = false; + let naming_id = mem.with("naming"); + let mut naming: Option = ctx.data(|d| d.get_temp(naming_id)); + let mut new_clicked = false; + let mut commit_name = false; let mut preset: Option = None; let modal = egui::Modal::new(mem.with("modal")).show(&ctx, |ui| { ui.label(RichText::new(tl!("Contour Editor")).font(crate::theme::semibold(14.0)).color(t.text)); @@ -321,6 +400,20 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map set_param(f, &effect, &key, v["contour"].clone()), + Err(e) => report(app, Err(e)), + } + } + ctx.data_mut(|d| match &naming { + Some(n) => { + d.insert_temp(naming_id, n.clone()); + } + None => { + d.remove_temp::(naming_id); + } + }); if let Some(name) = preset { set_param(f, &effect, &key, json!(name)); } if close.is_none() && modal.should_close() { close = Some(false); } - if close.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + if close.is_none() && naming.is_none() && !commit_name && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { close = Some(true); } if let Some(keep) = close { @@ -396,6 +510,42 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Contour { + Contour::Custom { + name: name.to_string(), + points: pts.iter().map(|q| CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(), + corners: corners.to_vec(), + } +} + +/// Load, Replace or Save Contours: the library lives in the session, the file is an `.shc`. +fn library_action(app: &mut PhotocraftApp, act: Act) { + let r = match act { + Act::Load | Act::Replace => app.pick_file_bytes_filtered(&["shc"], move |app, name, bytes| { + let mut p = crate::swatches_ui::source(&name, &bytes); + p["mode"] = json!(if act == Act::Replace { "replace" } else { "append" }); + let r = app.run("layer.layerStyle.contours.load", p)?; + let template = "Loaded contours from {file}"; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt(tl!(template), &[("file", &crate::file_open::display_name(&name))]); + Ok(r) + }), + Act::Save => app.run("layer.layerStyle.contours.save", json!({})).and_then(|r| { + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + app.pick_save("Contours.shc", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let template = "Saved contours to {file}"; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt(tl!(template), &[("file", &crate::file_open::display_name(&path))]); + Ok(json!({"path": path})) + }) + }), + }; + report(app, r); +} + fn report(app: &mut PhotocraftApp, r: Result) { if let Err(e) = r && e != crate::file_dialog::CANCELLED diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 3215f187b1..276d40f132 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -140,7 +140,7 @@ fn new_group(app: &mut PhotocraftApp) { /// Command params for a picked file: a real path keeps the journal small; browser files arrive /// as bytes. -fn source(name: &str, bytes: &[u8]) -> Value { +pub(crate) fn source(name: &str, bytes: &[u8]) -> Value { #[cfg(not(target_arch = "wasm32"))] if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() { return json!({"path": name}); diff --git a/docs/scorecard.md b/docs/scorecard.md index 78a0864549..675185c535 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -112,7 +112,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | -| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | +| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index e6943b578c..5b2c55471e 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -108,7 +108,7 @@ id = "FILE-215-15" target = "Layer Style contour picker, curve editor, presets, .shc import/export" status = "partial" issue = 2912 -note = "Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file" +note = "Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file" [[item]] id = "FILE-215-16" From ea3c9506f52fe81cca3f9bdaf74ba213c967fe27 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:11:34 +0200 Subject: [PATCH 0043/1452] i18n(tr): the Content-Aware Fill workspace in the Turkish catalog from main Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/tr.tsv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 617ad81fd9..9aa2043e18 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3782,3 +3782,19 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile +# Edit › Content-Aware Fill workspace + Excluded Area Hariç Tutulan Alan + Fill Settings Dolgu Ayarları + Full Tam + Indicates Gösterir + Mirror Yansıt + Output Settings Çıkış Ayarları + Output To Çıkış Hedefi + Rectangular Dikdörtgen + Rotation Adaptation Döndürme Uyarlaması + Sampling Area Örnekleme Alanı + Sampling Area Options Örnekleme Alanı Seçenekleri + Sampling Area Overlay Örnekleme Alanı Kaplaması + Sampling Brush Tool Örnekleme Fırçası Aracı + Show Sampling Area Örnekleme Alanını Göster + Very High Çok Yüksek From 100cedbdb7314fabe9afb8eb26f697f7e411f662 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:23:26 +0200 Subject: [PATCH 0044/1452] i18n(hu): the Content-Aware Fill workspace in the Hungarian catalog from main Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/hu.tsv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 080e204aaa..76b54bf07e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3970,3 +3970,19 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra +# Edit › Content-Aware Fill workspace + Excluded Area Kizárt terület + Fill Settings Kitöltési beállítások + Full Teljes + Indicates Jelzi + Mirror Tükrözés + Output Settings Kimeneti beállítások + Output To Kimenet célja + Rectangular Téglalap + Rotation Adaptation Elforgatás-adaptáció + Sampling Area Mintavételi terület + Sampling Area Options Mintavételi terület beállításai + Sampling Area Overlay Mintavételi terület átfedése + Sampling Brush Tool Mintavételi ecset + Show Sampling Area Mintavételi terület megjelenítése + Very High Nagyon magas From b2f67ef31f15c16ddf8936e0b930b502da3169d9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:02:41 +0200 Subject: [PATCH 0045/1452] Automation: classify contour library load/save paths in the workspace sandbox (#4391) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 76a14c6e34..dc57990e33 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -362,12 +362,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" | "layer.layerStyle.contours.load" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "swatches.export" => params.get("path").is_some(), + "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(), "plugin.reload" => true, _ => false, } @@ -762,6 +762,8 @@ mod tests { ("plugin.install", serde_json::json!({"path": " "})), ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), + ("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})), + ("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -771,6 +773,8 @@ mod tests { ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), ("swatches.export", serde_json::json!({"format": "ase"})), + ("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})), + ("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } From 52399524fee627c9e31efac6efebd53cd0c44787 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:00:48 +0200 Subject: [PATCH 0046/1452] i18n(it): drop the workspace's rows main's Italian catalog now has (High, Low, Duplicate Layer, Contract) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/it.tsv | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7899870fd1..30722ebab5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2655,14 +2655,10 @@ cameraData Lens Obiettivo Fill Settings Impostazioni di riempimento Rotation Adaptation Adattamento rotazione Very High Molto alta - High Alta - Low Bassa Full Completa Mirror Rifletti Output Settings Impostazioni di output Output To Output in - Duplicate Layer Duplica livello - Contract Contrai Loading fonts… Caricamento font… Tool Strumento Simulate Pressure Simula pressione From 932303788371aaedc150fb198a722cb713de6002 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:28 +0300 Subject: [PATCH 0047/1452] Keep Smart Filter and effect rows compact at every thumbnail size Photoshop keeps the Effects, effect and Smart Filter rows at their compact height and font at every Layers panel thumbnail size; only the layer row grows, and the Smart Filters header grows just enough for its shared-mask thumbnail, which follows the thumbnail size (hidden at None). Previously every sub-row took the layer row height and a 13 pt font at Large, so sub-rows outsized the layer name. Keep sub-rows at 22 px, 11.5 pt and a 12 px eye; scale only the Smart Filters header and its mask. Effect rows are back to main's layout. The test checks both and can save renders with PHOTOCRAFT_LAYERS_SNAPSHOTS=. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 7 +++--- crates/ui-egui/src/smart_ui.rs | 46 +++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 483539ae44..87336ce045 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3859,7 +3859,6 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); - let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3867,7 +3866,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3884,7 +3883,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3906,7 +3905,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(font_size), + egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index a563853df9..79cdd47c6a 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -33,16 +33,15 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } -/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column -/// remains independently clickable, while the filter mask and text keep their -/// own alignment and avoid overlap when the thumbnail is set to None. -pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { - let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); - let height = row.max(22.0); - let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; - let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; - let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; - (height, mask, eye, font) +/// Height of a Smart Filter or effect sub-row. As in Photoshop, these stay compact at every +/// Layers panel thumbnail size; only the layer row itself grows with its thumbnails. +pub(crate) const SUB_ROW_H: f32 = 22.0; + +/// The Smart Filters header row: (row height, shared-mask thumbnail side). The mask follows the +/// Layers panel thumbnail size (hidden at None) and the row grows just enough to hold it. +pub(crate) fn smart_filters_header_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32) { + let (thumb, _) = crate::layers_panel_ui::thumbnail_geometry(size, pro); + if thumb == 0.0 { (SUB_ROW_H, 0.0) } else { ((thumb + 6.0).max(SUB_ROW_H), thumb) } } /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart @@ -53,11 +52,11 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); - let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); + let (header_height, mask_size) = smart_filters_header_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), if index.is_none() { header_height } else { SUB_ROW_H }), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -75,7 +74,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -98,7 +97,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: rect.center().y, rect.right() - crate::layer_row_ui::RIGHT_PAD, &name, - egui::FontId::proportional(font_size), + egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); @@ -154,7 +153,7 @@ mod layer_thumbnail_scaling_tests { use egui_kittest::{Harness, kittest::Queryable}; #[test] - fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { + fn sub_rows_stay_compact_while_the_smart_filter_mask_follows_thumbnail_setting() { for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { let mut session = photocraft_engine::Session::new(); session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); @@ -170,10 +169,21 @@ mod layer_thumbnail_scaling_tests { }); h.state_mut().ui.layers_panel_options.thumbnail_size = setting; h.run_steps(8); - let expected = smart_filter_row_geometry(setting, true).0; - for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask", "Effects", "Drop Shadow"] { + // Filter and effect rows keep Photoshop's compact height at every thumbnail size. + for label in ["Gaussian Blur", "Unsharp Mask", "Effects", "Drop Shadow"] { let rect = h.get_by_label(label).rect(); - assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); + assert!((rect.height() - SUB_ROW_H).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {SUB_ROW_H}"); + } + // Only the Smart Filters header grows, just enough for its shared-mask thumbnail. + let (header, mask) = smart_filters_header_geometry(setting, true); + let rect = h.get_by_label("Smart Filters").rect(); + assert!((rect.height() - header).abs() < 1.0, "{setting:?}: Smart Filters was {rect:?}, expected {header}"); + assert!(mask <= header - 6.0 || mask == 0.0, "{setting:?}: a {mask} mask must fit a {header} row"); + assert_eq!(mask == 0.0, setting == LayerThumbnailSize::None, "{setting:?}: the mask hides only at None"); + if let Some(dir) = std::env::var_os("PHOTOCRAFT_LAYERS_SNAPSHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("layers-sub-rows-{setting:?}.png"))).unwrap(); } } } From fffa54068ce49840345d5e7caf4ed88ba31de180 Mon Sep 17 00:00:00 2001 From: Florent Siegenthaler <64897087+Atalantis@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:43:09 +0200 Subject: [PATCH 0048/1452] i18n: complete the French (fr) UI translation (1,041 entries) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 1,041 keys that other catalogs translate and fr.tsv lacked, so French covers the union of all catalog keys. New entries reuse the vocabulary the French catalog already uses (Canal, Rastériser, Canevas, Masque de calque, Objet intelligent); placeholders, edge spaces, trailing ellipses and the two French plural forms are preserved. --- crates/ui-egui/src/i18n/fr.tsv | 1041 ++++++++++++++++++++++++++++++++ 1 file changed, 1041 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a1319f04fb..88e2bc80f4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3053,3 +3053,1044 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + (none) (aucun) + 32-bit Preview Options Options d'aperçu 32 bits + AI Integrations Intégrations d'IA + About {app} À propos de {app} + Accented Edges Contours accentués + Action playback failed: {error} Échec de la lecture de l'action : {error} + Actual Pixels Pixels réels + Add Mask Ajouter un masque + Add Noise Ajout de bruit + Added swatch {name} Nuance {name} ajoutée + Adjust Interval For Loops Ajuster l'intervalle pour les boucles + Adjustment Layers Calques d'ajustement + Adjustment Properties Propriétés de l'ajustement + Adobe-rgb-compat Adobe RGB (compatible) + Adwaita (Dark) Adwaita (sombre) + Adwaita (Light) Adwaita (clair) + After Après + All shortcuts reset to Photoshop defaults. Tous les raccourcis ont été réinitialisés aux valeurs par défaut de Photoshop. + Ambience Ambiance + Ambient Ambiante + Amplitude Max Amplitude max. + Amplitude Min Amplitude min. + Angled Strokes Traits obliques + Anisotropic Anisotrope + Apply Data Set Appliquer un jeu de données + Apply Image Appliquer une image + Apply Mask Appliquer le masque + Apply to All Appliquer à tout + Apply {option}: {state} Appliquer {option} : {state} + Arbitrary Angle personnalisé + Arc Lower Arc inférieur + Arc Upper Arc supérieur + Are you sure you want to quit? Voulez-vous vraiment quitter ? + Are you sure? Êtes-vous sûr ? + Area Aire + Around Center Autour du centre + Arrangement Disposition + Arrows Flèches + Artboard from Group Plan de travail d'après un groupe + Artboard from Layers Plan de travail d'après des calques + Artboards Plans de travail + Artboards to Files Plans de travail en fichiers + Artboards to PDF Plans de travail en PDF + Artistic Artistique + Ask On Mismatch Demander en cas de non-concordance + Ask On Missing Demander en cas d'absence + Ask On Paste Demander au collage + Assign Profile Attribuer un profil + At À + Auto Scale Mise à l'échelle auto + Auto-Align Layers Alignement automatique des calques + Auto-Blend Layers Fusion automatique des calques + Autosave failed: {error} Échec de l'enregistrement automatique : {error} + Back Retour + Backend: {backend} ({type}) Moteur : {backend} ({type}) + Background Level Niveau d'arrière-plan + Bamboo Bambou + Bas Relief Bas-relief + Base Tree Type Type d'arbre de base + Basic Latin Latin de base + Batch Traitement par lots + Before Avant + Berries Baies + Best Optimale + Black Clip Écrêtage des noirs + Black Intensity Intensité du noir + Blade Curvature Courbure des lames + Blast Rafale + Bleach Bypass Sans blanchiment + Blend Clipped Layers as Group Fusionner les calques découpés en tant que groupe + Blend Interior Effects as Group Fusionner les effets internes en tant que groupe + Blend Text Gamma Gamma de fusion du texte + Blending Mode Mode de fusion + Bloat Dilatation + Blocks Blocs + Blue Yellow Bleu/Jaune + Blur Angle Angle du flou + Border Bordure + Border Thickness Épaisseur de la bordure + Box Blur Flou par boîte + Bpc Compensation du point noir + Branches Color Couleur des branches + Branches Height Hauteur des branches + Branches Thickness Épaisseur des branches + Breeze Dark Breeze sombre + Breeze Light Breeze clair + Brick Brique + Bristle Detail Détail des poils + Browse Parcourir + Brush Detail Détail du pinceau + Brush Strokes Traits de pinceau + Brush Type Type de pinceau + Burlap Toile de jute + Calculations Calculs + Camera Raw Camera Raw + Camera Raw Filter Filtre Camera Raw + Cancel transform Annuler la transformation + Candle Light Bougie + Canvas Size Taille du canevas + Cell Size Taille des cellules + Center X Centre X + Center Y Centre Y + Chalk Area Zone de craie + Changes Saved Modifications enregistrées + Changes have not been saved. Les modifications n'ont pas été enregistrées. + Change… Modifier… + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Charcoal Area Zone de fusain + Charcoal Thickness Épaisseur du fusain + Check for Updates Rechercher des mises à jour + Choose Folder Choisir un dossier + Circle Cercle + Circularity Circularité + Cleanliness Propreté + Clear Amount Zone sans lueur + Clear Selection Effacer la sélection + Clipping Mask Masque de découpe + Clipping Path… Tracé de détourage… + Clone Cloner + Clone source set at {x}, {y} Source de clonage définie en {x}, {y} + Close Window Fermer la fenêtre + Clumped Aggloméré + Coarse Dots Points grossiers + Coated-cmyk CMJN papier couché + Color Decontaminate Décontaminer les couleurs + Color Halftone Trame de demi-teintes couleur + Color Indicates Masked Areas La couleur indique les zones masquées + Color Indicates Selected Areas La couleur indique les zones sélectionnées + Color Lookup Tables Tables de correspondance des couleurs + Color Picker Sélecteur de couleurs + Color Picker (Background Color) Sélecteur de couleurs (Couleur d'arrière-plan) + Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée du canevas) + Color Picker (Foreground Color) Sélecteur de couleurs (Couleur de premier plan) + Color Settings Paramètres de couleur + Color Swatches Nuancier + Color Table Table des couleurs + Color management: {error} Gestion des couleurs : {error} + Colored Pencil Crayon de couleur + Commit transform Valider la transformation + Compare Comparer + Compression Compression + Conditional Mode Change Changement de mode conditionnel + Confirm Confirmer + Constraints Contraintes + Content-Aware Fill Remplissage d'après le contenu + Continue Continuer + Contract Contracter + Contrasty Contrasté + Convert Convertir + Convert to Alpha Channel Convertir en canal alpha + Convert to Linked Convertir en objet lié + Convert to Profile Convertir en profil + Cool Froid + Cooling L B B Refroidissement LBB + Cooling80 Refroidissement 80 + Cooling82 Refroidissement 82 + Copy Complete Stroke Copier le contour complet + Copy Fill Copier le remplissage + Correct C A Corriger l'aberration chromatique + Correct Distortion Corriger la distorsion + Correct Vignette Corriger le vignettage + Could not rename the action: {error} Impossible de renommer l'action : {error} + Couldn't open {name}: {error} Impossible d'ouvrir {name} : {error} + Couldn't save preferences: {error}. Retrying… Impossible d'enregistrer les préférences : {error}. Nouvelle tentative… + Crack Brightness Luminosité des fissures + Crack Depth Profondeur des fissures + Crack Spacing Espacement des fissures + Craquelure Craquelure + Create By Créer par + Create Vector Mask Créer un masque vectoriel + Create… Créer… + Cross Croix + Cross Process Traitement croisé + Crosshatch Hachures croisées + Crystallize Cristallisation + Currency Devise + Curve Darks Courbe : tons sombres + Curve Highlights Courbe : hautes lumières + Curve Lights Courbe : tons clairs + Curve Shadows Courbe : ombres + Curve Splits Courbe : points de séparation + Custom Branch Color Couleur de branche personnalisée + Custom Pixel Aspect Ratio Format des pixels personnalisé + Cutout Découpage + Cyrillic Cyrillique + Daisies Pâquerettes + Dark Gray Gris foncé + Dark Intensity Intensité des tons foncés + Dark Rough Foncé rugueux + Dark Strokes Traits sombres + Darken Only Assombrir seulement + Darkness Obscurité + Data Données + Data Set: {name} ({index}/{total}) Jeu de données : {name} ({index}/{total}) + Data Sets as Files Jeux de données en fichiers + Date Date + Date and Time Date et heure + Day For Night Nuit américaine + De-Interlace Désentrelacer + Deep Profond + Deep Blue Bleu profond + Deep Emerald Émeraude profond + Deep Red Rouge profond + Deep Yellow Jaune profond + Default Foreground and Background Colors Couleurs par défaut du premier plan et de l'arrière-plan + Default Leaves Feuilles par défaut + Default Photoshop Size Taille Photoshop par défaut + Defaults Valeurs par défaut + Define Brush Preset Définir un préréglage de pinceau + Define Custom Shape Définir une forme personnalisée + Define Pattern Définir un motif + Definition Définition + Defringe Supprimer les franges + Degrees Degrés + Delete Channel Supprimer le canal + Delete Mask Supprimer le masque + Delete… Supprimer… + Depth Map Carte de profondeur + Depth Mode Mode de profondeur + Diameter Diamètre + Diffuse Diffuser + Diffuse Glow Lueur diffuse + Direction Balance Équilibre des directions + Disable Désactiver + Disable Mask Désactiver le masque + Disable Symmetry Path Désactiver le tracé de symétrie + Disabled Désactivé + Discard Changes Ignorer les modifications + Displace Déplacement + Display Affichage + Display profile not used Profil d'affichage non utilisé + Display profiles: {reason} Profils d'affichage : {reason} + Display-p3 Display P3 + Distortion Distorsion + Distribution Distribution + Do you want to save you made to “{name}” before quitting? Voulez-vous enregistrer les modifications apportées à « {name} » avant de quitter ? + Documentation Documentation + Documents Documents + Dot Point + Double Line Double ligne + Draft Brouillon + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · {commit} valide · Échap annule + Drag: draw freehand · Release on the start point: close Glisser : tracer à main levée · Relâcher sur le point de départ : fermer + Driver: {driver} Pilote : {driver} + Dry Brush Pinceau sec + Duplicate Channel Dupliquer le canal + Duplicate Layer Dupliquer le calque + Duplicate Layers Dupliquer les calques + Duplication Duplication + Dust & Scratches Poussière et rayures + Edge Brightness Luminosité du contour + Edge Extension Extension du contour + Edge Fidelity Fidélité des contours + Edge Intensity Intensité du contour + Edge Only Contours seulement + Edge Simplicity Simplicité des contours + Edge Thickness Épaisseur du contour + Edge Width Largeur du contour + Edit Toolbar Modifier la barre d'outils + Edit… Modifier… + Elevation Élévation + Eliminate Éliminer + Enable Activer + Enable Mask Activer le masque + End X Fin X + End Y Fin Y + Enlarged Agrandi + Even Fields Trames paires + Exit Quick Mask Quitter le mode masque rapide + Export As Exporter sous + Export Contents Exporter le contenu + Export Image Exporter l'image + Export Options Options d'exportation + Export Preferences Préférences d'exportation + Export Settings Paramètres d'exportation + Export to Exporter vers + Export/Import Presets Exporter/importer des préréglages + Exported contents to {path} Contenu exporté vers {path} + Exported presets to {path} Préréglages exportés vers {path} + Exported {path} {path} exporté + Exported {path} ({size}) {path} exporté ({size}) + Exporting… Exportation… + Extrude Extruder + Faded Film Film délavé + Failed Échec + Failed to open file Échec de l'ouverture du fichier + Failed to save file Échec de l'enregistrement du fichier + Fallback: {reason} Repli : {reason} + Feather Radius Rayon d'adoucissement + Fiber Length Longueur des fibres + Fibers Fibres + Field Blur Flou de champ + File Format Format de fichier + File Info Informations sur le fichier + File Name Nom du fichier + File Size Taille du fichier + File not found Fichier introuvable + Filename Nom de fichier + Fill Color Couleur de remplissage + Fill Path… Remplir le tracé… + Film Grain Grain de film + Filter Gallery Galerie de filtres + Find Rechercher + Find and Replace Text Rechercher et remplacer du texte + Fine Fine + Fine Dots Points fins + Finish Terminer + Fit Image Adapter l'image + Fit to Window Adapter à la fenêtre + Flame Flamme + Flame Bottom Alignment Alignement du bas de la flamme + Flame Lines Lignes de flamme + Flame Shape Forme de la flamme + Flame Style Style de la flamme + Flame Type Type de flamme + Flat Plat + Flat Shading Ombrage plat + Flower Color Couleur des fleurs + Focal Distance Distance de mise au point + Focus Mise au point + Focus Area Zone de mise au point + Folder Dossier + Foreground Level Niveau de premier plan + Forward Warp Déformation vers l'avant + Frame Cadre + Free Transform Path Transformation libre du tracé + Freeze Lasso Lasso de figeage + Freeze Mask Masque de figeage + Fresco Fresque + From Left À partir de la gauche + From Right À partir de la droite + Frosted Dépoli + Full Screen Plein écran + Fullscreen Plein écran + Fuzziness Tolérance + GPU backend preference: {backend} Préférence de moteur GPU : {backend} + GPU device was lost; using the CPU renderer. Le périphérique GPU a été perdu ; utilisation du rendu CPU. + GPU error; using the CPU renderer. Erreur GPU ; utilisation du rendu CPU. + GPU retry scheduled Nouvelle tentative GPU planifiée + Gamma Gamma + Gaussian Blur Flou gaussien + General Blending Fusion générale + Generators Générateurs + Generic Générique + Glass Verre + Global Light Lumière globale + Gloss Brillant + Glow Amount Quantité de lueur + Glow Brightness Luminosité de la lueur + Glow Size Taille de la lueur + Glowing Edges Contours lumineux + Good Bonne + Grade Balance Balance de l'étalonnage + Grade Blending Fusion de l'étalonnage + Grade Global Étalonnage global + Grade Highlights Étalonnage des hautes lumières + Grade Midtones Étalonnage des tons moyens + Grade Shadows Étalonnage des ombres + Grain Amount Quantité de grain + Grain Roughness Rugosité du grain + Grain Size Taille du grain + Grain Type Type de grain + Graininess Grain + Grainy Dots Points granuleux + Graphics adapter Carte graphique + Graphics adapter: {adapter} Carte graphique : {adapter} + Gray Value (Maximum) Valeur de gris (maximum) + Gray Value (Mean) Valeur de gris (moyenne) + Gray Value (Median) Valeur de gris (médiane) + Gray Value (Minimum) Valeur de gris (minimum) + Gray-gamma-2.2 Gris gamma 2,2 + Greek Grec + Grid view Affichage en grille + Group from Layers Groupe d'après des calques + Groups Groupes + Grout Width Largeur du joint + Guide Layout… Disposition des repères… + HDR Toning Tonalité HDR + Hearts Cœurs + Heptagon Heptagone + Hexagon Hexagone + Hidden Masqué + Hide Grid Masquer la grille + Hide Others Masquer les autres + Hide {app} Masquer {app} + High Pass Passe-haut + Highlight Hautes lumières + Highlight Amount Quantité des hautes lumières + Highlight Area Zone des hautes lumières + Highlight Radius Rayon des hautes lumières + Highlight Strength Intensité des hautes lumières + Highlight Tone Amplitude tonale des hautes lumières + Horizontal Only Horizontal seulement + Hotspot Point chaud + Hsb TSL + Hsl TSI + Hsl Hue Teinte TSI + Hsl Lum Luminance TSI + Hsl Sat Saturation TSI + Image Balance Équilibre de l'image + Image Processor Processeur d'images + Image compositor: {processor} Compositeur d'images : {processor} + Import Brushes Importer des pinceaux + Import Image Importer une image + Import Options Options d'importation + Import Photoshop brushes (.abr) Importer des pinceaux Photoshop (.abr) + Import Settings Paramètres d'importation + Importing {what} from {file}… Importation de {what} depuis {file}… + Importing… Importation… + In Black Noir d'entrée + In White Blanc d'entrée + Infinite Infini + Information Informations + Ink Outlines Contours à l'encre + Input Entrée + Input Levels Niveaux d'entrée + Input Mode Mode d'entrée + Install Plug-in Installer une extension + Integrated Density Densité intégrée + Intensity Intensité + Intent Intention de rendu + Interpret Footage Interpréter le métrage + Interval Intervalle + Invalid panel layout; restored the default dock Disposition des panneaux non valide ; l'ancrage par défaut a été rétabli + Invert Mask Inverser le masque + Invert Selection Inverser la sélection + Iris Blur Flou d'iris + Ivy Lierre + Jag Dentelé + Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft + Join the ArtCraft Discord ({url}) Rejoindre le Discord d'ArtCraft ({url}) + Kernel Noyau + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Crénage : Métriques, Optique ou une valeur en 1/1000 em (Alt+←/→ au point d'insertion) + Keyboard Clavier + Label Étiquette + Latin 1 Latin 1 + Latin Extended-A Latin étendu A + Latin Extended-B Latin étendu B + Layer Comps to Files Compositions de calques en fichiers + Layer Content Options Options de contenu du calque + Layer From Background Calque d'après l'arrière-plan + Layer Mask Hides Effects Le masque de calque masque les effets + Layer Properties Propriétés du calque + Layer Styles Styles de calque + Layer from Background Calque d'après l'arrière-plan + Layers to Files Calques en fichiers + Leaf Color Couleur de la feuille + Learn More En savoir plus + Leaves Amount Quantité de feuilles + Leaves Color Couleur des feuilles + Leaves Size Taille des feuilles + Left Diagonal Diagonale gauche + Legacy Ancienne version + Length Longueur + Lens Blur Flou d'objectif + Lens Correction Correction de l'objectif + Lens Flare Reflet d'objectif + Less Moins + Level Based Selon les niveaux + Library Bibliothèque + Light Dark Balance Équilibre clair/foncé + Light Direction Direction de la lumière + Light Intensity Intensité de la lumière + Light Rough Clair rugueux + Light Type Type de lumière + Light X Lumière X + Light Y Lumière Y + Light Z Lumière Z + Lighten Grout Éclaircir le joint + Lighten Only Éclaircir seulement + Lighting Éclairage + Lighting Effects Effets d'éclairage + Linear Dodge Densité linéaire − + Linear-srgb sRVB linéaire + Link Lier + Liquify Liquéfier + List Liste + List view Affichage en liste + Live Preview Aperçu en direct + Load 3D LUT Charger une LUT 3D + Load Files into Stack Charger des fichiers dans une pile + Loading… Chargement… + Lock All Tout verrouiller + Lock Image Pixels Verrouiller les pixels de l'image + Lock Layers Verrouiller les calques + Lock Position Verrouiller la position + Lock Transparent Pixels Verrouiller les pixels transparents + Locked Verrouillé + Long Lines Lignes longues + Long Strokes Traits longs + Lower Inférieur + Lum Lum. + Lut LUT + Make Selection… Créer une sélection… + Make Symmetry Path Créer un tracé de symétrie + Make Work Path… Créer un tracé de travail… + Manual Manuel + Map Document Document de carte + Map Layer Calque de carte + Map Path Chemin de la carte + Mask Incomplete Blocks Masquer les blocs incomplets + Mask None Ne rien masquer + Masks Masques + Match Color Faire correspondre la couleur + Math Symbols Symboles mathématiques + Max Offset Décalage max. + Max Radius Rayon max. + Medium Dots Points moyens + Medium Gray Gris moyen + Medium Lines Lignes moyennes + Medium Strokes Traits moyens + Merge Channels… Fusionner les canaux… + Merge Spot Channel Fusionner le canal de ton direct + Merge to HDR Pro Fusionner en HDR Pro + Metallic Métallique + Mezzotint Manière noire + Middle Milieu + Midtone Contraste des tons moyens + Monitor Profile Profil du moniteur + Monitor profile in use on {display}: {profile} Profil du moniteur utilisé sur {display} : {profile} + Monitor profile in use: {profile} Profil du moniteur utilisé : {profile} + Monitor profile on {display} (main window): {profile} Profil du moniteur sur {display} (fenêtre principale) : {profile} + Monitor profile on {display}: {profile} Profil du moniteur sur {display} : {profile} + Monitor profile: {profile} Profil du moniteur : {profile} + Mono Contrast Contraste mono + Monochromatic Monochromatique + More Plus + Mosaic Mosaïque + Mosaic Tiles Carreaux de mosaïque + Motion Blur Flou de mouvement + Mouse Souris + Move Layer Déplacer le calque + Movie Prime Focale fixe cinéma + Multiple Flames Along Path Plusieurs flammes le long du tracé + Multiple Flames One Direction Plusieurs flammes dans une direction + Multiple Flames Path Directions Plusieurs flammes selon les directions du tracé + Multiple Flames Various Length Plusieurs flammes de longueurs variées + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou lancez PhotoCraft sous XWayland avec `WAYLAND_DISPLAY= photocraft`. + Neon Glow Lueur néon + Neutralize Neutraliser + New Brush Preset Nouveau préréglage de pinceau + New Channel… Nouveau canal… + New Guide Nouveau repère + New Guide Layout Nouvelle disposition de repères + New Spot Channel… Nouveau canal de ton direct… + New Video Layer from File Nouveau calque vidéo à partir d'un fichier + New Window Nouvelle fenêtre + New document Nouveau document + Next Suivant + No Document Aucun document + No Image Aucune image + No Layers Aucun calque + No Results Aucun résultat + No Results Found Aucun résultat trouvé + No Selection Aucune sélection + No custom shape "{name}": pick one in the options bar or Window › Shapes Aucune forme personnalisée « {name} » : choisissez-en une dans la barre d'options ou via Fenêtre › Formes + No layers Aucun calque + No recent files Aucun fichier récent + No selection Aucune sélection + Noise Color Bruit de couleur + Noise Color Detail Détail du bruit de couleur + Noise Luminance Bruit de luminance + Noise Luminance Detail Détail du bruit de luminance + Nothing to Redo Rien à rétablir + Nothing to Undo Rien à annuler + Nothing to paste: the clipboard holds no image Rien à coller : le presse-papiers ne contient aucune image + Number Of Levels Nombre de niveaux + Numbers Nombres + Ocean Ripple Ondulation océanique + Octagon Octogone + Odd Fields Trames impaires + Oil Paint Peinture à l'huile + On Activé + One Flame Along Path Une flamme le long du tracé + Open As Ouvrir en tant que + Open File Ouvrir un fichier + Open Folder Ouvrir un dossier + Open Image Ouvrir une image + Open Recent is unavailable on the web La commande Ouvrir un fichier récent n'est pas disponible sur le Web + Opened {url} {url} ouvert + Opening Ouverture + Operation canceled Opération annulée + Operation cancelled Opération annulée + Operation completed Opération terminée + Operation failed Échec de l'opération + Out Black Noir de sortie + Out From Center Du centre vers l'extérieur + Out White Blanc de sortie + Out of memory Mémoire insuffisante + Output Levels Niveaux de sortie + Oval Ovale + Overlay Edge Superposition des contours + Package Rassembler + Page Setup… Mise en page… + Paint Daubs Barbouillage + Palette Knife Couteau à palette + Panel Panneau + Paper Brightness Luminosité du papier + Parallel Parallèle + Paste Complete Stroke Coller le contour complet + Paste Fill Coller le remplissage + Patchwork Patchwork + Path Blur Flou de tracé + Paths to Illustrator Tracés vers Illustrator + Pattern Type Type de motif + Pencil Width Largeur du crayon + Pentagon Pentagone + Percentage Pourcentage + Perimeter Périmètre + Permission denied Autorisation refusée + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continue de réessayer ; tant qu'un enregistrement n'a pas abouti, les modifications des préférences seront perdues à la fermeture. + Photomerge Photomerge + Picture Frame Cadre d'image + Pinch Pincement + Pins Épingles + Place Embedded Placer incorporé + Place Linked Placer lié + Place Scale Marker Placer un repère d'échelle + Plane Plan + Planes Plans + Plastic Wrap Film plastique + Platform: {os} {arch} Plateforme : {os} {arch} + Please wait… Veuillez patienter… + Point Point + Point Curve Courbe de points + Point Curve Blue Courbe de points bleue + Point Curve Green Courbe de points verte + Point Curve Red Courbe de points rouge + Pointed Pointu + Pointillize Pointillisme + Polar Coordinates Coordonnées polaires + Polar To Rectangular Polaires vers rectangulaires + Policy Cmyk Règle CMJN + Policy Gray Règle niveaux de gris + Policy Rgb Règle RVB + Pond Ripples Ondulations d'étang + Poster Edges Contours postérisés + Posterization Postérisation + Preferences can't be saved Les préférences ne peuvent pas être enregistrées + Preferences saved Préférences enregistrées + Preferences were reset: {error} Les préférences ont été réinitialisées : {error} + Preferences… Préférences… + Preserve Conserver + Preset Manager Gestionnaire de préréglages + Press keys Appuyez sur les touches + Preview Image Aperçu de l'image + Previous Précédent + Prime105 Focale fixe 105 mm + Prime35 Focale fixe 35 mm + Printed to {path} Imprimé vers {path} + Processing… Traitement… + Profile Profil + Prophoto-compat ProPhoto (compatible) + Pucker Contraction + Push Left Pixels poussés vers la gauche + Pyramids Pyramides + Quit Quitter + Quit PhotoCraft Quitter PhotoCraft + Quit {app} Quitter {app} + Radial Blur Flou radial + Radius X Rayon X + Radius Y Rayon Y + Ran script {name} Script {name} exécuté + Random Aléatoire + Randomize Length Longueur aléatoire + Randomize Shapes Formes aléatoires + Raw Defaults Valeurs Raw par défaut + Reading Lecture + Ready Prêt + Rec2020 Rec2020 + Recent Files Fichiers récents + Reconstruct All Tout reconstruire + Recovered {name} {name} récupéré + Rectangular Selection Sélection rectangulaire + Rectangular To Polar Rectangulaires vers polaires + Red Cyan Rouge/Cyan + Reduce Color Noise Réduire le bruit de couleur + Reduce Noise Réduction du bruit + Relief Relief + Relink to File Lier de nouveau au fichier + Remove All Tout supprimer + Remove Jpeg Artifact Supprimer l'artefact JPEG + Rename Brush Renommer le pinceau + Rename Channel… Renommer le canal… + Rename Group Renommer le groupe + Rename Tool Preset Renommer le préréglage d'outil + Render Video Rendu vidéo + Rendering… Rendu en cours… + Reorder Layer Réorganiser le calque + Replace Remplacer + Replace Color Remplacer la couleur + Replace Contents Remplacer le contenu + Replace Footage Remplacer le métrage + Report a Bug Signaler un bogue + Report an Issue Signaler un problème + Reset All Tout réinitialiser + Reset Preferences Réinitialiser les préférences + Resolve Missing Fonts Résoudre les polices manquantes + Restart Redémarrer + Restart Required Redémarrage requis + Resume Reprendre + Retry Réessayer + Rgb RVB + Ridges Crêtes + Right Diagonal Diagonale droite + Ripple Ondulation + Ripple Magnitude Amplitude de l'ondulation + Ripple Size Taille de l'ondulation + Rope Corde + Roses Roses + Rough Pastels Pastels rugueux + Row Order Ordre des lignes + Sample Size Taille de l'échantillon + Sandstone Grès + Sat Sat. + Save As Enregistrer sous + Save Changes Enregistrer les modifications + Save File Enregistrer le fichier + Save Image Enregistrer l'image + Save Path Enregistrer le tracé + Save a Copy Enregistrer une copie + Save before closing? Enregistrer avant de fermer ? + Save for Web (Legacy) Enregistrer pour le Web (ancien) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Enregistrez votre travail et redémarrez PhotoCraft pour réessayer l'accélération GPU. Le rendu CPU reste actif pour cette session. + Saved a copy as {path} Copie enregistrée sous {path} + Saved {path}; image assets: {n} in {dir} {path} enregistré ; ressources d'image : {n} dans {dir} + Saving… Enregistrement… + Scale Effects Mettre les effets à l'échelle + Scale Factor Facteur d'échelle + Scale Units Unités d'échelle + Scaling Échelle + Scallops Festons + Script Events Manager Gestionnaire d'événements de script + Scroll menu down Faire défiler le menu vers le bas + Scroll menu up Faire défiler le menu vers le haut + Search commands, tools and panels Rechercher des commandes, outils et panneaux + Search menus Rechercher dans les menus + Seed Valeur initiale + Select Custom Color… Choisir une couleur personnalisée… + Select Inverse Inverser la sélection + Select Layer Sélectionner le calque + Select and Mask Sélectionner et masquer + Selected at launch: {backend} Sélectionné au lancement : {backend} + Sensitivity Sensibilité + Sent to the printer ({spooler}) Envoyé à l'imprimante ({spooler}) + Sepia Sépia + Services Services + Set Colors Définir les couleurs + Set Measurement Scale Définir l'échelle de mesure + Sgray sGray + Shadow Ombres + Shadow Amount Quantité des ombres + Shadow Intensity Intensité des ombres + Shadow Radius Rayon des ombres + Shadow Tone Amplitude tonale des ombres + Shadows/Highlights Ombres/Hautes lumières + Shallow Superficiel + Shape Blur Flou selon une forme + Sharpen Amount Quantité de netteté + Sharpen Detail Détail de la netteté + Sharpen Details Détails de la netteté + Sharpen Masking Masquage de la netteté + Sharpen Radius Rayon de netteté + Shear Cisaillement + Shine Brillance + Short Lines Lignes courtes + Short Strokes Traits courts + Shortcut Raccourci + Shortcuts Raccourcis + Show All Tout afficher + Show Rulers Afficher les règles + Simple Simple + Sine Sinus + Sketch Esquisse + Skip Ignorer + Slice Options Options de tranche + Smart Blur Flou intelligent + Smart Sharpen Netteté intelligente + Smoothness Lissage + Smudge Stick Estompe + Snap to Grid Aimanter à la grille + Snowflakes Flocons de neige + Softness Douceur + Software adapter Adaptateur logiciel + Solarized Dark Solarized sombre + Solid Color Couleur unie + Solid Front Faces Faces avant pleines + Source Layer Calque source + Sparkle Étincelle + Spatter Éclaboussures + Speckle Moucheté + Speed Vitesse + Spherize Sphérisation + Spin Rotation + Spin Blur Flou de rotation + Split Channels Séparer les canaux + Spot Spot + Spray Radius Rayon de pulvérisation + Sprayed Strokes Traits pulvérisés + Sprinkles Saupoudré + Square Size Taille des carrés + Squareness Forme carrée + Stagger Décalage + Stained Glass Vitrail + Star Étoile + Stars Étoiles + Start X Début X + Start Y Début Y + Statistics Statistiques + Step {step} ({id}) failed: {error} Échec de l'étape {step} ({id}) : {error} + Stippled Pointillé + Stops Arrêts + Strength Intensité + Stretch Étirement + Stroke Color Couleur du contour + Stroke Detail Détail du trait + Stroke Direction Direction du trait + Stroke Length Longueur du trait + Stroke Pressure Pression du trait + Stroke Size Taille du trait + Stroke Width Largeur du trait + Strokes Traits + Stylization Stylisation + Subtract Shapes at Overlap Soustraire les formes au chevauchement + Sumi-e Sumi-e + Surface Blur Flou de surface + Switch Foreground and Background Colors Permuter les couleurs de premier plan et d'arrière-plan + Tablet Tablette + Taper Effilement + Target Cible + Target X Cible X + Target Y Cible Y + Teal Orange Sarcelle et orange + Temporary Temporaires + Text Layer Calque de texte + Texture Coverage Couverture de la texture + Texturizer Texturisation + Thaw Mask Masque de libération + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas sur {display} est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The current gradient (pick one in Window › Gradients) Le dégradé actuel (à choisir dans Fenêtre › Dégradés) + The document does not have an embedded colour profile. Working space: {working}. Le document ne contient aucun profil colorimétrique incorporé. Espace de travail : {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Le document contient un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail.\nIncorporé : {embedded}\nEspace de travail : {working} + The file could not be opened. Impossible d'ouvrir le fichier. + The file could not be saved. Impossible d'enregistrer le fichier. + Thickness Épaisseur + This Layer: Ce calque : + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? + This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. + This session: {reason} Cette session : {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être rastérisé pour continuer. Il ne sera plus modifiable en tant que forme. Rastériser la forme ? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être rastérisé pour continuer. Son texte ne sera plus modifiable. Rastériser le texte ? + Tile Size Taille des carreaux + Tiles Carreaux + Tilt-Shift Inclinaison-décalage + Tint Color Couleur de la nuance + Tiny Lens Petites lentilles + To À + To Center Vers le centre + Toggle the Brush Settings panel Afficher/masquer le panneau Paramètres du pinceau + Tool Options Options d'outil + Trace Contour Tracer les contours + Transformation Transformation + Transition Transition + Transparency Shapes Layer La transparence détermine la forme du calque + Trap Recouvrement + Tree Arbre + Trim Rogner les bords + Turbulent Turbulent + Twirl Tourbillon + Twirl Clockwise Tourbillon horaire + Twirl Counterclockwise Tourbillon antihoraire + Unaltered Inchangé + Undefined Areas Zones non définies + Underlying Layer: Calque sous-jacent : + Underpainting Sous-couche + Underwater Sous l'eau + Ungroup Dissocier + Uniform Uniforme + Unite Shapes at Overlap Réunir les formes au chevauchement + Unlink Délier + Unlock Déverrouiller + Unlocked Déverrouillé + Unsaved Changes Modifications non enregistrées + Unsharp Mask Masque flou + Unsupported file format Format de fichier non pris en charge + Unsupported format Format non pris en charge + Up Haut + Update Mettre à jour + Updates Mises à jour + Upper Supérieur + Use Custom Color Utiliser une couleur personnalisée + Use File › Open to open an image. Utilisez Fichier › Ouvrir pour ouvrir une image. + Use Selection In Source Utiliser la sélection dans la source + Use Selection In Target Utiliser la sélection dans la cible + Vanishing Point Point de fuite + Variable Data Sets Jeux de données variables + Vector Mask Hides Effects Le masque vectoriel masque les effets + Vertical Only Vertical seulement + Vertical Type Tool Outil Texte vertical + Video Frames to Layers Images vidéo dans des calques + Vignette Amount Quantité de vignettage + Vignette Feather Adoucissement du vignettage + Vignette Highlights Hautes lumières du vignettage + Vignette Midpoint Point médian du vignettage + Vignette Roundness Arrondi du vignettage + Vignette Style Style du vignettage + Vine Color Couleur de la liane + Vine With Flowers Liane avec fleurs + Vine With Leaves Liane avec feuilles + Violent Violent + WIA Support Prise en charge de WIA + Warm Chaud + Warming L B A Réchauffement LBA + Warming81 Réchauffement 81 + Warming85 Réchauffement 85 + Warmth Chaleur + Warning Avertissement + Warp Text Déformer le texte + Watercolor Aquarelle + Wavelength Max Longueur d'onde max. + Wavelength Min Longueur d'onde min. + Waves Vagues + Wetness Humidité + White Clip Écrêtage des blancs + White Intensity Intensité du blanc + White Is High Le blanc est le plus haut + Wide Blurry Large et flou + Wide Sharp Large et net + Wind Vent + Window renderer: {renderer} Moteur de rendu de la fenêtre : {renderer} + Working Espace de travail + Working Cmyk CMJN de travail + Working Gray Gris de travail + Working Rgb RVB de travail + Workspaces Espaces de travail + Wrap Enrouler + ZigZag Zigzag + Zigzag Zigzag + auto auto + blue bleu + brushes pinceaux + fallback repli + gradients dégradés + gray gris + green vert + layer mask masque de calque + manual manuel + none aucun + off désactivé + on activé + orange orange + previous reading kept; the last re-read failed: {reason} lecture précédente conservée ; la dernière relecture a échoué : {reason} + red rouge + unknown inconnu + unknown type type inconnu + vector mask masque vectoriel + violet violet + yellow jaune + {index} of {total} {index} sur {total} + {label} is not available: {reason} {label} n'est pas disponible : {reason} + {message} (notes: {n}; {warning}) {message} (remarques : {n} ; {warning}) + {shortcut} assigned. {shortcut} attribué. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} est déjà utilisé par {commands} et lui sera retiré lorsque vous cliquerez sur OK. + {verb} {mask} {layer} {verb} le {mask} ({layer}) + ✓ Color Indicates Masked Areas ✓ La couleur indique les zones masquées + ✓ Color Indicates Selected Areas ✓ La couleur indique les zones sélectionnées +@id edit.clear Effacer +@id edit.stroke Contourner… +@id edit.transform.scale Mise à l'échelle +@id file.close Fermer +@id filter.sharpen.sharpen Netteté +@id layer.layerMask.apply Appliquer le masque de calque +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} nuance exportée vers {file}|{n} nuances exportées vers {file} +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pinceau importé depuis {file}|{n} pinceaux importés depuis {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} dégradé importé depuis {file}|{n} dégradés importés depuis {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} nuance importée depuis {file}|{n} nuances importées depuis {file} +@plural Played {n} step|Played {n} steps {n} étape lue|{n} étapes lues +@plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet intelligent lié modifié sur le disque|Mise à jour de {n} objets intelligents liés modifiés sur le disque +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré par cette composition a été supprimé. Mettez la composition à jour ou effacez l'avertissement.|{n} calques enregistrés par cette composition ont été supprimés. Mettez la composition à jour ou effacez l'avertissement. +@plural {n} plane|{n} planes {n} plan|{n} plans +@plural {n} preset|{n} presets {n} préréglage|{n} préréglages +@plural {n} sample point removed|{n} sample points removed {n} point d'échantillonnage supprimé|{n} points d'échantillonnage supprimés +@plural {n} slice|{n} slices {n} tranche|{n} tranches +@plural {n} step|{n} steps {n} étape|{n} étapes +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} autre avertissement de préréglage de pinceau)|{warning} (+{n} autres avertissements de préréglages de pinceau) +@plural {what} with {n} warning|{what} with {n} warnings {what} avec {n} avertissement|{what} avec {n} avertissements +cameraRaw Aqua Aqua +cameraRaw Balance Balance +cameraRaw Blue Bleu +cameraRaw Clarity Clarté +cameraRaw Color Couleur +cameraRaw Color Detail Détail des couleurs +cameraRaw Color Grading Étalonnage des couleurs +cameraRaw Color Mixer Mélangeur de couleurs +cameraRaw Contrast Contraste +cameraRaw Curve Courbe +cameraRaw Dehaze Correction de la brume +cameraRaw Detail Détail +cameraRaw Effects Effets +cameraRaw Exposure Exposition +cameraRaw Feather Adoucissement +cameraRaw Global Global +cameraRaw Green Vert +cameraRaw Highlights Hautes lumières +cameraRaw Hue Teinte +cameraRaw Luminance Luminance +cameraRaw Luminance Detail Détail de la luminance +cameraRaw Magenta Magenta +cameraRaw Midpoint Point médian +cameraRaw Midtones Tons moyens +cameraRaw Orange Orange +cameraRaw Purple Violet +cameraRaw Radius Rayon +cameraRaw Red Rouge +cameraRaw Roughness Rugosité +cameraRaw Roundness Arrondi +cameraRaw Saturation Saturation +cameraRaw Shadows Ombres +cameraRaw Size Taille +cameraRaw Temperature Température +cameraRaw Texture Texture +cameraRaw Vibrance Vibrance +cameraRaw Whites Blancs +cameraRaw Yellow Jaune +colorRange Invert Inverser +document Orientation Orientation +layerLabel Magenta Magenta +layerLabel No Color Aucune couleur +layerLabel Red Rouge +layers Fill Remplissage +layers Fill: Remplissage : +navigator Zoom Zoom +shape Appearance Apparence From a23f7778bec863140aa160774e2635e325cd26fb Mon Sep 17 00:00:00 2001 From: Florent Siegenthaler <64897087+Atalantis@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:13:17 +0200 Subject: [PATCH 0049/1452] i18n: align French terms with retouching and Photoshop vocabulary (366 entries) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewords French entries, old and new, to the terms French retouchers know: Couche for Channel, Zone de travail for Canvas, Masque de fusion, Objet dynamique, Pixelliser, Calque de réglage, Contour progressif, Tampon de duplication, Transformation manuelle, Tons clairs / Tons foncés, Masque d'écrêtage. 88 entries use terms attested in Adobe's public French help pages; 278 come from the reviewer's knowledge of the French interface. The catalog header records the exception to the clean-room note. Keys with two meanings (Fill, Canvas, Screen, Sharpen) are left unchanged. --- crates/ui-egui/src/i18n/fr.tsv | 737 +++++++++++++++++---------------- 1 file changed, 370 insertions(+), 367 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 88e2bc80f4..ec4192b90b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -13,6 +13,9 @@ # French computing vocabulary (calque, sélection, pinceau, dégradé, flou, opacité ...). No # proprietary localisation resource, string table or product UI was consulted or copied; in # particular not the French localisation of Adobe Photoshop. Add entries the same way. +# Exception, 2026-10: about 370 entries were realigned on the terms French retouchers use and that +# Adobe's public French help pages show (Couche, Zone de travail, Masque de fusion, Objet dynamique, +# Pixelliser, Réglage ...), so the paragraph above no longer holds for those entries. # Conventions: menus are nouns (Fichier, Édition, Image ...), commands are infinitives (Ouvrir…, # Enregistrer sous…), sentence case. French typography: a no-break space (U+00A0) before # « : ; ? ! » and "%", and inside guillemets « … ». Product and technology names (PhotoCraft, @@ -91,8 +94,8 @@ Render Video… Rendu vidéo… Generate Générer Image Assets Ressources d'image - Place Embedded… Placer incorporé… - Place Linked… Placer lié… + Place Embedded… Importer et incorporer… + Place Linked… Importer et lier… Package… Rassembler… Automate Automatisation Batch… Traitement par lots… @@ -125,11 +128,11 @@ Fade… Estomper… Cut Couper Copy Copier - Copy Merged Copier les calques fusionnés + Copy Merged Copier avec fusion Paste Coller Paste Special Collage spécial - Paste in Place Coller sur place - Paste Into Coller dans la sélection + Paste in Place Coller en place + Paste Into Coller dedans Paste Outside Coller hors de la sélection Paste Lorem Ipsum Coller du Lorem Ipsum Clear Effacer @@ -137,13 +140,13 @@ Check Spelling… Vérifier l'orthographe… Find and Replace Text… Rechercher et remplacer du texte… Fill… Remplir… - Stroke… Contourner… + Stroke… Contour… Content-Aware Fill… Remplissage d'après le contenu… Delete and Fill Selection Supprimer et remplir la sélection - Content-Aware Scale Mise à l'échelle d'après le contenu - Puppet Warp Déformation par épingles + Content-Aware Scale Échelle basée sur le contenu + Puppet Warp Déformation de la marionnette Perspective Warp Déformation de la perspective - Free Transform Transformation libre + Free Transform Transformation manuelle Transform Transformation Again Répéter Scale Mise à l'échelle @@ -191,37 +194,37 @@ Export… Exportation… Performance… Performances… Scratch Disks… Disques de travail… - Cursors… Curseurs… - Transparency & Gamut… Transparence et gamut… + Cursors… Pointeurs… + Transparency & Gamut… Transparence et couleurs non imprimables… Units & Rulers… Unités et règles… Guides, Grid & Slices… Repères, grille et tranches… - Plug-ins… Extensions… + Plug-ins… Modules externes… Type… Texte… Enhanced Controls… Commandes avancées… Camera Raw… Camera Raw… Raw Defaults… Valeurs Raw par défaut… Integrations… Intégrations… AI Integrations… Intégrations d'IA… - Install Plug-in… Installer une extension… + Install Plug-in… Installer un module externe… Language Options Options de langue Settings… Paramètres… Mode Mode Bitmap Bitmap Grayscale Niveaux de gris - Duotone Duotone + Duotone Bichromie Indexed Color Couleurs indexées RGB Color Couleurs RVB CMYK Color Couleurs CMJN Lab Color Couleurs Lab - Multichannel Multicanal - 8 Bits/Channel 8 bits/canal - 16 Bits/Channel 16 bits/canal - 32 Bits/Channel 32 bits/canal + Multichannel Multicouche + 8 Bits/Channel 8 bits/couche + 16 Bits/Channel 16 bits/couche + 32 Bits/Channel 32 bits/couche Color Table… Table des couleurs… Bitmap… Bitmap… - Duotone… Duotone… + Duotone… Bichromie… Indexed Color… Couleurs indexées… - Adjustments Ajustements + Adjustments Réglages Brightness/Contrast… Luminosité/Contraste… Levels… Niveaux… Curves… Courbes… @@ -231,33 +234,33 @@ Color Balance… Balance des couleurs… Black & White… Noir et blanc… Photo Filter… Filtre photo… - Channel Mixer… Mélangeur de canaux… - Color Lookup… Table de correspondance des couleurs… + Channel Mixer… Mélangeur de couches… + Color Lookup… Correspondance des couleurs… Invert Inverser - Posterize… Postériser… + Posterize… Isohélie… Threshold… Seuil… - Gradient Map… Mappage de dégradé… - Selective Color… Couleur sélective… - Shadows/Highlights… Ombres/Hautes lumières… - HDR Toning… Tonalité HDR… - Desaturate Désaturer - Match Color… Faire correspondre la couleur… - Replace Color… Remplacer la couleur… + Gradient Map… Courbe de transfert de dégradé… + Selective Color… Correction sélective… + Shadows/Highlights… Tons foncés/Tons clairs… + HDR Toning… Virage HDR… + Desaturate Désaturation + Match Color… Correspondance de la couleur… + Replace Color… Remplacement de couleur… Equalize Égaliser - Auto Tone Tonalité automatique + Auto Tone Ton automatique Auto Contrast Contraste automatique Auto Color Couleur automatique Image Size… Taille de l'image… - Canvas Size… Taille du canevas… + Canvas Size… Taille de la zone de travail… Image Rotation Rotation de l'image - Flip Canvas Horizontal Retourner le canevas horizontalement - Flip Canvas Vertical Retourner le canevas verticalement + Flip Canvas Horizontal Symétrie horizontale de la zone de travail + Flip Canvas Vertical Symétrie verticale de la zone de travail Crop Recadrer - Trim… Rogner les bords… - Reveal All Tout afficher + Trim… Rognage… + Reveal All Tout faire apparaître Duplicate… Dupliquer… Apply Image… Appliquer une image… - Calculations… Calculs… + Calculations… Opérations… Variables Variables Define… Définir… Data Sets… Jeux de données… @@ -269,7 +272,7 @@ Select Data Points… Sélectionner les points de données… Record Measurements Enregistrer les mesures Ruler Tool Outil Règle - Count Tool Outil Compteur + Count Tool Outil Comptage Place Scale Marker… Placer un repère d'échelle… Custom… Personnalisé… New Nouveau @@ -289,7 +292,7 @@ New Fill Layer Nouveau calque de remplissage New Adjustment Layer Nouveau calque de réglage New Layer Based Slice Nouvelle tranche d'après un calque - New Smart Object via Copy Nouvel objet intelligent par copie + New Smart Object via Copy Nouvel objet dynamique par copie New Blank Video Layer Nouveau calque vidéo vide New Video Layer from File… Nouveau calque vidéo à partir d'un fichier… Duplicate Layer… Dupliquer le calque… @@ -299,14 +302,14 @@ Create Layer Créer un calque Layer Style Style de calque Blending Options… Options de fusion… - Bevel & Emboss… Biseau et relief… - Inner Shadow… Ombre intérieure… - Inner Glow… Lueur intérieure… + Bevel & Emboss… Biseautage et estampage… + Inner Shadow… Ombre interne… + Inner Glow… Lueur interne… Satin… Satin… - Color Overlay… Superposition de couleur… - Gradient Overlay… Superposition de dégradé… - Pattern Overlay… Superposition de motif… - Outer Glow… Lueur extérieure… + Color Overlay… Incrustation couleur… + Gradient Overlay… Incrustation en dégradé… + Pattern Overlay… Incrustation de motif… + Outer Glow… Lueur externe… Drop Shadow… Ombre portée… Copy Layer Style Copier le style de calque Paste Layer Style Coller le style de calque @@ -314,35 +317,35 @@ Global Light… Lumière globale… Hide All Effects Masquer tous les effets Scale Effects… Mettre les effets à l'échelle… - Smart Filter Filtre intelligent - Convert for Smart Filters Convertir pour les filtres intelligents - Clear Smart Filters Effacer les filtres intelligents - Disable Smart Filters Désactiver les filtres intelligents + Smart Filter Filtre dynamique + Convert for Smart Filters Convertir pour les filtres dynamiques + Clear Smart Filters Effacer les filtres dynamiques + Disable Smart Filters Désactiver les filtres dynamiques Delete Filter Mask Supprimer le masque de filtre Disable Filter Mask Désactiver le masque de filtre Solid Color… Couleur unie… Gradient… Dégradé… Pattern… Motif… - Layer Mask Masque de calque + Layer Mask Masque de fusion Vector Mask Masque vectoriel - Reveal Selection Afficher la sélection + Reveal Selection Faire apparaître la sélection Hide Selection Masquer la sélection Hide All Tout masquer From Transparency D'après la transparence Delete Vector Mask Supprimer le masque vectoriel - Enable Layer Mask Activer le masque de calque - Disable Layer Mask Désactiver le masque de calque + Enable Layer Mask Activer le masque de fusion + Disable Layer Mask Désactiver le masque de fusion Enable Vector Mask Activer le masque vectoriel - Link Layer Mask Lier le masque de calque - Unlink Layer Mask Délier le masque de calque + Link Layer Mask Lier le masque de fusion + Unlink Layer Mask Délier le masque de fusion Link Vector Mask Lier le masque vectoriel Add Vector Mask Ajouter un masque vectoriel Vector Mask from Current Path Masque vectoriel d'après le tracé actif - Create Clipping Mask Créer un masque de découpe - Release Clipping Mask Libérer le masque de découpe - Smart Objects Objets intelligents - Smart Object Objet intelligent - Convert to Smart Object Convertir en objet intelligent + Create Clipping Mask Créer un masque d'écrêtage + Release Clipping Mask Libérer le masque d'écrêtage + Smart Objects Objets dynamiques + Smart Object Objet dynamique + Convert to Smart Object Convertir en objet dynamique Convert to Embedded Convertir en objet incorporé Convert to Layers Convertir en calques Convert to Linked… Convertir en objet lié… @@ -376,10 +379,10 @@ Duplicate Frame Dupliquer l'image Frame from Layers Image d'après les calques Video Frames to Layers… Images vidéo dans des calques… - Rasterize Rastériser - Rasterize Type Rastériser le texte - Rasterize Type Layer Rastériser le calque de texte - Rasterize Shape Rastériser la forme + Rasterize Pixelliser + Rasterize Type Pixelliser le texte + Rasterize Type Layer Pixelliser le calque de texte + Rasterize Shape Pixelliser la forme New Guide Layout… Nouvelle disposition de repères… New Guides From Shape Nouveaux repères d'après la forme Lock Layers… Verrouiller les calques… @@ -485,9 +488,9 @@ Save Default Type Styles Enregistrer les styles de texte par défaut Deselect Désélectionner Reselect Resélectionner - Inverse Inverser - Inverse Selection Inverser la sélection - Similar Similaire + Inverse Intervertir + Inverse Selection Intervertir la sélection + Similar Généraliser Color Range… Plage de couleurs… Focus Area… Zone de mise au point… Select and Mask… Sélectionner et masquer… @@ -500,40 +503,40 @@ Modify Modifier Border… Bordure… Smooth… Lisser… - Expand… Étendre… + Expand… Dilater… Contract… Contracter… - Feather… Adoucir… - Grow Agrandir + Feather… Contour progressif… + Grow Étendre Transform Selection Transformer la sélection - Edit in Quick Mask Mode Modifier en mode masque rapide - Load Selection… Charger la sélection… - Save Selection… Enregistrer la sélection… + Edit in Quick Mask Mode Modifier en mode Masque + Load Selection… Récupérer la sélection… + Save Selection… Mémoriser la sélection… Match All Tout faire correspondre Last Filter Dernier filtre Filter Gallery… Galerie de filtres… Adaptive Wide Angle… Grand angle adaptatif… Camera Raw Filter… Filtre Camera Raw… - Liquify… Liquéfier… + Liquify… Fluidité… Vanishing Point… Point de fuite… Blur Flou - Blur Gallery Galerie de flous - Field Blur… Flou de champ… - Iris Blur… Flou d'iris… + Blur Gallery Galerie d'effets de flou + Field Blur… Flou de profondeur de champ… + Iris Blur… Flou de diaphragme… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… Spin Blur… Flou de rotation… - Blur More Flou accentué + Blur More Plus flou Box Blur… Flou par boîte… Gaussian Blur… Flou gaussien… - Lens Blur… Flou d'objectif… - Motion Blur… Flou de mouvement… + Lens Blur… Flou de l'objectif… + Motion Blur… Flou directionnel… Radial Blur… Flou radial… Shape Blur… Flou selon une forme… - Smart Blur… Flou intelligent… + Smart Blur… Flou optimisé… Surface Blur… Flou de surface… Average Moyenne - Displace… Déplacement… - Pinch… Pincement… + Displace… Dispersion… + Pinch… Contraction… Polar Coordinates… Coordonnées polaires… Ripple… Ondulation… Shear… Cisaillement… @@ -543,16 +546,16 @@ ZigZag… Zigzag… Noise Bruit Add Noise… Ajout de bruit… - Despeckle Supprimer les mouchetures - Dust & Scratches… Poussière et rayures… + Despeckle Flou intérieur + Dust & Scratches… Antipoussière… Median… Médiane… Reduce Noise… Réduction du bruit… - Pixelate Pixeliser - Color Halftone… Trame de demi-teintes couleur… + Pixelate Pixellisation + Color Halftone… Demi-teintes couleur… Crystallize… Cristallisation… Facet Facettes Fragment Fragmentation - Mezzotint… Manière noire… + Mezzotint… Mezzo-tinto… Mosaic… Mosaïque… Pointillize… Pointillisme… Render Rendu @@ -560,38 +563,38 @@ Difference Clouds Nuages par différence Fibers… Fibres… Flame… Flamme… - Lens Flare… Reflet d'objectif… - Lighting Effects… Effets d'éclairage… + Lens Flare… Halo… + Lighting Effects… Éclairage… Relight… Rééclairage… Picture Frame… Cadre d'image… Tree… Arbre… Sharpen Netteté - Sharpen Edges Netteté des contours - Sharpen More Netteté accentuée - Smart Sharpen… Netteté intelligente… - Unsharp Mask… Masque flou… + Sharpen Edges Contours plus nets + Sharpen More Encore plus net + Smart Sharpen… Netteté optimisée… + Unsharp Mask… Accentuation… High Pass… Passe-haut… - Stylize Styliser - Diffuse… Diffuser… - Emboss… Relief… - Extrude… Extruder… - Find Edges Détecter les contours + Stylize Esthétiques + Diffuse… Diffusion… + Emboss… Estampage… + Extrude… Extrusion… + Find Edges Tracé des contours Oil Paint… Peinture à l'huile… - Solarize Solariser - Tiles… Carreaux… - Trace Contour… Tracer les contours… - Wind… Vent… + Solarize Solarisation + Tiles… Carrelage… + Trace Contour… Courbes de niveau… + Wind… Soufflerie… Video Vidéo - De-Interlace… Désentrelacer… + De-Interlace… Désentrelacement… NTSC Colors Couleurs NTSC - Other Autres + Other Divers Maximum… Maximum… Minimum… Minimum… - Offset… Décalage… - Plug-ins Extensions + Offset… Translation… + Plug-ins Modules externes Proof Setup Configuration d'épreuve Proof Colors Couleurs d'épreuve - Gamut Warning Alerte de gamut + Gamut Warning Couleurs non imprimables Working CMYK CMJN de travail Working Cyan Plate Plaque cyan de travail Working Magenta Plate Plaque magenta de travail @@ -633,11 +636,11 @@ HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) 32-bit Preview Options… Options d'aperçu 32 bits… - Screen Mode Mode d'écran - Standard Screen Mode Mode d'écran standard + Screen Mode Mode d'affichage + Standard Screen Mode Mode d'affichage standard Full Screen Mode With Menu Bar Mode plein écran avec barre de menus Full Screen Mode Mode plein écran - Cycle Screen Mode Changer de mode d'écran + Cycle Screen Mode Changer de mode d'affichage Show Afficher Extras Extras Selection Edges Contours de la sélection @@ -645,12 +648,12 @@ Current Path Tracé actif Grid Grille Guides Repères - Canvas Guides Repères du canevas + Canvas Guides Repères de la zone de travail Artboard Guides Repères du plan de travail Count Compteur - Smart Guides Repères intelligents + Smart Guides Repères commentés Slices Tranches - Notes Notes + Notes Annotations Pixel Grid Grille de pixels Pixel Art Pixel art Pixel Art Preview Aperçu pixel art @@ -666,7 +669,7 @@ Lock Guides Verrouiller les repères Clear Guides Effacer les repères Clear Selected Artboard Guides Effacer les repères du plan de travail sélectionné - Clear Canvas Guides Effacer les repères du canevas + Clear Canvas Guides Effacer les repères de la zone de travail New Guide… Nouveau repère… Lock Slices Verrouiller les tranches Clear Slices Effacer les tranches @@ -720,16 +723,16 @@ Actions Actions Brush Settings Paramètres du pinceau Brushes Pinceaux - Channels Canaux + Channels Couches Character Caractère Character Styles Styles de caractère - Clone Source Source de clonage + Clone Source Source de duplication Color Couleur Glyphs Glyphes Gradients Dégradés Histogram Histogramme History Historique - Info Infos + Info Informations Measurement Log Journal des mesures Modifier Keys Touches de modification Navigator Navigation @@ -757,7 +760,7 @@ Invert… Inverser… Load Files into Stack… Charger des fichiers dans une pile… Motion Mouvement - Notes… Notes… + Notes… Annotations… Other… Autre… Remove Warp Split Supprimer la division de la déformation Shape Forme @@ -778,18 +781,18 @@ Adaptive Adaptative Adaptive Wide Angle isn't open Grand angle adaptatif n'est pas ouvert Add Ajouter - Add Layer Mask Ajouter un masque de calque + Add Layer Mask Ajouter un masque de fusion Add Stop Ajouter un point Add a layer style Ajouter un style de calque Add a mask (from the selection; {key} inverts) Ajouter un masque (d'après la sélection ; {key} l'inverse) - Add an adjustment Ajouter un ajustement + Add an adjustment Ajouter un réglage Add disk Ajouter un disque Add space after paragraph Espace après le paragraphe Add space before paragraph Espace avant le paragraphe Add to Sample Ajouter à l'échantillon Add to selection ({key}) Ajouter à la sélection ({key}) Adjust for Zoom Adapter au zoom - Adjustment visible Ajustement visible + Adjustment visible Réglage visible Adobe RGB (1998) compatible Compatible Adobe RGB (1998) Align and Distribute Aligner et répartir Align and distribute Aligner et répartir @@ -813,7 +816,7 @@ Anti-alias Lissage Appearance Apparence Appearance (Layer Style) Apparence (style de calque) - Apply Layer Mask Appliquer le masque de calque + Apply Layer Mask Appliquer le masque de fusion Apply Per Tip Appliquer par pointe Apply next selected layer comp Appliquer la composition de calques sélectionnée suivante Apply previous selected layer comp Appliquer la composition de calques sélectionnée précédente @@ -870,7 +873,7 @@ Cancel transform (Esc) Annuler la transformation (Échap) Cancel warp (Esc) Annuler la déformation (Échap) Canvas Canevas - Canvas extension color: Couleur d'extension du canevas : + Canvas extension color: Couleur d'extension de la zone de travail : Capture Capturer Capture the visibility, position and style of every layer with + below, then switch between versions. Capturez la visibilité, la position et le style de chaque calque avec le + ci-dessous, puis passez d'une version à l'autre. Catch-up on Stroke End Rattraper en fin de trait @@ -879,10 +882,10 @@ Center text Centrer le texte Change Remplacer Change All Tout remplacer - Change Screen Mode (F) Changer de mode d'écran (F) + Change Screen Mode (F) Changer de mode d'affichage (F) Change To: Remplacer par : - Channel Mixer Mélangeur de canaux - Channel: Canal : + Channel Mixer Mélangeur de couches + Channel: Couche : Check All Layers Vérifier tous les calques Check Spelling Vérifier l'orthographe Clarity Clarté @@ -896,11 +899,11 @@ Click to edit the gradient Cliquez pour modifier le dégradé Click to restore the current colour Cliquez pour rétablir la couleur actuelle Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Cliquez pour zoomer · {key}-clic pour dézoomer · faites glisser vers la droite/gauche pour zoomer/dézoomer - Click with the Note tool to add a note. Cliquez avec l'outil Note pour ajouter une note. + Click with the Note tool to add a note. Cliquez avec l'outil Annotation pour ajouter une annotation. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic : angle · Glisser : lisse · Clic sur le premier point : fermer · {key} : terminer · Échap : annuler Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Clic : angle · Glisser : lisse · {alt}+glisser : briser la poignée · Clic ou glisser sur le premier point : fermer · {key} : terminer · Échap : annuler - Clip to layer below Découper selon le calque inférieur - Clipped Découpé + Clip to layer below Écrêter selon le calque inférieur + Clipped Écrêté Close Style Options Fermer les options de style Cmd Cmd Coated CMYK CMJN papier couché @@ -908,7 +911,7 @@ Color Balance Balance des couleurs Color Detail Détail des couleurs Color Grading Étalonnage des couleurs - Color Lookup Table de correspondance des couleurs + Color Lookup Correspondance des couleurs Color Management Gestion des couleurs Color Mixer Mélangeur de couleurs Color Mode Mode colorimétrique @@ -918,7 +921,7 @@ Colorize Colorer Colors Couleurs Commit Perspective Warp ({key}) Valider la déformation de la perspective ({key}) - Commit Puppet Warp ({key}) Valider la déformation par épingles ({key}) + Commit Puppet Warp ({key}) Valider la déformation de la marionnette ({key}) Commit any current edits ({key}) Valider les modifications en cours ({key}) Commit current crop operation ({key}) Valider le recadrage en cours ({key}) Commit transform ({key}) Valider la transformation ({key}) @@ -926,7 +929,7 @@ Constant Constant Constrain proportions Conserver les proportions Contact Sheet II Planche contact II - Content-Aware Selon le contenu + Content-Aware Contenu pris en compte Contiguous Contigu Contrast Contraste Control: Contrôle : @@ -949,9 +952,9 @@ Create a new group Créer un nouveau groupe Create a new layer Créer un nouveau calque Create new action Créer une nouvelle action - Create new channel Créer un nouveau canal + Create new channel Créer une nouvelle couche Create new document from current state Créer un nouveau document à partir de l'état actuel - Create new fill or adjustment layer Créer un nouveau calque de remplissage ou d'ajustement + Create new fill or adjustment layer Créer un nouveau calque de remplissage ou de réglage Create new gradient Créer un nouveau dégradé Create new group Créer un nouveau groupe Create new layer comp Créer une nouvelle composition de calques @@ -972,7 +975,7 @@ Cyan Cyan Cyan · Red Cyan · Rouge Darken Obscurcir - Darks Tons sombres + Darks Teintes sombres Data Sets Jeux de données Default Par défaut Default colours (D) Couleurs par défaut (D) @@ -983,13 +986,13 @@ Dehaze Correction de la brume Delete Cropped Pixels Supprimer les pixels recadrés Delete Group Supprimer le groupe - Delete Layer Mask Supprimer le masque de calque + Delete Layer Mask Supprimer le masque de fusion Delete Layers Supprimer les calques Delete Shortcut Supprimer le raccourci Delete Timeline Supprimer le montage Delete Tool Preset Supprimer le préréglage d'outil Delete count group Supprimer le groupe de comptage - Delete current channel Supprimer le canal actif + Delete current channel Supprimer la couche active Delete current state Supprimer l'état actuel Delete effect layer Supprimer le calque d'effet @@ -1005,7 +1008,7 @@ Delete layer Supprimer le calque Delete layer comp Supprimer la composition de calques - Delete note Supprimer la note + Delete note Supprimer l'annotation Delete selected measurements Supprimer les mesures sélectionnées Delete style Supprimer le style Delete tool preset Supprimer le préréglage d'outil @@ -1042,7 +1045,7 @@ Duplicate Layers… Dupliquer les calques… Duplicate style Dupliquer le style Edit Toolbar… Modifier la barre d'outils… - Edit in Quick Mask Mode (Q) Modifier en mode masque rapide (Q) + Edit in Quick Mask Mode (Q) Modifier en mode Masque (Q) Edit in Standard Mode (Q) Modifier en mode standard (Q) Effects Effets Embed Color Profile Incorporer le profil colorimétrique @@ -1061,15 +1064,15 @@ Factor: Facteur : Faux Bold Faux gras Faux Italic Faux italique - Feather Adoucissement + Feather Contour progressif Fewer Points Moins de points File Settings Paramètres du fichier Fill Remplissage - Fill: Remplissage : - Filter for adjustment layers Filtrer les calques d'ajustement + Fill: Fond : + Filter for adjustment layers Filtrer les calques de réglage Filter for pixel layers Filtrer les calques de pixels Filter for shape layers Filtrer les calques de forme - Filter for smart objects Filtrer les objets intelligents + Filter for smart objects Filtrer les objets dynamiques Filter for type layers Filtrer les calques de texte Filter: Filtre : Finger Painting Peinture au doigt @@ -1105,7 +1108,7 @@ Gamma: Gamma : Global Global Glow Color Couleur de la lueur - Gradient Map Mappage de dégradé + Gradient Map Courbe de transfert de dégradé Grain Grain Gray Gris Gray Gamma 2.2 Gris gamma 2,2 @@ -1117,9 +1120,9 @@ Height: Hauteur : Hide Auto Slices Masquer les tranches automatiques Hide Properties Masquer les propriétés - Highlight Compression Compression des hautes lumières - Highlight Priority Priorité aux hautes lumières - Highlights Hautes lumières + Highlight Compression Compression des tons clairs + Highlight Priority Priorité aux tons clairs + Highlights Tons clairs Home Accueil Horizontal scale Échelle horizontale Hue Teinte @@ -1145,7 +1148,7 @@ Interpolation: Interpolation : Intersect with selection ({key}) Intersection avec la sélection ({key}) Invert All Tout inverser - Invert has no settings. Inverser n'a aucun réglage. + Invert has no settings. Inverser n'a aucun paramètre. Italic Italique Join us on Discord Rejoignez-nous sur Discord Justify all Justifier tout @@ -1171,15 +1174,15 @@ Levels Niveaux Lighten Éclaircir Lightness Luminosité - Lights Tons clairs + Lights Teintes claires Limits: Limites : Linear Gradient Dégradé linéaire Link layers Lier les calques Link width and height Lier la largeur et la hauteur - Liquify is not open Liquéfier n'est pas ouvert + Liquify is not open Fluidité n'est pas ouvert Load Charger Load 3D LUT… Charger une LUT 3D… - Load channel as selection Charger le canal comme sélection + Load channel as selection Récupérer la couche comme sélection Load… Charger… Localized Color Clusters Groupes de couleurs localisés Lock Verrouiller @@ -1229,7 +1232,7 @@ New effect layer Nouveau calque d'effet New selection Nouvelle sélection Next frame Image suivante - Next note Note suivante + Next note Annotation suivante No Color Aucune couleur No Color Management Aucune gestion des couleurs No Dither Sans tramage @@ -1255,9 +1258,9 @@ Open a document to use the timeline. Ouvrez un document pour utiliser le montage. Optical Optique Optimized Optimisé - Option-click to define a source point to clone from Option-clic pour définir le point source du clonage + Option-click to define a source point to clone from Option-clic pour définir le point source de la duplication Original Original - Output Channel: Canal de sortie : + Output Channel: Couche de sortie : Output Levels: Niveaux de sortie : Output: Sortie : Outside Extérieur @@ -1294,7 +1297,7 @@ Portrait Portrait Position Position Position and Size Position et taille - Posterize Postériser + Posterize Isohélie Preserve Details Préserver les détails Preserve Luminosity Préserver la luminosité Preset Préréglage @@ -1308,7 +1311,7 @@ Prevent auto-nesting in and out of Artboards and Frames Empêcher l'imbrication automatique dans et hors des plans de travail et des cadres Preview Aperçu Previous frame Image précédente - Previous note Note précédente + Previous note Annotation précédente Print Imprimer Printer Imprimante Printer Manages Colors L'imprimante gère les couleurs @@ -1321,7 +1324,7 @@ Protect Tones Protéger les tons Proximity Match Correspondance de proximité Pulled String Mode Mode corde tendue - Puppet Warp is not active La déformation par épingles n'est pas active + Puppet Warp is not active La déformation de la marionnette n'est pas active Purity Pureté Quality Qualité Quick Actions Actions rapides @@ -1393,7 +1396,7 @@ Saturation Jitter Variation de la saturation Save HTML and Images Enregistrer le HTML et les images Save as PDF Enregistrer en PDF - Save selection as channel Enregistrer la sélection comme canal + Save selection as channel Mémoriser la sélection sur une couche Save… Enregistrer… Scale to Fit Media Adapter au support Scatter Diffusion @@ -1409,17 +1412,17 @@ Select: Sélection : Selections Sélections Selective Sélective - Selective Color Couleur sélective + Selective Color Correction sélective Send the selected pin backward Reculer l'épingle sélectionnée Set additional smoothing options Définir des options de lissage supplémentaires Set background color Définir la couleur d'arrière-plan Set foreground color Définir la couleur de premier plan Set painting symmetry options Définir les options de symétrie de peinture Symmetry Off Symétrie désactivée - Set shape fill type Définir le type de remplissage de la forme + Set shape fill type Définir le type de fond de la forme Set shape stroke type Définir le type de contour de la forme Set the text color Définir la couleur du texte - Shadows Ombres + Shadows Tons foncés Shape: Forme : Sharpening Netteté Shift Maj @@ -1431,7 +1434,7 @@ Show Mesh Afficher le maillage Show Overlay Afficher la superposition Show Transform Controls Afficher les contrôles de transformation - Show or hide the Notes panel Afficher ou masquer le panneau Notes + Show or hide the Notes panel Afficher ou masquer le panneau Annotations Show: Afficher : Sides Côtés Sides: Côtés : @@ -1467,7 +1470,7 @@ Suggestions: Suggestions : Swap colours (X) Permuter les couleurs (X) Swaps height and width Permute la hauteur et la largeur - Switch between free transform and warp modes Basculer entre les modes transformation libre et déformation + Switch between free transform and warp modes Basculer entre les modes transformation manuelle et déformation Switch theme Changer de thème Temperature Température Tetrahedral Tétraédrique @@ -1544,7 +1547,7 @@ No saved presets yet. Aucun préréglage enregistré pour le moment. Error Erreur Command Commande - Adjustment layers have nothing to transform Les calques d'ajustement n'ont rien à transformer + Adjustment layers have nothing to transform Les calques de réglage n'ont rien à transformer Altitude Altitude Android 1080p Android 1080p Apple Watch 45mm Apple Watch 45 mm @@ -1552,10 +1555,10 @@ Art & Illustration Art et illustration ArtCraft ArtCraft Background Eraser Tool Outil Gomme d'arrière-plan - Bevel & Emboss Biseau et relief + Bevel & Emboss Biseautage et estampage Blend Mode Mode de fusion Blues Bleus - Blur Tool Outil Flou + Blur Tool Outil Goutte d'eau Bold Gras Brush Pinceau Block Carré @@ -1563,24 +1566,24 @@ Brush Tip Shape Forme de la pointe Brush Tool Outil Pinceau Build-up Accumulation - Burn Tool Outil Assombrir + Burn Tool Outil Densité + Centimeters Centimètres Choke Resserrement Classic Classique Click to add text Cliquez pour ajouter du texte Click to fill similar colours Cliquez pour remplir les couleurs similaires - Clone Stamp Tool Outil Tampon de clonage + Clone Stamp Tool Outil Tampon de duplication Color Burn Densité couleur + Color Dodge Densité couleur − Color Dynamics Dynamique de la couleur - Color Overlay Superposition de couleur + Color Overlay Incrustation couleur Comic Book Bande dessinée Conform Conforme - Content-Aware Fill needs a selection on a pixel layer Le remplissage selon le contenu nécessite une sélection sur un calque de pixels + Content-Aware Fill needs a selection on a pixel layer Le remplissage d'après le contenu nécessite une sélection sur un calque de pixels Could not transform: the layer is empty Transformation impossible : le calque est vide Could not use Perspective Warp: the layer is empty Impossible d'utiliser la déformation de la perspective : le calque est vide - Could not use Puppet Warp: no opaque pixels Impossible d'utiliser la déformation par épingles : aucun pixel opaque - Could not use Puppet Warp: the layer is empty Impossible d'utiliser la déformation par épingles : le calque est vide + Could not use Puppet Warp: no opaque pixels Impossible d'utiliser la déformation de la marionnette : aucun pixel opaque + Could not use Puppet Warp: the layer is empty Impossible d'utiliser la déformation de la marionnette : le calque est vide Crop Tool Outil Recadrage Crosswise En croix Current Tool Outil actif @@ -1595,7 +1598,7 @@ Document Dimensions Dimensions du document Document Profile Profil du document Document Sizes Tailles du document - Dodge Tool Outil Éclaircir + Dodge Tool Outil Densité − Down Bas Drag to draw a gradient Faites glisser pour tracer un dégradé Drag to move the active layer Faites glisser pour déplacer le calque actif @@ -1606,30 +1609,30 @@ Efficiency Efficacité Efficiency: 100% Efficacité : 100 % Ellipse Tool Outil Ellipse - Elliptical Marquee Tool Outil Sélection elliptique - Single Row Marquee Tool Outil Sélection de ligne unique - Single Column Marquee Tool Outil Sélection de colonne unique - Emboss Relief + Elliptical Marquee Tool Outil Ellipse de sélection + Single Row Marquee Tool Outil Rectangle de sélection 1 rangée + Single Column Marquee Tool Outil Rectangle de sélection 1 colonne + Emboss Estampage Eraser Gomme Eraser Tool Outil Gomme ExtraBold Extra-gras ExtraLight Extra-maigre Eyedropper Tool Outil Pipette Fade Estompage - Fill Opacity Opacité du remplissage + Fill Opacity Opacité du fond Film & Video Film et vidéo From De General Général GitHub GitHub - Gradient Overlay Superposition de dégradé + Gradient Overlay Incrustation en dégradé Gradient Tool Outil Dégradé Greens Verts Hand Tool Outil Main Hard Dur Hard Mix Mélange maximal - Healing Brush Tool Outil Pinceau correcteur - Highlights from: Hautes lumières à partir de : - History Brush Tool Outil Pinceau d'historique + Healing Brush Tool Outil Correcteur + Highlights from: Tons clairs à partir de : + History Brush Tool Outil Forme d'historique Horizontal Type Tool Outil Texte horizontal Horizontal Type Mask Tool Outil Masque de texte horizontal Vertical Type Mask Tool Outil Masque de texte vertical @@ -1637,9 +1640,9 @@ Inches Pouces Indexed Indexé Initial Direction Direction initiale - Inner Bevel Biseau intérieur - Inner Glow Lueur intérieure - Inner Shadow Ombre intérieure + Inner Bevel Biseau interne + Inner Glow Lueur interne + Inner Shadow Ombre interne Lab Lab Landscape, 10 x 8 Paysage, 10 x 8 Landscape, 6 x 4 Paysage, 6 x 4 @@ -1668,20 +1671,20 @@ Mobile Mobile Move Tool Outil Déplacement Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé. - Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation libre, car le calque est verrouillé. + Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation manuelle, car le calque est verrouillé. Multiply Produit My Brushes Mes pinceaux Neutrals Neutres New Workspace Nouvel espace de travail - Note Tool Outil Note + Note Tool Outil Annotation Object Selection Tool Outil Sélection d'objet Off Désactivé Open Ouvrir Orange Orange Original Ratio Proportions d'origine - Out Of Gamut Hors gamut - Outer Bevel Biseau extérieur - Outer Glow Lueur extérieure + Out Of Gamut Couleurs non imprimables + Outer Bevel Biseau externe + Outer Glow Lueur externe Overlay Incrustation Paint Bucket Tool Outil Pot de peinture Paints from the document's opening state Peint à partir de l'état d'ouverture du document @@ -1691,7 +1694,7 @@ Delete Anchor Point Tool Outil Suppression de point d'ancrage Click a segment to add an anchor point · {alt}-click an anchor to delete it Cliquez sur un segment pour ajouter un point d'ancrage · {alt}-clic sur un point d'ancrage pour le supprimer Click an anchor point to delete it · {alt}-click a segment to add one Cliquez sur un point d'ancrage pour le supprimer · {alt}-clic sur un segment pour en ajouter un - Pattern Overlay Superposition de motif + Pattern Overlay Incrustation de motif Pen Pressure Pression du stylet Pen Tilt Inclinaison du stylet Pen Tool Outil Plume @@ -1699,7 +1702,7 @@ Photo Photo PhotoCraft website Site web de PhotoCraft Picas Picas - Pillow Emboss Relief en coussin + Pillow Emboss Estampage oreiller Pixel Replacement Remplacement de pixels Pixels Pixels Points Points @@ -1716,15 +1719,15 @@ Pro (Medium Gray) Pro (gris moyen) Protect Texture Protéger la texture Purple Violet - Quick Mask Masque rapide + Quick Mask Mode Masque Quick Selection Tool Outil Sélection rapide Radial Radial - Rasterize Layer Rastériser le calque - Rasterize Layers Rastériser les calques + Rasterize Layer Pixelliser le calque + Rasterize Layers Pixelliser les calques Ratio Proportions Recent Récent Rectangle Tool Outil Rectangle - Rectangular Marquee Tool Outil Sélection rectangulaire + Rectangular Marquee Tool Outil Rectangle de sélection Reds Rouges Reflected Réfléchi Sample: foreground colour Échantillon : couleur de premier plan @@ -1736,13 +1739,13 @@ Select Data Points Sélectionner les points de données Selection Sélection SemiBold Demi-gras - Shadows up to: Ombres jusqu'à : + Shadows up to: Tons foncés jusqu'à : Shape Dynamics Dynamique de la forme Sharpen Tool Outil Netteté Show Extras Options Options d'affichage des extras Slice Select Tool Outil Sélection de tranche Slice Tool Outil Tranche - Smart Filters Filtres intelligents + Smart Filters Filtres dynamiques Smudge Tool Outil Doigt Soft Doux Soften Adoucir @@ -1793,15 +1796,15 @@ @plural {n} Layer|{n} Layers {n} calque|{n} calques @plural {n} layer|{n} layers {n} calque|{n} calques @plural Group · {n} layer|Group · {n} layers Groupe · {n} calque|Groupe · {n} calques - Additional plugins folder Dossier d'extensions supplémentaire + Additional plugins folder Dossier de modules externes supplémentaire Adobe Adobe Adobe rgb Adobe RGB Allow agent control Autoriser le pilotage par des agents Allow scripts to connect Autoriser la connexion des scripts Always Toujours - Always create smart objects when placing Toujours créer des objets intelligents lors du placement + Always create smart objects when placing Toujours créer des objets dynamiques lors du placement Animated zoom Zoom animé - Apply auto tone Appliquer la tonalité automatique + Apply auto tone Appliquer le ton automatique Ask Demander Ask before saving layered tiff Demander avant d'enregistrer un TIFF avec calques Auto collapse icon panels Réduire automatiquement les panneaux en icônes @@ -1818,17 +1821,17 @@ Cache levels Niveaux de cache Cache tile size Taille des tuiles du cache Camera Raw Defaults Valeurs par défaut de Camera Raw - Canvas border Bordure du canevas - Show canvas dots Afficher les points du canevas - Canvas color Couleur du canevas - Canvas custom color Couleur personnalisée du canevas + Canvas border Bordure de la zone de travail + Show canvas dots Afficher les points de la zone de travail + Canvas color Couleur de la zone de travail + Canvas custom color Couleur personnalisée de la zone de travail Color picker Sélecteur de couleurs Color space Espace colorimétrique Column width Largeur de colonne Concise Concis Control port Port de contrôle Convert to srgb Convertir en sRVB - Cursors Curseurs + Cursors Pointeurs Custom dark Personnalisé sombre Custom light Personnalisé clair Dark Sombre @@ -1838,7 +1841,7 @@ Detailed Détaillé Disks Disques Display p3 Display P3 - Double click layer mask launches select and mask Un double-clic sur le masque de calque ouvre Sélectionner et masquer + Double click layer mask launches select and mask Un double-clic sur le masque de fusion ouvre Sélectionner et masquer Drop shadow Ombre portée Dynamic color sliders Curseurs de couleur dynamiques East asian Asie orientale @@ -1855,8 +1858,8 @@ Fill new type layers with placeholder Remplir les nouveaux calques de texte avec un texte de substitution Font preview Aperçu des polices Full size tip Pointe en taille réelle - Gamut warning color Couleur de l'alerte de gamut - Gamut warning opacity Opacité de l'alerte de gamut + Gamut warning color Couleur des alertes de couleurs non imprimables + Gamut warning opacity Opacité des alertes de couleurs non imprimables Generator enabled Générateur activé Gif GIF Glossy Brillant @@ -1896,7 +1899,7 @@ Nearest neighbor Au plus proche Never Jamais Normal tip Pointe normale - Open as smart object Ouvrir en tant qu'objet intelligent + Open as smart object Ouvrir en tant qu'objet dynamique Open in camera raw Ouvrir dans Camera Raw Open documents as tabs Ouvrir les documents dans des onglets Overscroll Défilement au-delà des bords @@ -1928,7 +1931,7 @@ Scrubby slider acceleration Accélération des curseurs glissants Sessions only Sessions uniquement Sharpen for Netteté pour - Show channels in color Afficher les canaux en couleur + Show channels in color Afficher les couches en couleur Show crosshair in brush tip Afficher le réticule dans la pointe Show extension panels Afficher les panneaux d'extension Show font names in english Afficher les noms de police en anglais @@ -1940,7 +1943,7 @@ Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches - Smart guide color Couleur des repères intelligents + Smart guide color Couleur des repères commentés Smart quotes Guillemets typographiques Snap vector tools and transforms to pixel grid Aimanter les outils vectoriels et les transformations à la grille de pixels Srgb sRVB @@ -1955,15 +1958,15 @@ Text file Fichier texte Tiny Minuscule Touch gestures Gestes tactiles - Transparency & Gamut Transparence et gamut + Transparency & Gamut Transparence et couleurs non imprimables Type units Unités de texte UI font size Taille de police de l'interface UI scale Échelle de l'interface Units & Rulers Unités et règles - Use additional plugins folder Utiliser le dossier d'extensions supplémentaire + Use additional plugins folder Utiliser le dossier de modules externes supplémentaire Use esc to commit Valider avec Échap Use gpu Utiliser le GPU - Use legacy free transform Utiliser l'ancienne transformation libre + Use legacy free transform Utiliser l'ancienne transformation manuelle Use shift key for tool switch Utiliser Maj pour changer d'outil Vary round brush hardness on hud Varier la dureté du pinceau rond dans l'affichage tête haute Webp WebP @@ -1975,9 +1978,9 @@ Pass Through Transfert Dissolve Fondu Darker Color Couleur plus foncée - Linear Dodge (Add) Densité linéaire − (Addition) + Linear Dodge (Add) Densité linéaire − (Ajout) Lighter Color Couleur plus claire - Soft Light Lumière douce + Soft Light Lumière tamisée Hard Light Lumière crue Vivid Light Lumière vive Linear Light Lumière linéaire @@ -1987,7 +1990,7 @@ Luminosity Luminosité @plural {n} sample|{n} samples {n} échantillon|{n} échantillons {added}, {removed} subtracted {added}, {removed} soustraits - Adjustment Preview Aperçu de l'ajustement + Adjustment Preview Aperçu du réglage Airbrush: keeps depositing paint while the pointer rests. Aérographe : continue de déposer de la peinture tant que le pointeur reste immobile. Angle: Angle : Blend If Fusion si @@ -2028,18 +2031,18 @@ Click to sample the foreground colour · {key}-click for background Cliquez pour prélever la couleur de premier plan · {key}-clic pour l'arrière-plan {key}-click to set the source {key}-clic pour définir la source {key} to subtract {key} pour soustraire - Clone source {n} ({key}-click with Clone Stamp to set) Source de clonage {n} ({key}-clic avec le tampon de clonage pour la définir) - Source: not set ({key}-click with Clone Stamp) Source : non définie ({key}-clic avec le tampon de clonage) + Clone source {n} ({key}-click with Clone Stamp to set) Source de duplication {n} ({key}-clic avec le tampon de duplication pour la définir) + Source: not set ({key}-click with Clone Stamp) Source : non définie ({key}-clic avec le tampon de duplication) Pixel Layer Calque de pixels Type Layer Calque de texte Shape Layer Calque de forme Group Layer Calque de groupe Fill Layer Calque de remplissage - Smart Object Layer Calque d'objet intelligent + Smart Object Layer Calque d'objet dynamique Create Warped Text Créer un texte déformé - Rasterize Smart Object Rastériser l'objet intelligent - Rasterize Fill Content Rastériser le contenu du remplissage - Rasterize Layer Style Rastériser le style de calque + Rasterize Smart Object Pixelliser l'objet dynamique + Rasterize Fill Content Pixelliser le contenu du remplissage + Rasterize Layer Style Pixelliser le style de calque Unlock: picking a preset replaces these settings Déverrouiller : choisir un préréglage remplace ces paramètres Lock: keep these settings when picking another preset Verrouiller : conserver ces paramètres en choisissant un autre préréglage Diameter: Diamètre : @@ -2065,7 +2068,7 @@ Mix Mélange Wetness Jitter Variation de l'humidité Mix Jitter Variation du mélange - Show the layer mask as an overlay Afficher le masque de calque en superposition + Show the layer mask as an overlay Afficher le masque de fusion en superposition Toggle visibility Afficher/masquer Unlink the mask from the layer Délier le masque du calque Link the mask to the layer Lier le masque au calque @@ -2077,7 +2080,7 @@ # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Éditeur de dégradé Gradient picker Sélecteur de dégradé - Save As Preset Enregistrer comme préreglage + Save As Preset Enregistrer comme préréglage The gradient the Gradient tool paints with Le dégradé que l'outil Dégradé applique Adds the current gradient to the Gradients panel Ajoute le dégradé actuel au panneau Dégradés Gradient stops Arrêts de dégradé @@ -2089,7 +2092,7 @@ Vertical type Texte vertical Fill path with foreground color Remplir le tracé avec la couleur de premier plan Stroke path with brush Contourner le tracé avec le pinceau - Load path as a selection Charger le tracé comme sélection + Load path as a selection Récupérer le tracé comme sélection Make work path from selection Créer un tracé de travail à partir de la sélection Add vector mask Ajouter un masque vectoriel Create new path Créer un nouveau tracé @@ -2104,7 +2107,7 @@ OpenGL OpenGL Use tablet pressure Utiliser la pression de la tablette Vulkan Vulkan - Alt-click to define a source point to clone from Alt-clic pour définir le point source du clonage + Alt-click to define a source point to clone from Alt-clic pour définir le point source de la duplication No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés. Cancel opening Annuler l'ouverture Cancelled opening {name} Ouverture de {name} annulée @@ -2124,8 +2127,8 @@ Working… Traitement… @id select.all Tout sélectionner @id edit.purge.undo Historique d'annulation - Delete Smart Filters Supprimer les filtres intelligents - Enable Smart Filters Activer les filtres intelligents + Delete Smart Filters Supprimer les filtres dynamiques + Enable Smart Filters Activer les filtres dynamiques New Layer… Nouveau calque… Delete Layer… Supprimer le calque… Color Range Plage de couleurs @@ -2145,7 +2148,7 @@ Stamp Tampon Torn Edges Bords déchirés Water Paper Papier mouillé - Canvas rotation Rotation du canevas + Canvas rotation Rotation de la zone de travail Extensions Extensions Libraries Bibliothèques 3D 3D @@ -2153,27 +2156,27 @@ Move Déplacement Wand Baguette magique Pencil Crayon - Clone Stamp Tampon de clonage + Clone Stamp Tampon de duplication Healing Correcteur - Dodge Éclaircir - Burn Assombrir + Dodge Densité − + Burn Densité + Pen Plume Hand Main Sampler Échantillonneur de couleur - Note tool Outil Note + Note tool Outil Annotation Quick Selection Sélection rapide Magnetic Lasso Lasso magnétique - History Brush Pinceau d'historique - Art History Brush Pinceau d'historique artistique + History Brush Forme d'historique + Art History Brush Forme d'historique artistique Pattern Stamp Tampon de motif Spot Healing Correcteur localisé - Red Eye Yeux rouges + Red Eye Œil rouge Patch Pièce - Content-Aware Move Déplacement selon le contenu + Content-Aware Move Déplacement basé sur le contenu Rotate View Rotation de la vue - Perspective Crop Recadrage en perspective + Perspective Crop Correction de perspective par recadrage Color Replacement Remplacement de couleur - Dodge/Burn Éclaircir/Assombrir + Dodge/Burn Densité −/Densité + Dynamics Dynamique Tilt Inclinaison Reset Réinitialiser @@ -2189,7 +2192,7 @@ Advanced Blending Fusion avancée RGB RVB Alpha Alpha - Spot Channel Canal de ton direct + Spot Channel Couche de ton direct Working Gray Plate Plaque grise de travail HSL/Grayscale TSL/Niveaux de gris Tone Curve Courbe des tonalités @@ -2226,7 +2229,7 @@ Localize Color Clusters Localiser les groupes de couleurs Duplicated selection name Nom de la sélection dupliquée Operation Opération - New channel only Nouveau canal uniquement + New channel only Nouvelle couche uniquement Masked Areas Zones masquées Selected Areas Zones sélectionnées Spot Color Ton direct @@ -2352,7 +2355,7 @@ Document Size Taille du document Timing Minutage Status Information Informations d'état - Saved Multichannel Multicanal enregistré + Saved Multichannel Multicouche enregistré Mouse Coordinates Coordonnées de la souris Ruler Units Unités des règles Hide Masquer @@ -2475,10 +2478,10 @@ Units Unités Columns Colonnes Create new count group Créer un nouveau groupe de comptage - Adjustment Layer Calque d'ajustement + Adjustment Layer Calque de réglage Pencil Tool Outil Crayon Pixel Pixel - Adjustment Ajustement + Adjustment Réglage Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Faites glisser (lasso) ou cliquez des points (polygonal) · {add} ajouter · {sub} soustraire 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 Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · flèches le décalent · X permute l'orientation · {commit} valide · Échap annule {mode} Color · {bits} bit Couleurs {mode} · {bits} bits @@ -2522,12 +2525,12 @@ Brush Size Taille du pinceau Heal Color Couleur de correction Mask Masque - Channel Canal + Channel Couche Rectangle Rectangle Result Résultat - New Channel Nouveau canal - Save Selection Enregistrer la sélection - Load Selection Charger la sélection + New Channel Nouvelle couche + Save Selection Mémoriser la sélection + Load Selection Récupérer la sélection Duplicate Dupliquer Fast Rapide Accurate Précis @@ -2549,7 +2552,7 @@ Click to zoom in · ⌥-click to zoom out Cliquez pour zoomer · ⌥-clic pour dézoomer Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic : angle · Glisser : lisse · Clic sur le premier point : fermer · ↩ : terminer · Échap : annuler Commit Perspective Warp (↩) Valider la déformation de la perspective (↩) - Commit Puppet Warp (↩) Valider la déformation par épingles (↩) + Commit Puppet Warp (↩) Valider la déformation de la marionnette (↩) Commit any current edits (⌘↩) Valider les modifications en cours (⌘↩) Commit current crop operation (↵) Valider le recadrage en cours (↵) Commit transform (↩) Valider la transformation (↩) @@ -2577,7 +2580,7 @@ # Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). New from Clipboard Créer depuis le presse-papiers - Free Transform a Copy Transformer librement une copie + Free Transform a Copy Transformation manuelle d'une copie Transform Again on a Copy Répéter la transformation sur une copie Select Layer Above Sélectionner le calque au-dessus Select Layer Below Sélectionner le calque au-dessous @@ -2632,8 +2635,8 @@ cameraRaw Light Lumière Camera Raw Filter ({layer}) Filtre Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) -cameraRaw Lights Tons clairs -cameraRaw Darks Tons sombres +cameraRaw Lights Teintes claires +cameraRaw Darks Teintes sombres cameraRaw Blacks Noirs cameraRaw Amount Quantité cameraRaw Tint Teinte @@ -2669,7 +2672,7 @@ cameraRaw Blending Fusion Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Faites glisser ou cliquez des points de polygone · maintenez Alt pendant le lasso pour des segments droits · {add} ajouter · {sub} avant de dessiner soustrait Extend Extension Structure: Structure : - Content-Aware Move Tool Outil Déplacement selon le contenu + Content-Aware Move Tool Outil Déplacement basé sur le contenu TIFF Options Options TIFF Targa Options Options Targa 24 bits/pixel 24 bits/pixel @@ -2736,8 +2739,8 @@ cameraRaw Blending Fusion # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte) Color Picker ({name}) Sélecteur de couleurs ({name}) - Color Picker (Fill Color) Sélecteur de couleurs (Couleur de remplissage) - Fill color Couleur de remplissage + Color Picker (Fill Color) Sélecteur de couleurs (Couleur de fond) + Fill color Couleur de fond Color Picker (Stroke Color) Sélecteur de couleurs (Couleur du contour) Stroke color Couleur du contour Pick screen color Choisir une couleur à l’écran @@ -2778,7 +2781,7 @@ cameraRaw Blending Fusion Rounded Steps Paliers arrondis Sawtooth 1 Dents de scie 1 - Red Eye Tool Outil Yeux rouges + Red Eye Tool Outil Œil rouge Pupil Size Taille de la pupille Darken Amount Quantité d'assombrissement Click a red pupil to neutralize it Cliquez sur une pupille rouge pour la corriger @@ -2836,8 +2839,8 @@ fontWeight Italic Italique 51 by 51 Average Moyenne 51 × 51 101 by 101 Average Moyenne 101 × 101 Sample Size: Taille de l'échantillon : - All Layers No Adjustments Tous les calques sans ajustements - Current & Below No Adjustments Actif et inférieurs sans ajustements + All Layers No Adjustments Tous les calques sans réglages + Current & Below No Adjustments Actif et inférieurs sans réglages Show Sampling Ring Afficher l'anneau d'échantillonnage # Color Picker warnings @@ -2933,7 +2936,7 @@ fontWeight Italic Italique Follows only edges this close to the pointer ([ and ] change it) Ne suit que les contours aussi proches du pointeur ([ et ] le modifient) Higher values follow only edges that contrast sharply with their surroundings Plus la valeur est élevée, plus seuls les contours très contrastés sont suivis How often fastening points are placed by themselves Fréquence de pose automatique des points d’ancrage - Appearance Settings… Réglages d’apparence… + Appearance Settings… Paramètres d’apparence… Add to favorites Ajouter aux favoris Remove from favorites Retirer des favoris # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). @@ -2968,7 +2971,7 @@ cameraData Lens Objectif Create a new rectangular frame Créer un cadre rectangulaire Create a new elliptical frame Créer un cadre elliptique Include in playback Inclure dans la lecture - Perspective Crop Tool Outil Recadrage perspective + Perspective Crop Tool Outil Correction de perspective par recadrage Show Grid Afficher la grille Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Tracez un cadre ou cliquez sur quatre coins · faites glisser les coins sur les bords d'une forme inclinée · {commit} valide · Échap annule Artboard Tool Outil Plan de travail @@ -2988,14 +2991,14 @@ cameraData Lens Objectif There is no crop size or ratio to save Aucune taille ni proportion de recadrage à enregistrer Scale: Échelle : Snap to Origin Aligner sur l'origine - Search settings… Rechercher des réglages… - No matching settings Aucun réglage correspondant + Search settings… Rechercher des paramètres… + No matching settings Aucun paramètre correspondant Loading fonts… Chargement des polices… Tool Outil Simulate Pressure Simuler la pression Stroke Path… Contourner le tracé… Background Eraser Gomme d'arrière-plan - Healing Brush Pinceau correcteur + Healing Brush Correcteur Spot Healing Brush Correcteur localisé Smudge Doigt Sponge Éponge @@ -3030,14 +3033,14 @@ curves Smooth Lisser Show rulers Afficher les règles # Color Sampler (added with the i18n coverage repair) - Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer + Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors de la zone de travail pour supprimer No document open Aucun document ouvert {n} of {max} sample points {n} sur {max} points d'échantillonnage # Print dialog (#2697) Emulsion Down Émulsion verso Functions Fonctions Print Resolution: Résolution d’impression : - Use Previous Layer to Create Clipping Mask Utiliser le calque précédent pour créer un masque de découpe + Use Previous Layer to Create Clipping Mask Utiliser le calque précédent pour créer un masque d'écrêtage Fill with {mode}-neutral color (white) Remplir avec la couleur neutre de {mode} (blanc) Fill with {mode}-neutral color (black) Remplir avec la couleur neutre de {mode} (noir) Fill with {mode}-neutral color (50% gray) Remplir avec la couleur neutre de {mode} (gris 50 %) @@ -3064,8 +3067,8 @@ curves Smooth Lisser Add Noise Ajout de bruit Added swatch {name} Nuance {name} ajoutée Adjust Interval For Loops Ajuster l'intervalle pour les boucles - Adjustment Layers Calques d'ajustement - Adjustment Properties Propriétés de l'ajustement + Adjustment Layers Calques de réglage + Adjustment Properties Propriétés du réglage Adobe-rgb-compat Adobe RGB (compatible) Adwaita (Dark) Adwaita (sombre) Adwaita (Light) Adwaita (clair) @@ -3096,7 +3099,7 @@ curves Smooth Lisser Artboards Plans de travail Artboards to Files Plans de travail en fichiers Artboards to PDF Plans de travail en PDF - Artistic Artistique + Artistic Artistiques Ask On Mismatch Demander en cas de non-concordance Ask On Missing Demander en cas d'absence Ask On Paste Demander au collage @@ -3122,7 +3125,7 @@ curves Smooth Lisser Blade Curvature Courbure des lames Blast Rafale Bleach Bypass Sans blanchiment - Blend Clipped Layers as Group Fusionner les calques découpés en tant que groupe + Blend Clipped Layers as Group Fusionner les calques écrêtés en tant que groupe Blend Interior Effects as Group Fusionner les effets internes en tant que groupe Blend Text Gamma Gamma de fusion du texte Blending Mode Mode de fusion @@ -3146,12 +3149,12 @@ curves Smooth Lisser Brush Strokes Traits de pinceau Brush Type Type de pinceau Burlap Toile de jute - Calculations Calculs + Calculations Opérations Camera Raw Camera Raw Camera Raw Filter Filtre Camera Raw Cancel transform Annuler la transformation Candle Light Bougie - Canvas Size Taille du canevas + Canvas Size Taille de la zone de travail Cell Size Taille des cellules Center X Centre X Center Y Centre Y @@ -3159,10 +3162,10 @@ curves Smooth Lisser Changes Saved Modifications enregistrées Changes have not been saved. Les modifications n'ont pas été enregistrées. Change… Modifier… - Channel1 Canal 1 - Channel2 Canal 2 - Channel3 Canal 3 - Channel4 Canal 4 + Channel1 Couche 1 + Channel2 Couche 2 + Channel3 Couche 3 + Channel4 Couche 4 Charcoal Area Zone de fusain Charcoal Thickness Épaisseur du fusain Check for Updates Rechercher des mises à jour @@ -3172,22 +3175,22 @@ curves Smooth Lisser Cleanliness Propreté Clear Amount Zone sans lueur Clear Selection Effacer la sélection - Clipping Mask Masque de découpe + Clipping Mask Masque d'écrêtage Clipping Path… Tracé de détourage… Clone Cloner - Clone source set at {x}, {y} Source de clonage définie en {x}, {y} + Clone source set at {x}, {y} Source de duplication définie en {x}, {y} Close Window Fermer la fenêtre Clumped Aggloméré Coarse Dots Points grossiers Coated-cmyk CMJN papier couché Color Decontaminate Décontaminer les couleurs - Color Halftone Trame de demi-teintes couleur + Color Halftone Demi-teintes couleur Color Indicates Masked Areas La couleur indique les zones masquées Color Indicates Selected Areas La couleur indique les zones sélectionnées Color Lookup Tables Tables de correspondance des couleurs Color Picker Sélecteur de couleurs Color Picker (Background Color) Sélecteur de couleurs (Couleur d'arrière-plan) - Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée du canevas) + Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée de la zone de travail) Color Picker (Foreground Color) Sélecteur de couleurs (Couleur de premier plan) Color Settings Paramètres de couleur Color Swatches Nuancier @@ -3205,7 +3208,7 @@ curves Smooth Lisser Contract Contracter Contrasty Contrasté Convert Convertir - Convert to Alpha Channel Convertir en canal alpha + Convert to Alpha Channel Convertir en couche alpha Convert to Linked Convertir en objet lié Convert to Profile Convertir en profil Cool Froid @@ -3213,7 +3216,7 @@ curves Smooth Lisser Cooling80 Refroidissement 80 Cooling82 Refroidissement 82 Copy Complete Stroke Copier le contour complet - Copy Fill Copier le remplissage + Copy Fill Copier le fond Correct C A Corriger l'aberration chromatique Correct Distortion Corriger la distorsion Correct Vignette Corriger le vignettage @@ -3232,10 +3235,10 @@ curves Smooth Lisser Crosshatch Hachures croisées Crystallize Cristallisation Currency Devise - Curve Darks Courbe : tons sombres - Curve Highlights Courbe : hautes lumières - Curve Lights Courbe : tons clairs - Curve Shadows Courbe : ombres + Curve Darks Courbe : teintes sombres + Curve Highlights Courbe : tons clairs + Curve Lights Courbe : teintes claires + Curve Shadows Courbe : tons foncés Curve Splits Courbe : points de séparation Custom Branch Color Couleur de branche personnalisée Custom Pixel Aspect Ratio Format des pixels personnalisé @@ -3254,7 +3257,7 @@ curves Smooth Lisser Date Date Date and Time Date et heure Day For Night Nuit américaine - De-Interlace Désentrelacer + De-Interlace Désentrelacement Deep Profond Deep Blue Bleu profond Deep Emerald Émeraude profond @@ -3270,13 +3273,13 @@ curves Smooth Lisser Definition Définition Defringe Supprimer les franges Degrees Degrés - Delete Channel Supprimer le canal + Delete Channel Supprimer la couche Delete Mask Supprimer le masque Delete… Supprimer… Depth Map Carte de profondeur Depth Mode Mode de profondeur Diameter Diamètre - Diffuse Diffuser + Diffuse Diffusion Diffuse Glow Lueur diffuse Direction Balance Équilibre des directions Disable Désactiver @@ -3284,7 +3287,7 @@ curves Smooth Lisser Disable Symmetry Path Désactiver le tracé de symétrie Disabled Désactivé Discard Changes Ignorer les modifications - Displace Déplacement + Displace Dispersion Display Affichage Display profile not used Profil d'affichage non utilisé Display profiles: {reason} Profils d'affichage : {reason} @@ -3301,11 +3304,11 @@ curves Smooth Lisser Drag: draw freehand · Release on the start point: close Glisser : tracer à main levée · Relâcher sur le point de départ : fermer Driver: {driver} Pilote : {driver} Dry Brush Pinceau sec - Duplicate Channel Dupliquer le canal + Duplicate Channel Dupliquer la couche Duplicate Layer Dupliquer le calque Duplicate Layers Dupliquer les calques Duplication Duplication - Dust & Scratches Poussière et rayures + Dust & Scratches Antipoussière Edge Brightness Luminosité du contour Edge Extension Extension du contour Edge Fidelity Fidélité des contours @@ -3324,7 +3327,7 @@ curves Smooth Lisser End Y Fin Y Enlarged Agrandi Even Fields Trames paires - Exit Quick Mask Quitter le mode masque rapide + Exit Quick Mask Quitter le mode Masque Export As Exporter sous Export Contents Exporter le contenu Export Image Exporter l'image @@ -3338,23 +3341,23 @@ curves Smooth Lisser Exported {path} {path} exporté Exported {path} ({size}) {path} exporté ({size}) Exporting… Exportation… - Extrude Extruder + Extrude Extrusion Faded Film Film délavé Failed Échec Failed to open file Échec de l'ouverture du fichier Failed to save file Échec de l'enregistrement du fichier Fallback: {reason} Repli : {reason} - Feather Radius Rayon d'adoucissement + Feather Radius Rayon du contour progressif Fiber Length Longueur des fibres Fibers Fibres - Field Blur Flou de champ + Field Blur Flou de profondeur de champ File Format Format de fichier File Info Informations sur le fichier File Name Nom du fichier File Size Taille du fichier File not found Fichier introuvable Filename Nom de fichier - Fill Color Couleur de remplissage + Fill Color Couleur de fond Fill Path… Remplir le tracé… Film Grain Grain de film Filter Gallery Galerie de filtres @@ -3381,7 +3384,7 @@ curves Smooth Lisser Foreground Level Niveau de premier plan Forward Warp Déformation vers l'avant Frame Cadre - Free Transform Path Transformation libre du tracé + Free Transform Path Transformation manuelle du tracé Freeze Lasso Lasso de figeage Freeze Mask Masque de figeage Fresco Fresque @@ -3411,9 +3414,9 @@ curves Smooth Lisser Grade Balance Balance de l'étalonnage Grade Blending Fusion de l'étalonnage Grade Global Étalonnage global - Grade Highlights Étalonnage des hautes lumières + Grade Highlights Étalonnage des tons clairs Grade Midtones Étalonnage des tons moyens - Grade Shadows Étalonnage des ombres + Grade Shadows Étalonnage des tons foncés Grain Amount Quantité de grain Grain Roughness Rugosité du grain Grain Size Taille du grain @@ -3433,7 +3436,7 @@ curves Smooth Lisser Groups Groupes Grout Width Largeur du joint Guide Layout… Disposition des repères… - HDR Toning Tonalité HDR + HDR Toning Virage HDR Hearts Cœurs Heptagon Heptagone Hexagon Hexagone @@ -3442,12 +3445,12 @@ curves Smooth Lisser Hide Others Masquer les autres Hide {app} Masquer {app} High Pass Passe-haut - Highlight Hautes lumières - Highlight Amount Quantité des hautes lumières - Highlight Area Zone des hautes lumières - Highlight Radius Rayon des hautes lumières - Highlight Strength Intensité des hautes lumières - Highlight Tone Amplitude tonale des hautes lumières + Highlight Tons clairs + Highlight Amount Quantité des tons clairs + Highlight Area Zone des tons clairs + Highlight Radius Rayon des tons clairs + Highlight Strength Intensité des tons clairs + Highlight Tone Amplitude tonale des tons clairs Horizontal Only Horizontal seulement Hotspot Point chaud Hsb TSL @@ -3473,7 +3476,7 @@ curves Smooth Lisser Input Entrée Input Levels Niveaux d'entrée Input Mode Mode d'entrée - Install Plug-in Installer une extension + Install Plug-in Installer un module externe Integrated Density Densité intégrée Intensity Intensité Intent Intention de rendu @@ -3481,8 +3484,8 @@ curves Smooth Lisser Interval Intervalle Invalid panel layout; restored the default dock Disposition des panneaux non valide ; l'ancrage par défaut a été rétabli Invert Mask Inverser le masque - Invert Selection Inverser la sélection - Iris Blur Flou d'iris + Invert Selection Intervertir la sélection + Iris Blur Flou de diaphragme Ivy Lierre Jag Dentelé Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft @@ -3497,7 +3500,7 @@ curves Smooth Lisser Layer Comps to Files Compositions de calques en fichiers Layer Content Options Options de contenu du calque Layer From Background Calque d'après l'arrière-plan - Layer Mask Hides Effects Le masque de calque masque les effets + Layer Mask Hides Effects Le masque de fusion masque les effets Layer Properties Propriétés du calque Layer Styles Styles de calque Layer from Background Calque d'après l'arrière-plan @@ -3510,9 +3513,9 @@ curves Smooth Lisser Left Diagonal Diagonale gauche Legacy Ancienne version Length Longueur - Lens Blur Flou d'objectif + Lens Blur Flou de l'objectif Lens Correction Correction de l'objectif - Lens Flare Reflet d'objectif + Lens Flare Halo Less Moins Level Based Selon les niveaux Library Bibliothèque @@ -3527,11 +3530,11 @@ curves Smooth Lisser Lighten Grout Éclaircir le joint Lighten Only Éclaircir seulement Lighting Éclairage - Lighting Effects Effets d'éclairage + Lighting Effects Éclairage Linear Dodge Densité linéaire − Linear-srgb sRVB linéaire Link Lier - Liquify Liquéfier + Liquify Fluidité List Liste List view Affichage en liste Live Preview Aperçu en direct @@ -3559,7 +3562,7 @@ curves Smooth Lisser Mask Incomplete Blocks Masquer les blocs incomplets Mask None Ne rien masquer Masks Masques - Match Color Faire correspondre la couleur + Match Color Correspondance de la couleur Math Symbols Symboles mathématiques Max Offset Décalage max. Max Radius Rayon max. @@ -3567,11 +3570,11 @@ curves Smooth Lisser Medium Gray Gris moyen Medium Lines Lignes moyennes Medium Strokes Traits moyens - Merge Channels… Fusionner les canaux… - Merge Spot Channel Fusionner le canal de ton direct + Merge Channels… Fusionner les couches… + Merge Spot Channel Fusionner la couche de ton direct Merge to HDR Pro Fusionner en HDR Pro Metallic Métallique - Mezzotint Manière noire + Mezzotint Mezzo-tinto Middle Milieu Midtone Contraste des tons moyens Monitor Profile Profil du moniteur @@ -3585,7 +3588,7 @@ curves Smooth Lisser More Plus Mosaic Mosaïque Mosaic Tiles Carreaux de mosaïque - Motion Blur Flou de mouvement + Motion Blur Flou directionnel Mouse Souris Move Layer Déplacer le calque Movie Prime Focale fixe cinéma @@ -3597,10 +3600,10 @@ curves Smooth Lisser Neon Glow Lueur néon Neutralize Neutraliser New Brush Preset Nouveau préréglage de pinceau - New Channel… Nouveau canal… + New Channel… Nouvelle couche… New Guide Nouveau repère New Guide Layout Nouvelle disposition de repères - New Spot Channel… Nouveau canal de ton direct… + New Spot Channel… Nouvelle couche de ton direct… New Video Layer from File Nouveau calque vidéo à partir d'un fichier New Window Nouvelle fenêtre New document Nouveau document @@ -3656,7 +3659,7 @@ curves Smooth Lisser Paper Brightness Luminosité du papier Parallel Parallèle Paste Complete Stroke Coller le contour complet - Paste Fill Coller le remplissage + Paste Fill Coller le fond Patchwork Patchwork Path Blur Flou de tracé Paths to Illustrator Tracés vers Illustrator @@ -3669,10 +3672,10 @@ curves Smooth Lisser PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continue de réessayer ; tant qu'un enregistrement n'a pas abouti, les modifications des préférences seront perdues à la fermeture. Photomerge Photomerge Picture Frame Cadre d'image - Pinch Pincement + Pinch Contraction Pins Épingles - Place Embedded Placer incorporé - Place Linked Placer lié + Place Embedded Importer et incorporer + Place Linked Importer et lier Place Scale Marker Placer un repère d'échelle Plane Plan Planes Plans @@ -3739,14 +3742,14 @@ curves Smooth Lisser Remove All Tout supprimer Remove Jpeg Artifact Supprimer l'artefact JPEG Rename Brush Renommer le pinceau - Rename Channel… Renommer le canal… + Rename Channel… Renommer la couche… Rename Group Renommer le groupe Rename Tool Preset Renommer le préréglage d'outil Render Video Rendu vidéo Rendering… Rendu en cours… Reorder Layer Réorganiser le calque Replace Remplacer - Replace Color Remplacer la couleur + Replace Color Remplacement de couleur Replace Contents Remplacer le contenu Replace Footage Remplacer le métrage Report a Bug Signaler un bogue @@ -3795,7 +3798,7 @@ curves Smooth Lisser Search menus Rechercher dans les menus Seed Valeur initiale Select Custom Color… Choisir une couleur personnalisée… - Select Inverse Inverser la sélection + Select Inverse Intervertir la sélection Select Layer Sélectionner le calque Select and Mask Sélectionner et masquer Selected at launch: {backend} Sélectionné au lancement : {backend} @@ -3806,12 +3809,12 @@ curves Smooth Lisser Set Colors Définir les couleurs Set Measurement Scale Définir l'échelle de mesure Sgray sGray - Shadow Ombres - Shadow Amount Quantité des ombres - Shadow Intensity Intensité des ombres - Shadow Radius Rayon des ombres - Shadow Tone Amplitude tonale des ombres - Shadows/Highlights Ombres/Hautes lumières + Shadow Tons foncés + Shadow Amount Quantité des tons foncés + Shadow Intensity Intensité des tons foncés + Shadow Radius Rayon des tons foncés + Shadow Tone Amplitude tonale des tons foncés + Shadows/Highlights Tons foncés/Tons clairs Shallow Superficiel Shape Blur Flou selon une forme Sharpen Amount Quantité de netteté @@ -3832,8 +3835,8 @@ curves Smooth Lisser Sketch Esquisse Skip Ignorer Slice Options Options de tranche - Smart Blur Flou intelligent - Smart Sharpen Netteté intelligente + Smart Blur Flou optimisé + Smart Sharpen Netteté optimisée Smoothness Lissage Smudge Stick Estompe Snap to Grid Aimanter à la grille @@ -3851,7 +3854,7 @@ curves Smooth Lisser Spherize Sphérisation Spin Rotation Spin Blur Flou de rotation - Split Channels Séparer les canaux + Split Channels Séparer les couches Spot Spot Spray Radius Rayon de pulvérisation Sprayed Strokes Traits pulvérisés @@ -3894,8 +3897,8 @@ curves Smooth Lisser Texture Coverage Couverture de la texture Texturizer Texturisation Thaw Mask Masque de libération - The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. - The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas sur {display} est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. La zone de travail est affichée en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. La zone de travail sur {display} est affichée en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. The current gradient (pick one in Window › Gradients) Le dégradé actuel (à choisir dans Fenêtre › Dégradés) The document does not have an embedded colour profile. Working space: {working}. Le document ne contient aucun profil colorimétrique incorporé. Espace de travail : {working}. The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Le document contient un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail.\nIncorporé : {embedded}\nEspace de travail : {working} @@ -3903,15 +3906,15 @@ curves Smooth Lisser The file could not be saved. Impossible d'enregistrer le fichier. Thickness Épaisseur This Layer: Ce calque : - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? - This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. - This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? - This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet dynamique doit être pixellisé pour continuer. Son contenu ne sera plus modifiable. Pixelliser l'objet dynamique ? + This adjustment ({key}) is kept as imported and can't be edited yet. Ce réglage ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être pixellisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Pixelliser le calque ? + This layer must be rasterized before proceeding. Ce calque doit être pixellisé pour continuer. This session: {reason} Cette session : {reason} - This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être rastérisé pour continuer. Il ne sera plus modifiable en tant que forme. Rastériser la forme ? - This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être rastérisé pour continuer. Son texte ne sera plus modifiable. Rastériser le texte ? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être pixellisé pour continuer. Il ne sera plus modifiable en tant que forme. Pixelliser la forme ? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être pixellisé pour continuer. Son texte ne sera plus modifiable. Pixelliser le texte ? Tile Size Taille des carreaux - Tiles Carreaux + Tiles Carrelage Tilt-Shift Inclinaison-décalage Tint Color Couleur de la nuance Tiny Lens Petites lentilles @@ -3919,13 +3922,13 @@ curves Smooth Lisser To Center Vers le centre Toggle the Brush Settings panel Afficher/masquer le panneau Paramètres du pinceau Tool Options Options d'outil - Trace Contour Tracer les contours + Trace Contour Courbes de niveau Transformation Transformation Transition Transition Transparency Shapes Layer La transparence détermine la forme du calque Trap Recouvrement Tree Arbre - Trim Rogner les bords + Trim Rognage Turbulent Turbulent Twirl Tourbillon Twirl Clockwise Tourbillon horaire @@ -3942,7 +3945,7 @@ curves Smooth Lisser Unlock Déverrouiller Unlocked Déverrouillé Unsaved Changes Modifications non enregistrées - Unsharp Mask Masque flou + Unsharp Mask Accentuation Unsupported file format Format de fichier non pris en charge Unsupported format Format non pris en charge Up Haut @@ -3960,8 +3963,8 @@ curves Smooth Lisser Vertical Type Tool Outil Texte vertical Video Frames to Layers Images vidéo dans des calques Vignette Amount Quantité de vignettage - Vignette Feather Adoucissement du vignettage - Vignette Highlights Hautes lumières du vignettage + Vignette Feather Contour progressif du vignettage + Vignette Highlights Tons clairs du vignettage Vignette Midpoint Point médian du vignettage Vignette Roundness Arrondi du vignettage Vignette Style Style du vignettage @@ -3987,7 +3990,7 @@ curves Smooth Lisser White Is High Le blanc est le plus haut Wide Blurry Large et flou Wide Sharp Large et net - Wind Vent + Wind Soufflerie Window renderer: {renderer} Moteur de rendu de la fenêtre : {renderer} Working Espace de travail Working Cmyk CMJN de travail @@ -4004,7 +4007,7 @@ curves Smooth Lisser gradients dégradés gray gris green vert - layer mask masque de calque + layer mask masque de fusion manual manuel none aucun off désactivé @@ -4026,11 +4029,11 @@ curves Smooth Lisser ✓ Color Indicates Masked Areas ✓ La couleur indique les zones masquées ✓ Color Indicates Selected Areas ✓ La couleur indique les zones sélectionnées @id edit.clear Effacer -@id edit.stroke Contourner… -@id edit.transform.scale Mise à l'échelle +@id edit.stroke Contour… +@id edit.transform.scale Homothétie @id file.close Fermer -@id filter.sharpen.sharpen Netteté -@id layer.layerMask.apply Appliquer le masque de calque +@id filter.sharpen.sharpen Plus net +@id layer.layerMask.apply Appliquer le masque de fusion @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} nuance exportée vers {file}|{n} nuances exportées vers {file} @plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pinceau importé depuis {file}|{n} pinceaux importés depuis {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group} @@ -4038,7 +4041,7 @@ curves Smooth Lisser @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} nuance importée depuis {file}|{n} nuances importées depuis {file} @plural Played {n} step|Played {n} steps {n} étape lue|{n} étapes lues @plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet intelligent lié modifié sur le disque|Mise à jour de {n} objets intelligents liés modifiés sur le disque +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet dynamique lié modifié sur le disque|Mise à jour de {n} objets dynamiques liés modifiés sur le disque @plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré par cette composition a été supprimé. Mettez la composition à jour ou effacez l'avertissement.|{n} calques enregistrés par cette composition ont été supprimés. Mettez la composition à jour ou effacez l'avertissement. @plural {n} plane|{n} planes {n} plan|{n} plans @plural {n} preset|{n} presets {n} préréglage|{n} préréglages @@ -4061,10 +4064,10 @@ cameraRaw Dehaze Correction de la brume cameraRaw Detail Détail cameraRaw Effects Effets cameraRaw Exposure Exposition -cameraRaw Feather Adoucissement +cameraRaw Feather Contour progressif cameraRaw Global Global cameraRaw Green Vert -cameraRaw Highlights Hautes lumières +cameraRaw Highlights Tons clairs cameraRaw Hue Teinte cameraRaw Luminance Luminance cameraRaw Luminance Detail Détail de la luminance @@ -4078,7 +4081,7 @@ cameraRaw Red Rouge cameraRaw Roughness Rugosité cameraRaw Roundness Arrondi cameraRaw Saturation Saturation -cameraRaw Shadows Ombres +cameraRaw Shadows Tons foncés cameraRaw Size Taille cameraRaw Temperature Température cameraRaw Texture Texture @@ -4090,7 +4093,7 @@ document Orientation Orientation layerLabel Magenta Magenta layerLabel No Color Aucune couleur layerLabel Red Rouge -layers Fill Remplissage -layers Fill: Remplissage : +layers Fill Fond +layers Fill: Fond : navigator Zoom Zoom shape Appearance Apparence From 5484591dc274ed358ac155923f23acc7ece84db4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:49:20 +0200 Subject: [PATCH 0050/1452] docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 2d653afce6..75d4bf46d8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -112,7 +112,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | -| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | +| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | From 6bf45d05acb3550f2cd87169b9fb5a9564e0257b Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:10:38 +0300 Subject: [PATCH 0051/1452] i18n: translate the Layers Panel strings in hu, tr and vi; drop Italian duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was written, so every_tl_literal_is_translated failed on the merged tree. Main also translated New Layer…, Panel Options… and Lock Layers in Italian (#3742); drop this branch's copies, which bundled_catalogs_are_consistent rejects as duplicates. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/hu.tsv | 17 +++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 3 --- crates/ui-egui/src/i18n/tr.tsv | 17 +++++++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..b401617443 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,3 +3972,20 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups „másolat” hozzáadása a másolt rétegekhez és csoportokhoz + Animation Options Animációs beállítások + Entire Document Teljes dokumentum + Expand New Effects Új effektusok kibontása + Filter Options Szűrési beállítások + Layer Bounds Réteghatárok + Layers Panel Options Rétegek panel beállításai + New Artboard… Új rajztábla… + New Group… Új csoport… + Prevent Auto-Nesting Automatikus beágyazás tiltása + Preview on canvas when reordering layers Előnézet a vásznon a rétegek átrendezésekor + Show Layers Rétegek megjelenítése + Show layer mask badges Rétegmaszk-jelvények megjelenítése + Thumbnail Contents Bélyegkép tartalma + Thumbnail Size Bélyegképméret diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 94c3b78e53..3235de7200 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2649,11 +2649,8 @@ cameraData Lens Obiettivo Filter Options Opzioni filtro Layer Bounds Limiti livello Layers Panel Options Opzioni pannello Livelli - Lock Layers Blocca livelli New Artboard… Nuova tavola da disegno… New Group… Nuovo gruppo… - New Layer… Nuovo livello… - Panel Options… Opzioni pannello… Prevent Auto-Nesting Impedisci annidamento automatico Show Layers Mostra livelli Thumbnail Contents Contenuto miniature diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..48e2b25c0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,3 +3784,20 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups Kopyalanan Katman ve Gruplara “kopya” Ekle + Animation Options Animasyon Seçenekleri + Entire Document Tüm Belge + Expand New Effects Yeni Efektleri Genişlet + Filter Options Filtre Seçenekleri + Layer Bounds Katman Sınırları + Layers Panel Options Katmanlar Paneli Seçenekleri + New Artboard… Yeni Çalışma Yüzeyi… + New Group… Yeni Grup… + Prevent Auto-Nesting Otomatik Yerleştirmeyi Önle + Preview on canvas when reordering layers Katmanları yeniden sıralarken kanvasta önizle + Show Layers Katmanları Göster + Show layer mask badges Katman maskesi rozetlerini göster + Thumbnail Contents Küçük Resim İçeriği + Thumbnail Size Küçük Resim Boyutu diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7cd59aebb..66810fe329 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3536,3 +3536,21 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups Thêm “bản sao” vào lớp và nhóm được sao chép + Animation Options Tùy chọn hoạt ảnh + Entire Document Toàn bộ tài liệu + Expand New Effects Mở rộng hiệu ứng mới + Filter Options Tùy chọn bộ lọc + Layer Bounds Đường biên lớp + Layers Panel Options Tùy chọn bảng Lớp + New Artboard… Bảng vẽ mới… + New Group… Nhóm mới… + Panel Options… Tùy chọn bảng… + Prevent Auto-Nesting Chặn tự lồng + Preview on canvas when reordering layers Xem trước trên Canvas khi sắp xếp lại lớp + Show Layers Hiện các lớp + Show layer mask badges Hiện huy hiệu mặt nạ lớp + Thumbnail Contents Nội dung hình thu nhỏ + Thumbnail Size Kích thước hình thu nhỏ From 8bc312ac59c59155be3d4aca896fb43f561d7668 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:11:08 +0300 Subject: [PATCH 0052/1452] i18n: translate the Smart Filter editor strings in hu, tr and vi Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was written, so every_tl_literal_is_translated fails on the merged tree. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/hu.tsv | 8 ++++++++ crates/ui-egui/src/i18n/tr.tsv | 8 ++++++++ crates/ui-egui/src/i18n/vi.tsv | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..a0843549db 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,3 +3972,11 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + +# Layers panel options and Smart Filter editors + Blending Options ({filter}) Keverési beállítások ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek elrejtése ({key}+kattintás: minden réteg) + Opacity must be between 0% and 100%. Az átlátszatlanságnak 0% és 100% között kell lennie. + Show the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek megjelenítése ({key}+kattintás: minden réteg) + The document or Smart Filter changed. Reopen its dialog. A dokumentum vagy az intelligens szűrő megváltozott. Nyissa meg újra a párbeszédablakát. + Wait for the current job to finish. Várja meg, amíg az aktuális feladat befejeződik. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..48aac2cdf7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,3 +3784,11 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + +# Layers panel options and Smart Filter editors + Blending Options ({filter}) Karışım Seçenekleri ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini gizle ({key}-tık: tüm katmanlar) + Opacity must be between 0% and 100%. Opaklık %0 ile %100 arasında olmalıdır. + Show the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini göster ({key}-tık: tüm katmanlar) + The document or Smart Filter changed. Reopen its dialog. Belge veya Akıllı Filtre değişti. İletişim kutusunu yeniden açın. + Wait for the current job to finish. Geçerli işin bitmesini bekleyin. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7cd59aebb..9394c4238d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3536,3 +3536,11 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + +# Layers panel options and Smart Filter editors + Blending Options ({filter}) Tùy chọn hòa trộn ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ẩn hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) + Opacity must be between 0% and 100%. Độ mờ phải nằm trong khoảng từ 0% đến 100%. + Show the layer's effects and Smart Filters ({key}-click: all layers) Hiện hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) + The document or Smart Filter changed. Reopen its dialog. Tài liệu hoặc bộ lọc thông minh đã thay đổi. Hãy mở lại hộp thoại của nó. + Wait for the current job to finish. Hãy chờ tác vụ hiện tại hoàn tất. From 3fbdc1a947863a703c383aae36125e151719edcb Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:11:38 +0300 Subject: [PATCH 0053/1452] i18n: translate Use Default Masks on Fill Layers in hu, tr and vi Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/hu.tsv | 3 +++ crates/ui-egui/src/i18n/tr.tsv | 3 +++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ 3 files changed, 9 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b401617443..de773f6a1a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3989,3 +3989,6 @@ cameraRaw Yellow Sárga Show layer mask badges Rétegmaszk-jelvények megjelenítése Thumbnail Contents Bélyegkép tartalma Thumbnail Size Bélyegképméret + +# Layers panel options and Smart Filter editors + Use Default Masks on Fill Layers Alapértelmezett maszkok kitöltőrétegeken diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 48e2b25c0b..5921e3bdf1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3801,3 +3801,6 @@ cameraData Lens Lens Show layer mask badges Katman maskesi rozetlerini göster Thumbnail Contents Küçük Resim İçeriği Thumbnail Size Küçük Resim Boyutu + +# Layers panel options and Smart Filter editors + Use Default Masks on Fill Layers Dolgu Katmanlarında Varsayılan Maskeleri Kullan diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 66810fe329..4cf628ac6d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3554,3 +3554,6 @@ curves Smooth Làm mượt Show layer mask badges Hiện huy hiệu mặt nạ lớp Thumbnail Contents Nội dung hình thu nhỏ Thumbnail Size Kích thước hình thu nhỏ + +# Layers panel options and Smart Filter editors + Use Default Masks on Fill Layers Dùng mặt nạ mặc định cho lớp tô From 7c501ed90d234a86ba9eab250cbfc8e6ebea4e3d Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 00:16:07 +0200 Subject: [PATCH 0054/1452] Merge fixups: translate the History Brush source strings into Hungarian and Turkish Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..d80d66e174 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1082,6 +1082,8 @@ Height: Magasság: Hide Auto Slices Automatikus szeletek elrejtése History Brush Előzményecset + Paints from the History panel's source state Az Előzmények panel forrásállapotából fest + Sets the source for the history brush Beállítja az előzményecset forrását Horizontal scale Vízszintes méretarány Horizontal type Vízszintes szöveg Hue Jitter Színárnyalat ingadozása diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..0fd78f4430 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2136,6 +2136,8 @@ Burn Yanık Quick Selection Hızlı Seçim History Brush Tarih Fırçası + Paints from the History panel's source state Tarih panelinin kaynak durumundan boyar + Sets the source for the history brush Tarih fırçasının kaynağını ayarlar Color Replacement Renk Değiştirme New Group Yeni Grup Layer Comp Katman Kompozisyonu From fdef9180a7339c961bc4d010f5baa34bb976fe2a Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:36:49 +0200 Subject: [PATCH 0055/1452] History panel menu: Hungarian and Turkish labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hungarian and Turkish now claim complete menus, so the History panel menu's New Snapshot… and History Options… need entries there too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a84d19458b..3c1dbc083d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3823,6 +3823,8 @@ cameraRaw Yellow Sárga New Layer from Brush Új réteg ecsetből New Path Új útvonal New Snapshot Új pillanatkép + New Snapshot… Új pillanatkép… + History Options… Előzménybeállítások… New Video Group Új videócsoport Newspaper Újságpapír No Layer Comps Nincs rétegkompozíció diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3f552460ca..20e365d411 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2642,6 +2642,8 @@ cameraRaw Color Renk Create Snapshot Anlık Görüntü Oluştur Delete Snapshot Anlık Görüntüyü Sil New Snapshot Yeni Anlık Görüntü + New Snapshot… Yeni Anlık Görüntü… + History Options… Geçmiş Seçenekleri… Snapshot 1 Anlık Görüntü 1 Nonlinear History Doğrusal Olmayan Geçmiş Set New Snapshot as Default Yeni Anlık Görüntüyü Varsayılan Olarak Ayarla From d553a1b29fbfda42a66f58d18542766927f4d2dc Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:45:06 +0200 Subject: [PATCH 0056/1452] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e..a7accede67 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,13 +8,13 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | -| [Type](#type) | 4 | 2 | 3 | | +| [Tools](#tools) | 29 | 13 | 1 | | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | +| [Type](#type) | 5 | 2 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | -| [Open bugs](#open-bugs) | 29 | 2 | 1 | | +| [Open bugs](#open-bugs) | 30 | 2 | 0 | | ## Performance @@ -157,7 +157,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | -| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | +| TOOL-214-13 | Mask Properties: Density and Feather controls | done | [#214](https://github.com/storytold/photocraft/issues/214) | Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs; mask_props_ui_tests.rs pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, mask_slider_drags_preview_live_and_undo_as_separate_gestures, vector_mask_properties_edit_density_and_feather) | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | @@ -199,7 +199,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | | UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | -| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | +| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs; input_tests.rs alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked | | UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | @@ -216,7 +216,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | -| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 | +| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs warp_defaults; warp_text_dialog_tests.rs warp_text_opens_with_the_type_layers_current_warp, warp_text_opens_at_none_for_a_layer_without_warp, remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (warp_text_dialog_previews_the_warp_without_editing_the_document, cancelling_warp_text_leaves_the_document_and_history_untouched, confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | | TYPE-218-9 | Distort/Perspective/Warp on live type as in Photoshop (was: offer 'Rasterize and continue?'; Photoshop doesn't prompt) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701) | @@ -298,9 +298,9 @@ Never-crash attribute on 28 of 28 crate roots. | BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) | | BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) | | BUG-207-4 | edit.transform transforms a targeted alpha channel | done | [#207](https://github.com/storytold/photocraft/issues/207) | edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel | -| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) | +| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | done | [#207](https://github.com/storytold/photocraft/issues/207) | Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask, only a targeted alpha channel, and to follow the Channels panel target (engine retouch_cmds/tests.rs retouch_tools_paint_only_the_targeted_mask, retouch_tools_paint_only_the_targeted_alpha_channel, retouch_tools_follow_the_channels_panel_target, retouch_tools_paint_the_quick_mask, retouch_targets_fail_gracefully). Sample All Layers changes the result over two layers (sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, sample_all_layers_smudge_drags_colour_from_below, sample_all_layers_spot_healing_heals_onto_an_empty_layer, clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs sample_all_layers_reaches_the_command); no smudge fringe (smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: tests.rs painting_can_target_the_layer_mask, channel_cmds/tests.rs painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel | | BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners applies Shift via Drag::marquee_mods; marquee_tests.rs::shift_during_the_drag_draws_a_square_and_the_readout_follows covers the drag | | BUG-208-2 | Alt while dragging a marquee draws from the centre | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners centres Alt drags; marquee_tests.rs::alt_draws_from_the_centre_and_shift_alt_a_centred_circle verifies rectangles and ellipses | | BUG-208-3 | W x H px readout beside the cursor while dragging a marquee | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_readout draws the readout; marquee_tests.rs asserts dimensions while dragging and its removal after release | -| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195) | +| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (marquee_tests.rs modifiers_held_before_the_drag_pick_the_mode_not_the_shape). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift/Alt at the first click keep the selection and combine (polygon_lasso_tests.rs modifiers_at_the_first_click_keep_and_combine_the_selection). Missing: a test that Shift+Alt held before a marquee drag intersects | | BUG-208-5 | Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size | done | [#208](https://github.com/storytold/photocraft/issues/208) | crates/ui-egui/src/marquee_tests.rs covers Shift, Alt, selection bounds, W/H readout and drag completion | From 5d753525ccc9d7f3dfcfd54ccbd4c2560d3da6ce Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:57:30 +0700 Subject: [PATCH 0057/1452] fix(screen-picker): allow cancelling pending captures and avoid stale answers --- crates/ui-egui/src/lib.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0d64e7c04..9a2f45047f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1740,10 +1740,16 @@ impl eframe::App for PhotocraftApp { crate::widgets::set_field_resolution(&ctx, dpi, ppi); // View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns). screen_picker::show(&ctx); - if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) { + if screen_picker::busy(&ctx) { + // While the capture service is still returning a snapshot there is no picker + // viewport yet. Keep a visible Cancel action even during this phase (#4737). egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| { ui.spinner(); - ui.label(tl!("Pick a screen pixel or press Esc to cancel")); + ui.label(if screen_picker::showing(&ctx) { + tl!("Pick a screen pixel or press Esc to cancel") + } else { + tl!("Preparing screen capture…") + }); if ui.button(tl!("Cancel")).clicked() { screen_picker::cancel(&ctx); } @@ -1751,10 +1757,6 @@ impl eframe::App for PhotocraftApp { self.automation_input = false; return; } - if screen_picker::busy(&ctx) { - ui.disable(); - ui.set_opacity(1.0); - } // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); From 6c7b9d879dca99e159d4af761c9cfe7c8a02dc39 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:57:35 +0700 Subject: [PATCH 0058/1452] fix(screen-picker): allow cancelling pending captures and avoid stale answers --- crates/ui-egui/src/screen_picker.rs | 35 ++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/screen_picker.rs b/crates/ui-egui/src/screen_picker.rs index b9c163d3f8..c0fbec0316 100644 --- a/crates/ui-egui/src/screen_picker.rs +++ b/crates/ui-egui/src/screen_picker.rs @@ -247,7 +247,15 @@ pub fn busy(ctx: &Context) -> bool { } pub fn cancel(ctx: &Context) { let r = runtime(ctx); - lock(&r).answer = Some(Ok(None)); + let mut state = lock(&r); + if state.owner.is_some() { + state.answer = Some(Ok(None)); + } else { + // A request can be queued by the button before tick starts the capture service. + // Dropping that request cancels it without leaving an answer for the next pick. + state.request = None; + } + drop(state); ctx.request_repaint(); } @@ -453,6 +461,31 @@ mod tests { assert!(tick(app, ctx)); (owner, tx, cancelled) } + #[test] + fn cancel_while_queued_does_not_start_or_poison_the_next_pick() { + let ctx = Context::default(); + let mut app = app(); + let launched = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = launched.clone(); + app.services.screen_pick = Some(Box::new(move |_| { + started.store(true, std::sync::atomic::Ordering::Relaxed); + let (_tx, rx) = std::sync::mpsc::channel(); + Pending { receiver: rx, cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)) } + })); + let owner = Id::new("queued"); + lock(&runtime(&ctx)).request = Some(owner); + cancel(&ctx); + assert!(!tick(&mut app, &ctx)); + assert!(!launched.load(std::sync::atomic::Ordering::Relaxed)); + assert_eq!(take(&ctx, owner), None); + // An idle Cancel must not be retained as the reply to a future request. + cancel(&ctx); + let (owner, tx, _) = queued(&mut app, &ctx); + tx.send(Ok(Capture::Color(Some([0.2, 0.4, 0.6])))).unwrap(); + assert!(!tick(&mut app, &ctx)); + assert_eq!(take(&ctx, owner), Some([0.2, 0.4, 0.6])); + } + #[test] fn cancellation_wins_over_a_simultaneously_completed_pick() { let mut app = app(); From ec56c286ddf21fe0b4b62ad491f31eeeed869213 Mon Sep 17 00:00:00 2001 From: Sean Lavery Date: Mon, 12 Oct 2026 22:38:41 -0700 Subject: [PATCH 0059/1452] Fix mixed-order TIFF type transforms and reject unsafe geometry --- crates/io/src/psd_import.rs | 42 ++++++++-------- crates/io/tests/psd_structure.rs | 4 +- crates/io/tests/psd_type_bounds.rs | 16 ++++++- crates/io/tests/tiff_type_geometry.rs | 69 +++++++++++++++++++++++++++ crates/psd/src/tiff.rs | 68 ++++++++++++++++++++++---- crates/text/src/psd.rs | 24 ++++++++++ docs/file-format-parity.md | 4 +- 7 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 crates/io/tests/tiff_type_geometry.rs diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1e77cdaa6a..aa599aa07a 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{ - AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartContentsId, SmartObject, SmartSource, TextLayer, + AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartContentsId, SmartObject, SmartSource, }; use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; @@ -341,28 +341,26 @@ impl Ctx<'_> { )); } LayerContent::Adjustment(adj) - } else if rec.block(b"TySh").is_some() { - // Typed model from TySh/EngineData (photocraft-text); Photoshop's pixels stay the cache. - let data = rec.block(b"TySh").map(|b| b.data.clone()).unwrap_or_default(); - let mut t = photocraft_text::psd::text_layer_from_tysh(&data, self.dpi).unwrap_or_else(|| { - let (text, transform) = blocks::parse_tysh(&data).unwrap_or_default(); - TextLayer { text, transform, ..Default::default() } - }); - if let Some(txt2) = &self.txt2 { - photocraft_text::psd::apply_txt2(&mut t, &data, txt2); - photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2); + } else if let Some(block) = rec.block(b"TySh") { + // Only canonical, safely decoded type data can become editable text. A retained + // foreign-order block must never be handed to the big-endian reader (#4758). + let parsed = (!block.is_foreign_order()).then(|| photocraft_text::psd::text_layer_from_tysh(&block.data, self.dpi)).flatten(); + if let Some(mut t) = parsed { + if let Some(txt2) = &self.txt2 { + photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2); + photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2); + } + // Photoshop's pixels stay until edited. Writers without type pixels are + // rendered by text_import::prepare instead of retaining an empty cache. + let cache = self.record_surface(rec, &name); + let drawn = !cache.content_bounds().is_empty() || photocraft_text::psd::engine_text_is_blank(&block.data); + t.cache = drawn.then_some(cache); + t.psd_raw = principal(b"TySh"); + LayerContent::Text(t) + } else { + self.warn(format!("layer \"{name}\": unreadable or unsafe type geometry; kept as pixels with its original type data")); + LayerContent::Raster(self.record_surface(rec, &name)) } - // Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and - // other writers leave type layers without image data (Photoshop re-renders them on - // open), and an empty cache would show nothing until the layer is edited; the import - // renders such a layer from its model instead (`text_import::prepare`). When the - // engine text itself is blank, Photoshop draws nothing either: keep the empty pixels - // (corpus: text/path-wave-open.psd). - let cache = self.record_surface(rec, &name); - let drawn = !cache.content_bounds().is_empty() || photocraft_text::psd::engine_text_is_blank(&data); - t.cache = drawn.then_some(cache); - t.psd_raw = principal(b"TySh"); - LayerContent::Text(t) } else if let Some(k) = smart_key { let Placement { id, transform, perspective, warp } = match rec.block(k) { Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name), diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index e893adaf94..44d6d33c4a 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -422,9 +422,11 @@ fn text_and_smart_detected() { use photocraft_psd::{LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); for (name, key) in [("t", b"TySh"), ("s", b"PlLd"), ("sh", b"vscg")] { + // Type identification needs readable type data, not an eight-byte malformed stub. + let data = if key == b"TySh" { photocraft_text::psd::build_tysh(&photocraft_doc::TextLayer::default(), 72.0, None) } else { vec![0; 8] }; f.layers_mut().push(LayerRecord { name: name.as_bytes().to_vec(), - blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, vec![0; 8])], + blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, data)], ..Default::default() }); } diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 8fabe8be24..a80bc9b7ab 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -367,7 +367,21 @@ fn hostile_type_sizes_without_pixels_import_without_a_huge_render() { { let runs = vec![Run { len: 1, size, leading: None }]; let data = tysh("M", &runs, None, transform, [10.0, 10.0, 20.0, 20.0]); - let mut t = photocraft_text::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + let Some(mut t) = photocraft_text::psd::text_layer_from_tysh(&data, 72.0) else { + // Unsafe affine data is now rejected at the boundary (#4758). Exercise the + // actual file import too: with no saved pixels it must remain empty and safe. + let mut file = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Raw); + file.layers_mut().push(photocraft_psd::LayerRecord { + name: b"hostile".to_vec(), + blocks: vec![photocraft_psd::TaggedBlock::new(*b"TySh", data)], + ..Default::default() + }); + let back = photocraft_io::import("t.psd", &file.to_bytes().unwrap()).unwrap(); + assert!(back.warnings.iter().any(|w| w.contains("unsafe type geometry"))); + let LayerContent::Raster(pixels) = &back.document.layers.last().unwrap().content else { panic!("unsafe type must stay pixels") }; + assert!(pixels.content_bounds().is_empty()); + continue; + }; if big { // Precondition: the renderer's own guard (256 Mpx) would have drawn this one. let mut engine = photocraft_text::TextEngine::new(); diff --git a/crates/io/tests/tiff_type_geometry.rs b/crates/io/tests/tiff_type_geometry.rs new file mode 100644 index 0000000000..a64aeb7c2b --- /dev/null +++ b/crates/io/tests/tiff_type_geometry.rs @@ -0,0 +1,69 @@ +//! Photoshop's mixed-order Intel TIFF type blocks and unsafe/unresolved type fallback (#4758). + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::text::{CharStyle, TextRun}; +use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer}; +use photocraft_geom::Affine; +use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document}; + +fn document() -> Document { + let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 300.0; + let mut t = TextLayer { + text: "Synthetic".into(), + transform: Affine { m: [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 40.0, 120.0] }, + ..Default::default() + }; + t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { size_pt: 24.0, ..Default::default() } }]; + t.sync_summary(); + t.cache = Some(photocraft_text::TextEngine::default().render(&t, doc.resolution_dpi, doc.pixel_format()).1.surface); + doc.layers.push(Layer::new("Synthetic", LayerContent::Text(t))); + doc +} + +#[test] +fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() { + let doc = document(); + let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let canonical = &psd.layers()[0].block(b"TySh").unwrap().data; + let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + let mut image = photocraft_codecs::decode(&encoded.bytes).unwrap(); + let blocks = image.meta.photoshop_layers.as_mut().unwrap(); + let at = blocks.windows(8).position(|b| b == b"MIB8hSyT").unwrap() + 12; + // Only the text version/descriptor switch order; warp and bounds remain little-endian. + let (_, used) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&canonical[52..]).unwrap(); + blocks[at + 50..at + 52 + used].copy_from_slice(&canonical[50..52 + used]); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Tiff, &Default::default()).unwrap(); + let opened = import("photoshop.tif", &bytes).unwrap(); + assert!(!opened.warnings.iter().any(|w| w.contains("TySh")), "{:?}", opened.warnings); + let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") }; + let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") }; + assert_eq!(after.transform, before.transform); + assert_eq!(after.size_pt, 24.0); + assert_eq!(after.text, before.text); + assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds()); + let rerendered = photocraft_text::TextEngine::default().render(after, opened.document.resolution_dpi, opened.document.pixel_format()).1.surface; + assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds()); +} + +#[test] +fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() { + let doc = document(); + for foreign in [true, false] { + let (mut psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let block = psd.layer_info.as_mut().unwrap().layers[0].blocks.iter_mut().find(|b| b.key == *b"TySh").unwrap(); + if foreign { + // A foreign-order marker must be respected even if the descriptor could parse. + block.signature = *b"MIB8"; + } else { + block.data[2..10].copy_from_slice(&1.315776645269349e62f64.to_be_bytes()); + } + let raw = block.data.clone(); + let (opened, warnings) = psd_to_document(&psd); + let LayerContent::Raster(pixels) = &opened.layers[0].content else { panic!("unsafe type must stay pixels") }; + assert!(!pixels.content_bounds().is_empty()); + assert_eq!(pixels.content_bounds(), doc.layers[0].surface().unwrap().content_bounds()); + assert!(warnings.iter().any(|w| w.contains("unsafe type geometry")), "{warnings:?}"); + assert!(opened.layers[0].psd_blocks.iter().any(|(k, data)| k == b"TySh" && **data == raw)); + } +} diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 7c8b1940d5..8bdebd13a0 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -845,15 +845,7 @@ pub mod transcode { // Type: version, transform, text version + descriptor, warp version + descriptor, // and the bounds as four 32-bit values (the specification says doubles; Photoshop // writes 16 bytes). - b"TySh" => { - self.u16()?; - self.f64s(6)?; - self.u16()?; - self.versioned_descriptor()?; - self.u16()?; - self.versioned_descriptor()?; - self.i32s(4)?; - } + b"TySh" => self.type_layer()?, b"vmsk" | b"vsms" => { self.u32()?; self.u32()?; @@ -1151,6 +1143,31 @@ pub mod transcode { // ---- descriptors ---- + /// Photoshop's Intel-order TIFF type blocks can use PSD byte order for the + /// text descriptor, then container order again for warp and bounds (#4758). + fn type_layer(&mut self) -> Result<()> { + self.u16()?; + self.f64s(6)?; + self.type_descriptor(50)?; + self.type_descriptor(1)?; + self.i32s(4) + } + + fn type_descriptor(&mut self, version: u16) -> Result<()> { + let [hi, lo] = version.to_be_bytes(); + let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from }; + let data = self.r.peek_rest(); + let mut descriptor = Tx::new(data, self.version, from, self.to); + descriptor.depth = self.depth; + descriptor.u16()?; + descriptor.versioned_descriptor()?; + let used = data.len() - descriptor.remaining(); + self.r.bytes(used)?; + self.warnings.append(&mut descriptor.warnings); + self.out.extend_from_slice(&descriptor.out); + Ok(()) + } + fn versioned_descriptor(&mut self) -> Result<()> { self.u32()?; self.descriptor() @@ -1794,6 +1811,39 @@ mod tests { assert_eq!(&little[50..58], &1.0f64.to_le_bytes()); } + #[test] + fn photoshop_type_block_switches_to_big_endian_after_its_matrix() { + let mut data = Vec::new(); + data.put_u16(1); + for v in [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 247.25, 2864.5] { + data.put_f64(v); + } + data.put_u16(50); + let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic headline"))); + data.extend(VersionedDescriptor::new(text).to_bytes()); + data.put_u16(1); + data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); + for v in [0, 0, 120, 30] { + data.put_i32(v); + } + let (_, mut little) = block_rt(*b"TySh", data.clone()); + // Photoshop uses big-endian text but returns to little-endian for warp/bounds. + let (_, used) = VersionedDescriptor::parse_prefix(&data[52..]).unwrap(); + little[98..100 + used].copy_from_slice(&data[50..52 + used]); + let (back, warnings) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!back.global_blocks[0].is_foreign_order()); + assert_eq!(back.global_blocks[0].data, data); + // The normalized model remains writable in either order. + for order in [ByteOrder::Big, ByteOrder::Little] { + let (bytes, warnings) = back.to_bytes(order).unwrap(); + assert!(warnings.is_empty()); + let (again, warnings) = ImageSourceData::from_bytes(&bytes).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(again.global_blocks[0].data, data); + } + } + #[test] fn legacy_effects_and_adjustments() { // lrFX with a drop shadow and a solid fill. diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3f8ce72902..b659a151bd 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -37,6 +37,11 @@ pub fn parse_tysh(data: &[u8]) -> Option { for (i, v) in m.iter_mut().enumerate() { *v = f(2 + i * 8)?; } + // Pixel coordinates use i32 throughout the document. Reject non-finite and + // unrepresentable affine terms before they reach rendering or egui (#4758). + if !m.iter().all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX)) { + return None; + } let (text, used) = VersionedDescriptor::parse_prefix(data.get(52..)?).ok()?; let rest = data.get(52 + used..).unwrap_or(&[]); let (warp, wused) = match rest.get(2..).and_then(|r| VersionedDescriptor::parse_prefix(r).ok()) { @@ -1334,3 +1339,22 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } + +#[cfg(test)] +mod transform_safety_tests { + use super::*; + + #[test] + fn unsafe_affine_terms_are_not_editable_type() { + let mut t = TySh { transform: Affine::IDENTITY, text: Descriptor::new("TxLr"), warp: None, bounds: [0.0; 4] }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.315776645269349e62, 1.4759871577491642e307] { + for i in 0..6 { + t.transform = Affine::IDENTITY; + t.transform.m[i] = bad; + assert!(parse_tysh(&write_tysh(&t)).is_none(), "term {i}: {bad}"); + } + } + t.transform = Affine { m: [0.0, -2.01242653341062, 2.286521576443544, 0.0, 586.63, 2452.60] }; + assert_eq!(parse_tysh(&write_tysh(&t)).unwrap().transform, t.transform); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index daa8827c36..427497afc2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (`.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop mixed-order TIFF type geometry, #4758; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | From c99df31a13a45536590e15c14208ea10f1f671d1 Mon Sep 17 00:00:00 2001 From: Przemek Date: Mon, 12 Oct 2026 15:20:03 +0200 Subject: [PATCH 0060/1452] SVG import: set text in an installed font when the named ones are missing usvg's fontdb maps the generic families to Windows fonts (sans-serif to Arial, serif to Times New Roman, monospace to Courier New) and knows nothing of Fontconfig's aliases. On a Linux without those fonts (Fedora 44 ships neither Arial nor DejaVu Sans), every family of a usual font-family="Arial, Helvetica, sans-serif" misses and usvg drops the text, although hundreds of fonts are installed; the import then only says "nothing to draw", because the no-font warning (#3168) fires only for an empty font database. Point each generic family at an installed font, as a browser falls back: the system's choice (fc-match on Linux) when the database has it, then metric-compatible and common stand-ins (Liberation, Croscore, DejaVu, Noto), the bundled craft fonts, the sans-serif choice, and any font at all. A generic whose font is installed is left alone. The tests use the bundled Inter only, so they don't depend on the fonts of the machine; tests/svg.rs's text test now passes on Fedora. --- crates/io/src/svg.rs | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..4170e2ca59 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -61,11 +61,75 @@ fn fonts() -> Arc { } #[cfg(not(target_arch = "wasm32"))] db.load_system_fonts(); + resolve_generic_families(&mut db, system_generic_family); Arc::new(db) }) .clone() } +/// Installed families that stand in for each generic one, best first: the metric-compatible +/// Liberation and Croscore fonts, then common Linux defaults, then the bundled craft fonts. +const SANS_SERIF: &[&str] = &["Liberation Sans", "Arimo", "Helvetica", "DejaVu Sans", "Noto Sans", photocraft_text::fonts::DEFAULT_FAMILY]; +const SERIF: &[&str] = &["Liberation Serif", "Tinos", "Times", "DejaVu Serif", "Noto Serif"]; +const MONOSPACE: &[&str] = &["Liberation Mono", "Cousine", "Courier", "DejaVu Sans Mono", "Noto Sans Mono", photocraft_text::fonts::MONO_FAMILY]; + +/// Points the generic families (`sans-serif`, `serif`, `monospace`, `cursive`, `fantasy`) at +/// fonts the database holds. fontdb names Windows fonts for them (Arial, Times New Roman, +/// Courier New…): on a system without those, every family of a usual +/// `font-family="Arial, Helvetica, sans-serif"` misses, and usvg drops the text although +/// hundreds of fonts are installed, where a browser sets it in a fallback font. A generic whose +/// font is installed is left alone. Otherwise the system's choice (`system`, Fontconfig on Linux) +/// wins when the database has it, then the first installed stand-in, then the sans-serif choice, +/// then any font at all. +fn resolve_generic_families(db: &mut usvg::fontdb::Database, system: impl Fn(&str) -> Option) { + use usvg::fontdb::Family; + let has = |db: &usvg::fontdb::Database, name: &str| db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))); + let Some(any) = db.faces().find_map(|f| f.families.first()).map(|(n, _)| n.clone()) else { return }; + let mut sans = None; + for (generic, css, stand_ins) in [ + (Family::SansSerif, "sans-serif", SANS_SERIF), + (Family::Serif, "serif", SERIF), + (Family::Monospace, "monospace", MONOSPACE), + (Family::Cursive, "cursive", &[][..]), + (Family::Fantasy, "fantasy", &[][..]), + ] { + let current = db.family_name(&generic).to_string(); + let pick = if has(db, ¤t) { + current + } else { + system(css) + .filter(|n| has(db, n)) + .or_else(|| stand_ins.iter().find(|n| has(db, n)).map(|n| n.to_string())) + .or_else(|| sans.clone()) + .unwrap_or_else(|| any.clone()) + }; + match generic { + Family::SansSerif => db.set_sans_serif_family(pick.clone()), + Family::Serif => db.set_serif_family(pick.clone()), + Family::Monospace => db.set_monospace_family(pick.clone()), + Family::Cursive => db.set_cursive_family(pick.clone()), + _ => db.set_fantasy_family(pick.clone()), + } + if generic == Family::SansSerif { + sans = Some(pick); + } + } +} + +/// The family Fontconfig sets a generic family in (`fc-match sans-serif`), as the rest of the +/// desktop does; `None` without Fontconfig. +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn system_generic_family(generic: &str) -> Option { + let out = std::process::Command::new("fc-match").args(["--format=%{family[0]}", generic]).output().ok()?; + let name = String::from_utf8(out.stdout).ok()?; + Some(name.trim().to_string()).filter(|n| out.status.success() && !n.is_empty()) +} + +#[cfg(not(all(target_os = "linux", not(target_arch = "wasm32"))))] +fn system_generic_family(_generic: &str) -> Option { + None +} + /// Parse options for an untrusted drawing. `` resolves only `data:` URLs: a file path /// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`, /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all @@ -768,6 +832,53 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + /// A database with the bundled Inter alone, generics resolved without Fontconfig: none of + /// fontdb's Windows defaults (Arial, Times New Roman, Courier New) is in it. + fn inter_only() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + resolve_generic_families(&mut db, |_| None); + Arc::new(db) + } + + /// Text asking for fonts that aren't installed (`Arial, Helvetica, sans-serif` on a Linux + /// without them) is set in an installed font, as a browser does, instead of being dropped. + #[test] + fn text_in_missing_fonts_falls_back_to_an_installed_one() { + for family in ["Arial, Helvetica, sans-serif", "Times New Roman", "Courier New, monospace", "cursive", "fantasy"] { + let svg = format!( + "II" + ); + let parsed = parse_with(svg.as_bytes(), inter_only).unwrap(); + assert!(!parsed.fontless_text); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings); + } + } + + #[test] + fn generic_families_point_at_installed_fonts() { + use usvg::fontdb::Family; + let db = inter_only(); + for g in [Family::SansSerif, Family::Serif, Family::Monospace, Family::Cursive, Family::Fantasy] { + assert_eq!(db.family_name(&g), "Inter", "{g:?}"); + } + // The system's choice wins when installed; a generic whose font is installed stays. + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec()); + db.set_cursive_family("Inter"); + resolve_generic_families(&mut db, |g| Some(if g == "serif" { "JetBrains Mono" } else { "Not Installed" }.to_string())); + assert_eq!(db.family_name(&Family::Serif), "JetBrains Mono"); + assert_eq!(db.family_name(&Family::Monospace), "JetBrains Mono", "the bundled mono stand-in"); + assert_eq!(db.family_name(&Family::SansSerif), "Inter"); + assert_eq!(db.family_name(&Family::Cursive), "Inter"); + // An empty database is left alone. + let mut empty = usvg::fontdb::Database::new(); + resolve_generic_families(&mut empty, |_| Some("Inter".into())); + assert_eq!(empty.family_name(&Family::SansSerif), "Arial"); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; From 2a10524d53806b268d8217103edd23144bb7b7bd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 09:00:20 +0200 Subject: [PATCH 0061/1452] PSD: save Indexed Color, Bitmap and Duotone documents in their own mode Indexed Color (mode 2): the flattened document as palette indices, the Color Table as Color Mode Data (256 reds, greens, blues), its length in resource 1046 and the transparent entry in 1047; the importer now reads both back. Bitmap (mode 0): 1 bit per pixel, 1 = black. Neither mode has layers in Photoshop, so both are written flat, like Multichannel; the layerless file building is shared with multichannel_map. A Duotone keeps the ink data it was imported with (undocumented, kept verbatim in Duotone::psd_raw) over its grey plate. Duotones whose inks were set in PhotoCraft have no such data and stay Grayscale with the warning, as before. Fixes #4814 --- crates/io/src/lib.rs | 1 + crates/io/src/multichannel_map.rs | 34 +++++++--- crates/io/src/palette_map.rs | 107 ++++++++++++++++++++++++++++++ crates/io/src/psd_export.rs | 20 +++++- crates/io/src/psd_import.rs | 8 ++- crates/io/tests/psd_structure.rs | 41 ++++++++++++ 6 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 crates/io/src/palette_map.rs diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b80da66c3c..309107a4c5 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -49,6 +49,7 @@ mod kra; pub mod linked; mod multichannel_map; mod ora; +mod palette_map; pub use ora::losses as ora_losses; pub mod pattern_map; mod pdn; diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index db31e743ef..365d551504 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -119,7 +119,27 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false))); } - resources.extend(crate::psd_export::path_resources(doc, &mut warnings)); + // Alpha identifiers: one u32 per channel, only while they still match the channels. + let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4); + let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + (file, warnings) +} + +/// A PSD without layers around `image_data`: `resources` (the mode's own) plus the paths, XMP, +/// EXIF and every other imported resource `keep` accepts (except the ones that only describe +/// layers and any already in `resources`), and the document-level tagged blocks. Shared by the +/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap. +pub(crate) fn layerless_file( + doc: &Document, + header: Header, + image_data: ImageData, + color_mode_data: Vec, + mut resources: Vec, + keep: impl Fn(u16, &[u8]) -> bool, + warnings: &mut Vec, +) -> PsdFile { + resources.extend(crate::psd_export::path_resources(doc, warnings)); // The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it. let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0); if let Some(x) = &doc.metadata.xmp { @@ -132,11 +152,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< // ones that only describe layers and any already written above. let written: Vec = resources.iter().map(|r| r.id).collect(); for (id, name, data) in &doc.metadata.psd_resources { - if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) { - continue; - } - // Alpha identifiers: one u32 per channel, only while they still match the channels. - if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) { + if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) || !keep(*id, data) { continue; } let mut r = ImageResource::new(*id, data.to_vec()); @@ -154,8 +170,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< .filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32")) .map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data)) .collect(); - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); - let file = PsdFile { + PsdFile { header, color_mode_data, resources, @@ -165,6 +180,5 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< global_blocks, layer_mask_trailing: Vec::new(), image_data, - }; - (file, warnings) + } } diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs new file mode 100644 index 0000000000..a279af0f4a --- /dev/null +++ b/crates/io/src/palette_map.rs @@ -0,0 +1,107 @@ +//! Indexed Color and Bitmap documents -> PSD colour modes 2 and 0. +//! +//! Neither mode has layers in Photoshop, so the file holds only the merged image (the document +//! flattened): for Indexed Color one plane of palette indices, with the Color Table as Color Mode +//! Data (768 bytes: 256 reds, 256 greens, 256 blues), its length in resource 1046 and the +//! transparent entry in resource 1047; for Bitmap one plane of 1 bit per pixel, 1 = black. + +use photocraft_color::ColorMode; +use photocraft_doc::{ColorTable, Document}; +use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids}; +use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; + +/// Indexed Color Table Count: the number of colours in the table (u16). +pub(crate) const COLOR_TABLE_COUNT: u16 = 1046; +/// Transparency Index: the colour table entry shown as transparent (u16). +pub(crate) const TRANSPARENCY_INDEX: u16 = 1047; +/// Channel resources (names, display info, alpha identifiers): these modes have no extra channels. +const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPLAY_INFO]; + +/// The PSD for an Indexed Color document with a colour table of at most 256 entries, or a Bitmap +/// document; None for any other document (it is written like the others). +pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec)> { + let table = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256); + let (mode, plane, color_mode_data) = match doc.mode { + ColorMode::Indexed => { + let table = table?; + (PsdMode::Indexed, indices(doc, table), palette(table)) + } + ColorMode::Bitmap => (PsdMode::Bitmap, bits(doc), Vec::new()), + _ => return None, + }; + let mut warnings = Vec::new(); + if doc.layers.len() > 1 { + warnings.push(format!("{:?} documents have no layers in Photoshop: written flattened", doc.mode)); + } + let big = doc.size.width > 30_000 || doc.size.height > 30_000; + let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc); + let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit); + let depth = if mode == PsdMode::Bitmap { 1 } else { 8 }; + let header = Header::new(version, doc.size.width, doc.size.height, 1, depth, mode); + let image_data = ImageData::encode(Compression::Rle, &plane, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &plane, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: plane }); + let mut resources = vec![ + ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), + ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(1032, crate::psd_export::guides_resource(doc)), + ]; + if let (PsdMode::Indexed, Some(t)) = (mode, table) { + resources.push(ImageResource::new(COLOR_TABLE_COUNT, (t.colors.len() as u16).to_be_bytes().to_vec())); + if let Some(i) = t.transparent { + resources.push(ImageResource::new(TRANSPARENCY_INDEX, u16::from(i).to_be_bytes().to_vec())); + } + } + // An imported transparency index that no longer applies is not copied either. + let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT; + let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + Some((file, warnings)) +} + +/// The Color Table as Color Mode Data: 256 reds, 256 greens, 256 blues (unused entries black). +fn palette(t: &ColorTable) -> Vec { + let mut m = vec![0u8; 768]; + for (i, c) in t.colors.iter().take(256).enumerate() { + m[i] = c[0]; + m[256 + i] = c[1]; + m[512 + i] = c[2]; + } + m +} + +/// The flattened document as palette indices (the nearest entry; the transparent one where the +/// document is less than half covered). +fn indices(doc: &Document, t: &ColorTable) -> Vec { + let mut idx = Vec::with_capacity(doc.size.area() as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + idx.extend(band.px.iter().map(|p| match t.transparent { + Some(i) if p[3] < 0.5 => i, + _ => t.nearest([p[0], p[1], p[2]]) as u8, + })); + Ok(()) + }); + idx +} + +/// The flattened document over white as 1-bit rows (padded to a byte), 1 = black: pixels darker +/// than middle grey. +fn bits(doc: &Document) -> Vec { + let w = doc.size.width as usize; + let row = w.div_ceil(8); + let mut out = Vec::with_capacity(row * doc.size.height as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + for line in band.px.chunks(w.max(1)) { + let mut bytes = vec![0u8; row]; + for (x, p) in line.iter().enumerate() { + let grey = (p[0] + p[1] + p[2]) / 3.0 * p[3] + (1.0 - p[3]); + if grey < 0.5 { + bytes[x / 8] |= 0x80 >> (x % 8); + } + } + out.extend(bytes); + } + Ok(()) + }); + out +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4eada2422d..9b5878af7d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1428,7 +1428,17 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } + if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) { + return file; + } let fmt = doc.pixel_format(); + // A Duotone keeps the inks it was imported with (their Color Mode Data is undocumented and + // kept verbatim) over its grey plate; Photoshop has no 32-bit Duotone. + let duotone_inks = doc + .duotone + .as_ref() + .and_then(|d| d.psd_raw.as_ref()) + .filter(|_| doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32); let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; @@ -1458,7 +1468,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } else if size_exceeds_limit && !opts.force_psb { ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into()); } - if fmt.mode != doc.mode { + if fmt.mode != doc.mode && duotone_inks.is_none() { ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode)); } // Assign layer ids up front (stable across round trips): keep unique @@ -1568,7 +1578,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; - let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); + let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; + let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), mode); let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; let image_data = ImageData::encode(mcomp, &planes, &header) .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) @@ -1706,7 +1717,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } // 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not // open a 32-bit file without them (#291). - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let color_mode_data = match duotone_inks { + Some(raw) => raw.to_vec(), + None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), + }; let file = PsdFile { header, color_mode_data, diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1e77cdaa6a..1517300131 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -828,8 +828,12 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 { // Planar palette: 256 reds, 256 greens, 256 blues (the Color Table). let m = &file.color_mode_data; - let colors = (0..256).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); - doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None }); + // Resource 1046 says how many entries are used, 1047 which one is transparent. + let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]])); + let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256); + let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); + let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8); + doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { // The duotone ink block is undocumented: keep it raw; the image shows as its gray plate. doc.duotone = diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index e893adaf94..5e4f85a574 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -177,6 +177,47 @@ fn fallback_modes_import_flattened() { } } +#[test] +fn palette_and_duotone_modes_are_written_in_their_own_mode() { + // Indexed Color and Bitmap are written flat in their own mode (palette / 1 bit); a Duotone + // keeps the ink data it was imported with over its grey plate. Same image, same mode data. + for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone] { + let depth = testgen::mode_depths(mode)[0]; + let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5); + let (d, _) = psd_to_document(&f); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{mode:?}: {:?}", out.warnings); + let back = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((back.header.color_mode, back.header.depth, back.header.channels), (mode, f.header.depth, 1), "{mode:?}"); + assert_eq!(back.composite_rgba8().unwrap(), f.composite_rgba8().unwrap(), "{mode:?}"); + if mode != ColorMode::Bitmap { + assert_eq!(back.color_mode_data, f.color_mode_data, "{mode:?}"); + } + } +} + +#[test] +fn indexed_psd_keeps_the_table_length_and_transparent_entry() { + use photocraft_doc::ColorTable; + let mut d = photocraft_doc::Document::with_background( + "i", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), + ); + d.mode = photocraft_color::ColorMode::Indexed; + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 0, 255]], transparent: Some(2) }); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!(f.header.color_mode, ColorMode::Indexed); + assert_eq!(f.resource(1046).map(|r| r.data.clone()), Some(vec![0, 3])); + assert_eq!(f.resource(1047).map(|r| r.data.clone()), Some(vec![0, 2])); + assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "every pixel is entry 1 (red)"); + let (back, _) = psd_to_document(&f); + assert_eq!(back.color_table, d.color_table); +} + #[test] fn multichannel_imports_ink_channels() { for depth in testgen::mode_depths(ColorMode::Multichannel) { From 5f59f0671aca1e0ba3ffa4345f4930c3a51b5c91 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 11:51:34 +0200 Subject: [PATCH 0062/1452] Layered TIFF: Indexed, Bitmap and Duotone layers are modelled in their pixel format The layered TIFF export builds its layer data through the PSD exporter, which now writes these modes in their own PSD mode (palette, 1 bit, duotone). TIFF has no such layout and interleaves the merged image in the pixel format, so the PSD model for it is made in that format (RGB or Grayscale), as before. Refs #4814 --- crates/io/src/tiff_layers.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 0aca0fe882..043f83068b 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -203,6 +203,16 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // keeps straight alpha) and uncompressed (its planes are interleaved below). let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; + // TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap + // or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter + // otherwise writes in the document's own mode. + let plain; + let doc = if matches!(doc.mode, ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone) { + plain = Document { mode: doc.pixel_format().mode, ..doc.clone() }; + &plain + } else { + doc + }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; From 772cf39c09930229e11b920196f486239bbbfab8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 12:19:37 +0200 Subject: [PATCH 0063/1452] PSD Duotone: read and write the ink block (names, colours, curves) The Duotone Color Mode Data is undocumented. From files Photoshop 2025 made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20, ..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain and eleven overprint colours. - Import interprets the inks, so an opened duotone shows them instead of its grey plate; the block is still kept raw and written back while the inks are unedited. A colour-book ink (not readable here) shows as black with a warning. - Export writes a block for duotones made in PhotoCraft (RGB ink colours, curves on the 13-point grid or sampled at it, default dot gain, computed overprints), so every 8/16-bit Duotone is saved as Duotone. Photoshop opens such files as Duotone, and a black monotone whose curve is 0 up to 60 % density stays white exactly up to 60 % there. Refs #4814 --- crates/io/src/duotone_map.rs | 243 +++++++++++++++++++++++++++++++ crates/io/src/lib.rs | 1 + crates/io/src/psd_export.rs | 13 +- crates/io/src/psd_import.rs | 15 +- crates/io/tests/psd_structure.rs | 26 ++++ 5 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 crates/io/src/duotone_map.rs diff --git a/crates/io/src/duotone_map.rs b/crates/io/src/duotone_map.rs new file mode 100644 index 0000000000..565af591a4 --- /dev/null +++ b/crates/io/src/duotone_map.rs @@ -0,0 +1,243 @@ +//! Duotone inks ⇄ a PSD's Color Mode Data in Duotone mode (colour mode 8). +//! +//! The PSD specification leaves this block undocumented. Photoshop writes 524 bytes (observed on +//! files it made with 1–4 inks): +//! +//! | offset | size | content | +//! |---|---|---| +//! | 0 | 2 | version, 1 | +//! | 2 | 2 | number of inks, 1–4 | +//! | 4 | 4 × 10 | ink colours: colour space + 4 × u16, as in swatch files; space 0xFFFF = no ink | +//! | 44 | 4 × 64 | ink names, Pascal strings | +//! | 300 | 4 × 28 | ink curves: 13 × i16 then an i16 (0), the PSD's transfer-function layout | +//! | 412 | 2 | dot gain, 20 | +//! | 414 | 11 × 10 | overprint colours, space 0xFFFF = computed | +//! +//! A curve's 13 values are the ink's density in tenths of a percent at gray densities of 0, 5, +//! 10, 20, …, 90, 95 and 100 %; −1 leaves a point out (Photoshop's empty dialog fields). + +use photocraft_color::convert::{cmyk_to_rgb_naive, lab_to_srgb}; +use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::{Duotone, DuotoneInk}; +use photocraft_psd::aco::AcoColor; + +/// Length of the block Photoshop writes. +pub(crate) const BLOCK_LEN: usize = 524; +/// Gray densities (%) of a curve's 13 points. +const CURVE_AT: [u16; 13] = [0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]; +const NO_COLOR: [u8; 10] = [0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; +const NAMES_AT: usize = 44; +const CURVES_AT: usize = 300; +const DOT_GAIN_AT: usize = 412; + +fn u16_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) +} + +/// The sRGB display colour of an ink colour; `None` for a colour book or another space we can't +/// show. +fn display(c: AcoColor) -> Option<[f32; 3]> { + let f = |v: u16| f32::from(v) / 65535.0; + Some(match c { + AcoColor::Rgb(v) => v.map(f), + AcoColor::Cmyk(v) => cmyk_to_rgb_naive(v.map(f)), + AcoColor::Lab { l, a, b } => lab_to_srgb([f32::from(l.min(10000)) / 100.0, f32::from(a) / 100.0, f32::from(b) / 100.0]).map(|v| v.clamp(0.0, 1.0)), + AcoColor::Gray(g) => [1.0 - f32::from(g.min(10000)) / 10000.0; 3], + _ => return None, + }) +} + +/// The inks of a Duotone block, or `None` when it is not one this reads (the caller keeps it raw). +/// `unknown` is set when an ink's colour can't be shown (a colour book): it is shown as black. +pub(crate) fn parse(b: &[u8], unknown: &mut bool) -> Option> { + if b.len() < DOT_GAIN_AT || u16_at(b, 0)? != 1 { + return None; + } + let n = usize::from(u16_at(b, 2)?); + if !(1..=4).contains(&n) { + return None; + } + (0..n) + .map(|i| { + let at = 4 + 10 * i; + let space = u16_at(b, at)?; + let values = [u16_at(b, at + 2)?, u16_at(b, at + 4)?, u16_at(b, at + 6)?, u16_at(b, at + 8)?]; + let color = display(AcoColor::from_record(space, values)).unwrap_or_else(|| { + *unknown = true; + [0.0; 3] + }); + let name_at = NAMES_AT + 64 * i; + let len = usize::from(*b.get(name_at)?).min(63); + let name = String::from_utf8_lossy(b.get(name_at + 1..name_at + 1 + len)?).into_owned(); + let curve_at = CURVES_AT + 28 * i; + let mut curve: Vec = Vec::new(); + for (k, x) in CURVE_AT.iter().enumerate() { + let v = u16_at(b, curve_at + 2 * k)? as i16; + if v >= 0 { + curve.push(CurvePoint { input: f32::from(*x) / 100.0, output: (f32::from(v) / 1000.0).clamp(0.0, 1.0) }); + } + } + if curve.len() < 2 { + curve = DuotoneInk::new("", [0.0; 3]).curve; + } + Some(DuotoneInk { name, color, curve }) + }) + .collect() +} + +/// The Duotone block for `d`'s first four inks: RGB ink colours, their names and their curves +/// sampled at the 13 gray densities (the points on that grid as they are, the others left out), +/// Photoshop's default dot gain and computed overprint colours. +pub(crate) fn write(d: &Duotone) -> Vec { + let mut b = Vec::with_capacity(BLOCK_LEN); + let inks: Vec<&DuotoneInk> = d.inks.iter().take(4).collect(); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&(inks.len().max(1) as u16).to_be_bytes()); + for i in 0..4 { + match inks.get(i) { + Some(ink) => { + let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16; + let (space, v) = AcoColor::Rgb(ink.color.map(q)).to_record(); + b.extend_from_slice(&space.to_be_bytes()); + v.iter().for_each(|x| b.extend_from_slice(&x.to_be_bytes())); + } + None => b.extend_from_slice(&NO_COLOR), + } + } + for i in 0..4 { + let mut name = [0u8; 64]; + if let Some(ink) = inks.get(i) { + let bytes: Vec = ink.name.chars().map(|c| if c.is_ascii() { c as u8 } else { b'?' }).take(63).collect(); + name[0] = bytes.len() as u8; + name[1..=bytes.len()].copy_from_slice(&bytes); + } + b.extend_from_slice(&name); + } + for i in 0..4 { + let values = curve_values(inks.get(i).map(|ink| ink.curve.as_slice())); + values.iter().for_each(|v| b.extend_from_slice(&v.to_be_bytes())); + b.extend_from_slice(&0i16.to_be_bytes()); + } + b.extend_from_slice(&20u16.to_be_bytes()); + for _ in 0..11 { + b.extend_from_slice(&NO_COLOR); + } + b +} + +/// A curve's 13 values: an identity curve (or none) is 0 and 100 % only, as Photoshop writes it; +/// a curve whose points all lie on the 13 gray densities keeps exactly those points; any other +/// curve is sampled at all 13. +fn curve_values(curve: Option<&[CurvePoint]>) -> [i16; 13] { + let mut out = [-1i16; 13]; + let pts = curve.filter(|c| c.len() >= 2); + let identity = pts.is_none_or(|c| c.iter().all(|p| (p.input - p.output).abs() < 1e-4)); + let per_mille = |v: f32| (v.clamp(0.0, 1.0) * 1000.0).round() as i16; + if identity { + out[0] = 0; + out[12] = 1000; + return out; + } + let pts = pts.unwrap_or_default(); + let slot = |x: f32| CURVE_AT.iter().position(|a| (f32::from(*a) / 100.0 - x).abs() < 1e-4); + if pts.iter().all(|p| slot(p.input).is_some()) { + for p in pts { + if let Some(k) = slot(p.input) { + out[k] = per_mille(p.output); + } + } + return out; + } + let ink = DuotoneInk { name: String::new(), color: [0.0; 3], curve: pts.to_vec() }; + for (k, x) in CURVE_AT.iter().enumerate() { + out[k] = per_mille(ink.density(f32::from(*x) / 100.0)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ink(name: &str, color: [f32; 3], curve: &[(f32, f32)]) -> DuotoneInk { + let curve = + if curve.is_empty() { DuotoneInk::new("", [0.0; 3]).curve } else { curve.iter().map(|&(input, output)| CurvePoint { input, output }).collect() }; + DuotoneInk { name: name.into(), color, curve } + } + + #[test] + fn writes_the_block_as_photoshop_does() { + // Black and orange (255, 128, 0) with plain curves: the block Photoshop 2025 wrote for the + // same inks, field by field. + let d = Duotone { inks: vec![ink("Black", [0.0; 3], &[]), ink("Orange", [1.0, 32895.0 / 65535.0, 0.0], &[])], psd_raw: None }; + let b = write(&d); + assert_eq!(b.len(), BLOCK_LEN); + assert_eq!(&b[..4], &[0, 1, 0, 2]); + assert_eq!(&b[4..14], &[0; 10], "black, RGB"); + assert_eq!(&b[14..24], &[0, 0, 0xFF, 0xFF, 0x80, 0x7F, 0, 0, 0, 0], "orange, RGB"); + assert_eq!(&b[24..44], &[NO_COLOR, NO_COLOR].concat(), "no third and fourth ink"); + assert_eq!(&b[44..50], b"\x05Black"); + assert_eq!(&b[108..115], b"\x06Orange"); + let identity: Vec = [0i16, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1000, 0].iter().flat_map(|v| v.to_be_bytes()).collect(); + for i in 0..4 { + assert_eq!(&b[300 + 28 * i..328 + 28 * i], &identity[..], "curve {i}"); + } + assert_eq!(&b[412..414], &[0, 20], "dot gain"); + assert!(b[414..].chunks(10).all(|c| c == NO_COLOR), "overprint colours computed"); + } + + #[test] + fn inks_round_trip() { + let d = Duotone { + inks: vec![ + ink("Black", [0.0; 3], &[(0.0, 0.0), (0.6, 0.0), (1.0, 1.0)]), + ink("Teal", [0.0, 0.5, 0.5], &[(0.0, 0.1), (0.05, 0.2), (0.95, 0.9), (1.0, 0.95)]), + ink("Tan", [0.8, 0.7, 0.5], &[]), + ], + psd_raw: None, + }; + let mut unknown = false; + let back = parse(&write(&d), &mut unknown).unwrap(); + assert!(!unknown); + assert_eq!(back.len(), 3); + for (a, b) in d.inks.iter().zip(&back) { + assert_eq!(a.name, b.name); + assert!(a.color.iter().zip(b.color).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} {b:?}"); + assert_eq!(a.curve.len(), b.curve.len(), "{}", a.name); + for (p, q) in a.curve.iter().zip(&b.curve) { + assert!((p.input - q.input).abs() < 1e-6 && (p.output - q.output).abs() < 1e-3, "{}: {p:?} {q:?}", a.name); + } + } + } + + #[test] + fn a_curve_off_the_grid_is_sampled_at_all_13_points() { + let curve = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.33, output: 0.66 }, CurvePoint { input: 1.0, output: 1.0 }]; + let v = curve_values(Some(&curve)); + assert!(v.iter().all(|&x| x >= 0), "{v:?}"); + assert_eq!((v[0], v[6], v[12]), (0, 746, 1000), "{v:?}"); + } + + #[test] + fn a_colour_book_ink_reads_as_black_and_says_so() { + let mut b = write(&Duotone { inks: vec![ink("PANTONE 300 C", [0.0, 0.3, 0.7], &[])], psd_raw: None }); + b[4..6].copy_from_slice(&3u16.to_be_bytes()); + let mut unknown = false; + let inks = parse(&b, &mut unknown).unwrap(); + assert!(unknown); + assert_eq!((inks[0].name.as_str(), inks[0].color), ("PANTONE 300 C", [0.0; 3])); + } + + #[test] + fn malformed_blocks_are_not_read() { + let mut unknown = false; + let good = write(&Duotone { inks: vec![ink("Black", [0.0; 3], &[])], psd_raw: None }); + assert!(parse(&good[..100], &mut unknown).is_none()); + let mut v = good.clone(); + v[1] = 2; + assert!(parse(&v, &mut unknown).is_none(), "version"); + let mut n = good; + n[3] = 5; + assert!(parse(&n, &mut unknown).is_none(), "ink count"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 309107a4c5..11211618b5 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,7 @@ pub mod exr_parts; mod flat; mod gradient_bake; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; +mod duotone_map; mod kra; pub mod linked; mod multichannel_map; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9b5878af7d..e45232e143 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1432,13 +1432,14 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c return file; } let fmt = doc.pixel_format(); - // A Duotone keeps the inks it was imported with (their Color Mode Data is undocumented and - // kept verbatim) over its grey plate; Photoshop has no 32-bit Duotone. - let duotone_inks = doc + // A Duotone is written over its grey plate with its inks: the block it was imported with, + // verbatim, while the inks are unedited, else one made from them (`duotone_map`). Photoshop + // has no 32-bit Duotone. + let duotone_inks: Option> = doc .duotone .as_ref() - .and_then(|d| d.psd_raw.as_ref()) - .filter(|_| doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32); + .filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32) + .map(|d| d.psd_raw.clone().unwrap_or_else(|| crate::duotone_map::write(d))); let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; @@ -1718,7 +1719,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not // open a 32-bit file without them (#291). let color_mode_data = match duotone_inks { - Some(raw) => raw.to_vec(), + Some(raw) => raw, None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), }; let file = PsdFile { diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1517300131..6b5ea0e95f 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -835,10 +835,17 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8); doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { - // The duotone ink block is undocumented: keep it raw; the image shows as its gray plate. - doc.duotone = - Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(file.color_mode_data.clone()) }); - cx.warn("duotone inks are not interpreted (shown as grayscale)"); + // The inks (`duotone_map`); the block is also kept raw, written back while they stay + // unedited. + let mut unknown = false; + let inks = crate::duotone_map::parse(&file.color_mode_data, &mut unknown); + if inks.is_none() { + cx.warn("duotone inks are not interpreted (shown as grayscale)"); + } else if unknown { + cx.warn("a duotone ink from a colour book is shown as black"); + } + let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]); + doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.clone()) }); } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index 5e4f85a574..a8bf7ad0af 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -196,6 +196,32 @@ fn palette_and_duotone_modes_are_written_in_their_own_mode() { } } +#[test] +fn a_duotone_made_in_photocraft_is_written_with_its_inks() { + use photocraft_doc::{Duotone, DuotoneInk}; + let mut d = photocraft_doc::Document::with_background( + "d", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Grayscale, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0), + ); + d.mode = photocraft_color::ColorMode::Duotone; + d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{:?}", out.warnings); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((f.header.color_mode, f.color_mode_data.len()), (ColorMode::Duotone, 524)); + let (back, w) = psd_to_document(&f); + assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}"); + let inks = &back.duotone.as_ref().unwrap().inks; + assert_eq!(inks.iter().map(|i| i.name.as_str()).collect::>(), ["Black", "Orange"]); + assert!((inks[1].color[1] - 0.5).abs() < 1e-4, "{:?}", inks[1].color); + // Re-exported unedited, the block is the one read. + let again = PsdFile::from_bytes(&export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(again.color_mode_data, f.color_mode_data); +} + #[test] fn indexed_psd_keeps_the_table_length_and_transparent_entry() { use photocraft_doc::ColorTable; From 09af99c6f3e6f38230da4b093364e0a4682862f3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 12:41:44 +0200 Subject: [PATCH 0064/1452] History panel menu: Serbian labels Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/sr.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7a85f8d58f..9a4618f6de 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2259,6 +2259,8 @@ Create Snapshot Napravi snimak stanja Delete Snapshot Obriši snimak stanja New Snapshot Novi snimak stanja + New Snapshot… Novi snimak stanja… + History Options… Opcije istorije… Snapshot 1 Snimak stanja 1 Step Forward Korak napred Step Backward Korak nazad From 716e398edee185f8fc4e80a5927a60cab8d00fe6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 12:47:44 +0200 Subject: [PATCH 0065/1452] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1ad3f3aa3d..66c0f894e5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -136,7 +136,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | -| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | +| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627) | | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | From 511b55bec3761103eaa99980a3759545d366c106 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 13:10:26 +0200 Subject: [PATCH 0066/1452] History Brush source: Serbian labels Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/sr.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7a85f8d58f..eb2215010b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1705,6 +1705,8 @@ Overlay Preklapanje Paint Bucket Tool Alatka kofica s bojom Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju + Paints from the History panel's source state Slika iz izvornog stanja panela Istorija + Sets the source for the history brush Postavlja izvor za četkicu istorije Path Selection Tool Alatka za selekciju putanje Direct Selection Tool Alatka za direktnu selekciju Add Anchor Point Tool Alatka za dodavanje sidrišne tačke From b2fbdb1c2dafa11c2423f8260d49f3a8a28e097a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:12:49 +0700 Subject: [PATCH 0067/1452] fix(ui): deselect layers when clicking outside the canvas (#4923) --- crates/ui-egui/src/move_box.rs | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/src/move_box.rs b/crates/ui-egui/src/move_box.rs index ccbfd9cb4f..f49a5061be 100644 --- a/crates/ui-egui/src/move_box.rs +++ b/crates/ui-egui/src/move_box.rs @@ -50,6 +50,20 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { let b = LayerBox { end: [x, y], ..*b }; app.layer_box = None; let (w, h) = (b.end[0] - b.start[0], b.end[1] - b.start[1]); + if w == 0.0 && h == 0.0 && !b.add { + // A click on the pasteboard clears the layer target; a drag from there still + // uses the marquee, and a click on transparent pixels inside the canvas does not. + if let Some(st) = app.session.active() { + let rect = st.doc.bounds(); + let outside = b.start[0] < f64::from(rect.x0) + || b.start[1] < f64::from(rect.y0) + || b.start[0] >= f64::from(rect.x1) + || b.start[1] >= f64::from(rect.y1); + if outside { + let _ = app.run("select.deselectLayers", json!({})); + } + } + } if w == 0.0 || h == 0.0 { return true; } @@ -115,6 +129,31 @@ mod tests { assert_eq!(sel, ids); } + /// #4923: clicking the pasteboard, but not empty canvas pixels, clears layer targets. + /// A Shift-click preserves the selection, and a pasteboard drag remains a marquee. + #[test] + fn pasteboard_click_deselects_layers_without_affecting_empty_canvas_clicks() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + let layer = app.session.active().unwrap().active_layer.unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer]); + let click = |app: &mut PhotocraftApp, p: [f64; 2], mods| { + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, mods); + }; + click(&mut app, [30.0, 30.0], egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer], "empty pixels are still in the canvas"); + click(&mut app, [-10.0, 30.0], egui::Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer], "Shift adds, not clears"); + click(&mut app, [-10.0, 30.0], egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert!(st.selected_layers().is_empty() && st.active_layer.is_none()); + assert!(app.layer_box.is_none()); + } + /// #4058: a drag that starts on the locked Background draws the layer marquee. It does not /// open the move-lock dialog, and releasing selects the unlocked layer the box touches. #[test] From 6f3db89321f60b4d2188ed634f42804e891a214e Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:20:27 -0500 Subject: [PATCH 0068/1452] Contributors: add Jason Brashear --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bbda6719ec..7f0210dee2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -154,3 +154,6 @@ sync_github_name = true [people.VanTan666] display_name = "VanTan" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 1e6a9f27b9976a7ed1c5fbeca7da6107163bb21e Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:59:19 +0000 Subject: [PATCH 0069/1452] Codecs: PCX runs may cross plane boundaries within a scanline --- crates/codecs/src/codecs/pcx.rs | 6 ++---- crates/codecs/tests/pcx.rs | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index 43a3e97302..f89a781e9d 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -34,7 +34,7 @@ pub(crate) fn looks_like_pcx(b: &[u8]) -> bool { stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4) } -/// Read one compressed plane's row. RLE packets never cross a scanline. +/// Read one compressed scanline, including all planes. RLE packets never cross a scanline. fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> { let mut n = 0; while n < out.len() { @@ -77,9 +77,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let mut p = HEADER; let width = w as usize; for y in 0..h as usize { - for plane in row.chunks_mut(stride) { - read_row(&b[..image_end], &mut p, plane)?; - } + read_row(&b[..image_end], &mut p, &mut row)?; for x in 0..width { let rgb = if bits == 8 && planes == 3 { [row[x], row[stride + x], row[2 * stride + x]] diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/pcx.rs index 89661cb8eb..a5796473e6 100644 --- a/crates/codecs/tests/pcx.rs +++ b/crates/codecs/tests/pcx.rs @@ -79,7 +79,7 @@ fn decodes_4bit_and_monochrome_palettes() { fn rejects_truncated_and_overshooting_rle_without_panicking() { let mut b = header(8, 3, 2, 1, 2); assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated"); - b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row + b.extend([0xc7, 0xff]); // a seven-byte run cannot cross a six-byte scanline assert!(decode_as(Format::Pcx, &b).is_err()); let mut b = header(8, 3, 2, 1, 2); b.extend([0xc1]); // missing run value @@ -98,3 +98,21 @@ fn refuses_headers_that_exceed_allocation_budget() { b[2] = 0; assert_eq!(detect(&b), None, "unsupported uncompressed encoding"); } + +#[test] +fn decodes_rgb_runs_across_planes() { + let mut b = header(8, 3, 2, 1, 2); + b.extend([0xc4, 0xff, 0xc2, 0]); + let im = decode(&b).unwrap(); + assert_eq!(im.data(), &[255, 255, 0, 255, 255, 0]); + let png = encode(&im, Format::Png, &Default::default()).unwrap(); + assert_eq!(decode(&png).unwrap().data(), im.data()); +} + +#[test] +fn decodes_four_plane_runs_across_planes() { + let mut b = header(1, 4, 2, 1, 2); + b[16 + 15 * 3..16 + 16 * 3].copy_from_slice(&[255, 255, 0]); + b.extend([0xc8, 0xff]); + assert_eq!(decode(&b).unwrap().data(), &[255, 255, 0, 255, 255, 0]); +} From 74ad8ee5bc68929fd2a332d2340eef155ee6e1e1 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 16:51:45 -0500 Subject: [PATCH 0070/1452] Pin a corpus of real Photoshop Actions sets and test the .atn parser and import on it (#219) - corpus/atn: 16 sets from seven public repositories (MIT, BSD, Unlicense), fetched at pinned commits by cargo xtask corpus --atn, verified against xtask/atn-corpus.sha256, licences next to the files, rows in ATTRIBUTION.md and docs/development.md. photocraft-corpus takes only our own generated files, so third-party sets are pins here, like psd-tools and ag-psd. - photocraft-psd corpus_atn: every set parses and rewrites byte for byte (floor 16 files). photocraft-engine corpus_atn: every set loads, saves and reloads to the same actions; the steps still kept as Photoshop steps are capped (517 of 5 296 measured; generative AI and scripts), and the first differing step is named on a mismatch. - What the corpus found: localised panel names are platform legacy bytes (Mac Roman), now kept verbatim with AtnItem::label from the descriptor's Unicode class name; exporters emitted Photoshop defaults the importer had not read (now symmetric); setAdjustment carries its kind; and 300 more steps map: transform without a reference, selection nudges, moves to the bottom, View menu items, toolbox colours, clipping masks, ungroup, copy/paste styles, collapse groups, layer comps. New example atn_roundtrip shows the first differing byte. --- ATTRIBUTION.md | 1 + crates/engine/src/atn_cmds.rs | 209 ++++++++++++++++++++------- crates/engine/src/atn_cmds/tests.rs | 65 ++++++++- crates/engine/tests/corpus_atn.rs | 89 ++++++++++++ crates/psd/examples/atn_dump.rs | 4 +- crates/psd/examples/atn_roundtrip.rs | 54 +++++++ crates/psd/src/atn.rs | 82 ++++++++--- crates/psd/tests/corpus_atn.rs | 59 ++++++++ docs/development.md | 7 + xtask/atn-corpus.sha256 | 16 ++ xtask/src/corpus.rs | 9 +- xtask/src/corpus_pins.rs | 69 ++++++++- xtask/src/main.rs | 2 +- 13 files changed, 587 insertions(+), 79 deletions(-) create mode 100644 crates/engine/tests/corpus_atn.rs create mode 100644 crates/psd/examples/atn_roundtrip.rs create mode 100644 crates/psd/tests/corpus_atn.rs create mode 100644 xtask/atn-corpus.sha256 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 227aee8ac9..369aa638fe 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -111,6 +111,7 @@ next to the files: | `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) | | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | +| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | | `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..86e8075dd6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -121,6 +121,7 @@ enum Target { Property(String), Class, Offset(i32), + Index(u32), Other, } @@ -131,6 +132,7 @@ fn target(r: &[ReferenceItem]) -> Target { Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)), Some(ReferenceItem::Class(_)) => Target::Class, Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset), + Some(ReferenceItem::Index { index, .. }) => Target::Index(*index), _ => Target::Other, } } @@ -607,24 +609,30 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { } } for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { - e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); + if let Some(v) = jnum(p, param) { + e = e.with(key, long(v)); + } } Some(e) } +// Exporters write only what the importer read, so a loaded set saves and reloads to the same +// steps; Photoshop fills in its own defaults for what is missing. fn hue_sat_to(p: &Value) -> Option { - let mut entries = vec![D::Descriptor(hue_entry(p, None)?)]; + let mut d = desc("HStr").with("presetKind", enm("presetKindType", "presetKindCustom")); + if let Some(c) = jbool(p, "colorize") { + d = d.with("Clrz", D::Boolean(c)); + } + let mut entries = Vec::new(); + if ["hue", "saturation", "lightness"].iter().any(|k| p.get(*k).is_some()) { + entries.push(D::Descriptor(hue_entry(p, None)?)); + } for (i, name) in HUE_RANGES.iter().enumerate() { if let Some(r) = p.get(*name).filter(|v| v.is_object()) { entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?)); } } - Some( - desc("HStr") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))) - .with("Adjs", D::List(entries)), - ) + Some(d.with("Adjs", D::List(entries))) } fn triple(d: &Descriptor, k: &str) -> Option { @@ -653,9 +661,14 @@ fn color_balance_from(d: &Descriptor) -> Option { fn color_balance_to(p: &Value) -> Option { let mut d = desc("ClrB"); for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { - d = d.with(key, triple_to(p, param).unwrap_or_else(|| D::List(vec![long(0.0), long(0.0), long(0.0)]))); + if let Some(t) = triple_to(p, param) { + d = d.with(key, t); + } } - Some(d.with("PrsL", D::Boolean(jbool(p, "preserveLuminosity").unwrap_or(true)))) + if let Some(b) = jbool(p, "preserveLuminosity") { + d = d.with("PrsL", D::Boolean(b)); + } + Some(d) } const BW: &[F] = &[ @@ -694,7 +707,10 @@ fn channel_mixer_to(p: &Value) -> Option { d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3))))); } } - Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) + if let Some(m) = jbool(p, "monochrome") { + d = d.with("Mnch", D::Boolean(m)); + } + Some(d) } const SELECTIVE_RANGES: &[(&str, &str)] = &[ @@ -733,12 +749,11 @@ fn selective_to(p: &Value) -> Option { )); } } - Some( - desc("SlcC") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" })) - .with("ClrC", D::List(list)), - ) + let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom")); + if let Some(m) = jstr(p, "method") { + d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" })); + } + Some(d.with("ClrC", D::List(list))) } /// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. @@ -787,12 +802,14 @@ fn gradient_map_to(p: &Value) -> Option { .with("Intr", D::Double(4096.0)) .with("Clrs", D::List(stops?)) .with("Trns", transparency); - Some( - desc("GdMp") - .with("Grad", D::Descriptor(grad)) - .with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false))) - .with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))), - ) + let mut d = desc("GdMp").with("Grad", D::Descriptor(grad)); + if let Some(r) = jbool(p, "reverse") { + d = d.with("Rvrs", D::Boolean(r)); + } + if let Some(dd) = jbool(p, "dither") { + d = d.with("Dthr", D::Boolean(dd)); + } + Some(d) } /// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and @@ -989,7 +1006,7 @@ fn make_from(d: &Descriptor) -> Mapped { let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; let mut v = vec![(id, json!({}))]; if let Some(n) = get_text(nw, "Nm ") { - v.push(("layer.renameLayer", json!({"name": n}))); + v.push(("layer.setProps", json!({"name": n}))); } return steps(v); } @@ -1227,6 +1244,11 @@ fn set_from(d: &Descriptor) -> Mapped { } steps(v) } + // The toolbox colours. + (Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) { + Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})), + None => unsupported, + }, (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1268,7 +1290,11 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { Some((a, t)) => match (a.from)(t) { - Some(p) => step("layer.setAdjustment", p), + // `kind` names the adjustment so Save Actions can write the class back. + Some(mut p) => { + p["kind"] = json!(a.suffix); + step("layer.setAdjustment", p) + } None => unsupported, }, None => unsupported, @@ -1308,6 +1334,17 @@ fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", quick_mask_ref()) } + "tools.setColors" => { + // One colour per step: the foreground when given, else the background. + let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) { + (Some(f), _) => ("FrgC", f), + (None, Some(b)) => ("BckC", b), + (None, None) => return None, + }; + desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: code(key) }])) + .with("T ", D::Descriptor(color_desc(hex)?)) + } "select.rect" => { let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; @@ -1400,10 +1437,18 @@ fn select_from(d: &Descriptor) -> Mapped { } } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + // A menu item chosen while recording (`Mn`): the View menu's zoom items replay. + (Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) { + Some(id) => none(id), + None => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, } } +/// `MnIt` menu-item codes ↔ the shell's view commands. +const MENU_ITEMS: &[(&str, &str)] = &[("FtOn", "view.fitOnScreen"), ("ActP", "view.actualPixels"), ("ZmIn", "view.zoomIn"), ("ZmOt", "view.zoomOut")]; + fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); @@ -1434,7 +1479,10 @@ fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), - _ => return None, + _ => { + let item = lookup_back(MENU_ITEMS, id)?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: code("MnIt"), value: code(item) }])) + } }) } @@ -1550,7 +1598,9 @@ fn close_to(_: &str, _: &Value) -> Option { /// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything /// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. fn transform_from(d: &Descriptor) -> Mapped { - let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // No reference (a smart object replaced in place, `replaceLayer`) means the target layer. + let target_layer = [ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]; + let r: &[ReferenceItem] = get_ref(d, "null").unwrap_or(&target_layer); let unsupported = Mapped::Unsupported { harmless: false }; let mut p = Map::new(); if let Some(o) = get_obj(d, "Ofst") { @@ -1695,7 +1745,9 @@ fn apply_image_to(_: &str, p: &Value) -> Option { if let Some(i) = jbool(&source, "invert") { with = with.with("Invr", D::Boolean(i)); } - with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, jstr(p, "blending").unwrap_or("multiply"))?)); + if let Some(b) = jstr(p, "blending") { + with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, b)?)); + } if let Some(v) = jnum(p, "opacity") { with = with.with("Opct", pct(v)); } @@ -1746,15 +1798,14 @@ fn export_from(d: &Descriptor) -> Mapped { fn export_to(_: &str, p: &Value) -> Option { let path = jstr(p, "path")?; - let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?)); - for (key, param, default) in [ - ("Intr", "interlaced", false), - ("Trns", "transparency", true), - ("EICC", "embedIcc", false), - ("Prgr", "progressive", false), - ("Optm", "optimized", true), - ] { - u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default))); + let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")); + if let Some(f) = jstr(p, "format") { + u = u.with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, f)?)); + } + for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] { + if let Some(b) = jbool(p, param) { + u = u.with(key, D::Boolean(b)); + } } if let Some(q) = jnum(p, "quality") { u = u.with("Qlty", long(q)); @@ -1764,14 +1815,15 @@ fn export_to(_: &str, p: &Value) -> Option { } match jstr(p, "matte") { Some("none") => u = u.with("Mtt ", D::Boolean(false)), - matte => { - let c = color_desc(matte.unwrap_or("#ffffff"))?; + Some(matte) => { + let c = color_desc(matte)?; u = u .with("Mtt ", D::Boolean(true)) .with("MttR", get_num(&c, "Rd ").map(long)?) .with("MttG", get_num(&c, "Grn ").map(long)?) .with("MttB", get_num(&c, "Bl ").map(long)?); } + None => {} } Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) } @@ -1808,6 +1860,16 @@ fn visibility_to(_: &str, p: &Value) -> Option { fn move_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // The selection outline nudged. + if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Property(p) if p == "fsel") { + return match get_obj(d, "T ") { + Some(t) if matches!(t.get("Hrzn"), Some(D::UnitFloat { unit: PX, .. })) => match (get_num(t, "Hrzn"), get_num(t, "Vrtc")) { + (Some(dx), Some(dy)) => step("select.transformSelection", json!({"dx": dx.round(), "dy": dy.round()})), + _ => Mapped::Unsupported { harmless: false }, + }, + _ => Mapped::Unsupported { harmless: false }, + }; + } if ref_class(r).as_deref() != Some("Lyr") { return Mapped::Unsupported { harmless: false }; } @@ -1843,6 +1905,8 @@ fn move_from(d: &Descriptor) -> Mapped { Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", Target::Offset(1) => "layer.arrange.bringForward", Target::Offset(-1) => "layer.arrange.sendBackward", + // Index 0 is the bottom of the stack. + Target::Index(0) => "layer.arrange.sendToBack", _ => return Mapped::Unsupported { harmless: false }, }; step(id, Value::Object(p)) @@ -1987,12 +2051,19 @@ fn fill_from(d: &Descriptor) -> Mapped { fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); - d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); + if let Some(t) = jnum(p, "tolerance") { + d = d.with("Tlrn", long(t)); + } + if let Some(a) = jbool(p, "antiAlias") { + d = d.with("AntA", D::Boolean(a)); + } d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); if let Some(o) = jnum(p, "opacity") { d = d.with("Opct", pct(o)); } - d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(c) = jbool(p, "contiguous") { + d = d.with("Cntg", D::Boolean(c)); + } if let Some(m) = jbool(p, "sampleAllLayers") { d = d.with("Mrgd", D::Boolean(m)); } @@ -2003,7 +2074,12 @@ fn fill_to(id: &str, p: &Value) -> Option { if contents == "color" { d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?)); } - d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?)); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + if let Some(m) = jstr(p, "mode") { + d = d.with("Md ", enm("BlnM", blend_ps(m)?)); + } if let Some(t) = jbool(p, "preserveTransparency") { d = d.with("PrsT", D::Boolean(t)); } @@ -2223,7 +2299,9 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { if let Some(hh) = jnum(p, "height") { d = d.with("Hght", px(hh)); } - d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + if jstr(p, "anchor").is_some() { + d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + } if let Some(c) = jstr(p, "extensionColor") { d = match lookup_back(EXTENSION, c) { Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), @@ -2325,7 +2403,10 @@ fn crop_to(_: &str, p: &Value) -> Option { if let Some(a) = jnum(p, "angle") { d = d.with("Angl", ang(a)); } - Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true)))) + if let Some(del) = jbool(p, "deleteCroppedPixels") { + d = d.with("Dlt ", D::Boolean(del)); + } + Some(d) } fn stop_from(d: &Descriptor) -> Mapped { @@ -2733,6 +2814,7 @@ const EVENTS: &[Event] = &[ "select.rect", "select.lasso", "select.editInQuickMaskMode", + "tools.setColors", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2758,6 +2840,10 @@ const EVENTS: &[Event] = &[ "channel.target", "edit.undo", "edit.redo", + "view.fitOnScreen", + "view.actualPixels", + "view.zoomIn", + "view.zoomOut", ], from: select_from, to: select_to, @@ -2801,6 +2887,29 @@ const EVENTS: &[Event] = &[ ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]), ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]), + ev!("", "groupEvent", "Create Clipping Mask", "layer.createClippingMask", &[]), + ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]), + ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]), + ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]), + Event { + code: "", + name: "collapseAllGroupsEvent", + label: "Collapse All Groups", + ids: &["layer.setExpanded"], + from: |_| step("layer.setExpanded", json!({"all": true, "expanded": false})), + to: |_, p| (jbool(p, "all") == Some(true) && jbool(p, "expanded") == Some(false)).then(|| desc("null")), + }, + Event { + code: "", + name: "applyComp", + label: "Apply Layer Comp", + ids: &["layerComp.apply"], + from: |d| match get_ref(d, "null").map(target) { + Some(Target::Name(n)) => step("layerComp.apply", json!({"comp": n})), + _ => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))), + }, ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]), Event { code: "Algn", @@ -2907,7 +3016,7 @@ pub struct Imported { fn photoshop_step(item: &AtnItem) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); - (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor})) + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor})) } /// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings @@ -2942,7 +3051,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) let (new_steps, enabled) = match mapped { Mapped::Steps(s) => (s, item.enabled), Mapped::Unsupported { harmless } => { - unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() }); + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() }); (vec![photoshop_step(item)], item.enabled && !harmless) } }; @@ -2993,21 +3102,21 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { let event = jstr(params, "event")?; let event = match event.as_bytes() { [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), - _ => AtnEvent::Name(event.to_string()), + _ => AtnEvent::name(event), }; let descriptor = match jstr(params, "descriptor") { Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), None => None, }; - return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); + return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").as_bytes().to_vec(), descriptor, ..Default::default() }); } let mut params = params.clone(); if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } let (e, descriptor) = event_and_descriptor(id, ¶ms)?; - let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; - Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) + let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; + Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }) } /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..de4f7efc99 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -6,9 +6,9 @@ use serde_json::json; fn item(event: &str, label: &str, d: Option) -> AtnItem { let event = match event.as_bytes() { [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), - _ => AtnEvent::Name(event.into()), + _ => AtnEvent::name(event), }; - AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() } + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() } } fn b64(bytes: &[u8]) -> String { @@ -293,6 +293,16 @@ fn every_mapped_command_exports_and_imports_back() { ("file.openAs", json!({"path": "/tmp/a.psd"})), ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), + ("tools.setColors", json!({"foreground": "#102030"})), + ("tools.setColors", json!({"background": "#ffffff"})), + ("view.fitOnScreen", json!({})), + ("view.zoomIn", json!({})), + ("layer.createClippingMask", json!({})), + ("layer.ungroupLayers", json!({})), + ("layer.layerStyle.copyLayerStyle", json!({})), + ("layer.layerStyle.pasteLayerStyle", json!({})), + ("layer.setExpanded", json!({"all": true, "expanded": false})), + ("layerComp.apply", json!({"comp": "Original"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). @@ -393,6 +403,8 @@ fn every_mapped_command_exports_and_imports_back() { "select.modify.smooth", "select.reselect", "select.similar", + "view.actualPixels", + "view.zoomOut", "tools.swapColors", "edit.cut", "edit.copy", @@ -422,7 +434,10 @@ fn every_mapped_command_exports_and_imports_back() { // Every command the table names exists. // (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.) for id in EVENTS.iter().flat_map(|e| e.ids.iter()) { - assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`"); + assert!( + crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id), + "the .atn table names an unknown command `{id}`" + ); } } @@ -741,6 +756,50 @@ fn remaining_events_import_in_their_photoshop_shapes() { let rest_steps = &rest.actions[0].steps; let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + // A transform without a reference (a replaced smart object) is the target layer's; the + // selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the + // foreground colour. + let replaced = desc("null") + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("replaceLayer", D::Descriptor(desc("null"))); + let nudged = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0)))); + let to_bottom = desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }])) + .with("Adjs", D::Boolean(false)); + let fit = + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }])); + let fg = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }])) + .with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)))); + let items = vec![ + item("transform", "Transform", Some(replaced)), + item("move", "Move", Some(nudged)), + item("move", "Move", Some(to_bottom)), + item("select", "Select", Some(fit)), + item("set", "Set", Some(fg)), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More2"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let more = import(&atn::write(&file).unwrap()).unwrap(); + let more_steps = &more.actions[0].steps; + assert_eq!( + more_steps.iter().map(|(id, _)| id.as_str()).collect::>(), + ["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"] + ); + assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50})); + assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2})); + assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); + assert!(more.unsupported.is_empty()); assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); diff --git a/crates/engine/tests/corpus_atn.rs b/crates/engine/tests/corpus_atn.rs new file mode 100644 index 0000000000..1a0670f7a4 --- /dev/null +++ b/crates/engine/tests/corpus_atn.rs @@ -0,0 +1,89 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` set under +//! `corpus/atn/` loads through `actions.load`, exports through `actions.save`, and the export +//! loads back to the same actions. Steps without a PhotoCraft equivalent are counted against a +//! ceiling, so a mapping regression (or a new unmapped event in the corpus) shows up here. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_engine::Session; +use serde_json::json; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { + out.push(p); + } + } +} + +/// Sets in the corpus, and the most steps that may still be kept Photoshop steps across all of +/// them (2026-10-12: 517 of 5 296; 404 are generative AI (`generativeUpscale`, `syntheticFill`, +/// `syntheticGenHarmonize`: deferred by decision) and `AdobeScriptAutomation` scripts, the rest +/// shape layers with strokes, snapshots, smart-filter and channel edits, frame animation). +/// Raise the first, lower the second, never the reverse. +const FLOOR_FILES: usize = 16; +const CEILING_UNSUPPORTED: usize = 540; + +#[test] +fn atn_corpus_loads_saves_and_reloads() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + let (mut steps, mut unsupported) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let data = photocraft_engine::paint::tile::b64_encode(&std::fs::read(p).unwrap()); + let mut s = Session::new(); + let loaded = match s.execute("actions.load", json!({"data": data})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: load: {e}")); + continue; + } + }; + let n_steps = loaded["steps"].as_u64().unwrap_or(0) as usize; + let n_unsupported = loaded["unsupported"].as_array().map_or(0, Vec::len); + let events: Vec = + loaded["unsupported"].as_array().map_or(Vec::new(), |u| u.iter().filter_map(|x| x["event"].as_str().map(String::from)).collect()); + steps += n_steps; + unsupported += n_unsupported; + println!("{name}: {} actions, {n_steps} steps, {n_unsupported} kept Photoshop steps {events:?}", loaded["actions"].as_array().map_or(0, Vec::len)); + let first = s.actions.list.clone(); + let saved = match s.execute("actions.save", json!({"set": loaded["set"]})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: save: {e}")); + continue; + } + }; + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap_or("")).unwrap_or_default(); + let mut t = Session::new(); + match t.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes)})) { + Ok(_) if t.actions.list == first => {} + Ok(_) => { + // Name the first step that differs, so a mapping asymmetry is quick to find. + let detail = first + .iter() + .zip(&t.actions.list) + .flat_map(|(a, b)| a.steps.iter().zip(&b.steps).enumerate().map(move |(i, (x, y))| (a.name.as_str(), i, x, y))) + .find(|(_, _, x, y)| x != y) + .map(|(action, i, x, y)| format!("`{action}` step {}: {} {} → {} {}", i + 1, x.0, x.1, y.0, y.1)) + .unwrap_or_else(|| "the actions differ in count, flags or names".into()); + failures.push(format!("{name}: the saved set loads to different actions: {detail}")); + } + Err(e) => failures.push(format!("{name}: reload: {e}")), + } + } + println!("{} files, {steps} steps, {unsupported} kept Photoshop steps", files.len()); + assert!(files.len() >= FLOOR_FILES, "only {} .atn files; the corpus floor is {FLOOR_FILES}", files.len()); + assert!(unsupported <= CEILING_UNSUPPORTED, "{unsupported} steps have no PhotoCraft equivalent; the ceiling is {CEILING_UNSUPPORTED}"); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs index 8a39bbb121..04e268da94 100644 --- a/crates/psd/examples/atn_dump.rs +++ b/crates/psd/examples/atn_dump.rs @@ -66,8 +66,8 @@ fn main() { for item in &a.items { let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); match &item.descriptor { - Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)), - None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary), + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.label(), keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()), } } } diff --git a/crates/psd/examples/atn_roundtrip.rs b/crates/psd/examples/atn_roundtrip.rs new file mode 100644 index 0000000000..b73151b745 --- /dev/null +++ b/crates/psd/examples/atn_roundtrip.rs @@ -0,0 +1,54 @@ +//! Check `.atn` files for byte-exact round trips: parse, write, compare, and show the first +//! difference with its context: `cargo run -p photocraft-psd --example atn_roundtrip -- set.atn…` +//! +//! Exit status 1 when any file differs or fails to parse. + +use photocraft_psd::atn; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect::>().join(" ") +} + +fn main() { + let mut failed = false; + for path in std::env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: {e}"); + failed = true; + continue; + } + }; + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + println!("{path}: parse failed: {e}"); + failed = true; + continue; + } + }; + let written = match atn::write(&file) { + Ok(w) => w, + Err(e) => { + println!("{path}: write failed: {e}"); + failed = true; + continue; + } + }; + if written == bytes { + println!("{path}: identical ({} bytes)", bytes.len()); + continue; + } + failed = true; + let at = bytes.iter().zip(&written).position(|(a, b)| a != b).unwrap_or(bytes.len().min(written.len())); + let lo = at.saturating_sub(24); + println!("{path}: differs at byte {at} (original {} bytes, rewritten {} bytes)", bytes.len(), written.len()); + println!(" original : {}", hex(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[]))); + println!(" rewritten: {}", hex(written.get(lo..(at + 40).min(written.len())).unwrap_or(&[]))); + println!(" original : {}", String::from_utf8_lossy(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])).replace(|c: char| c.is_control(), ".")); + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 2d8bf9990f..6bb900d435 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -17,8 +17,10 @@ //! //! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the //! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events -//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are -//! rejected as unsupported. +//! carry them. The event name and the panel name are byte strings (ASCII, or the platform's +//! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim; +//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a +//! different, undocumented layout and are rejected as unsupported. //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor //! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so @@ -26,7 +28,7 @@ use crate::descriptor::{Descriptor, UnicodeString}; use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, WriteExt, decode_legacy_name}; /// The only version this module reads and writes (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -84,8 +86,9 @@ pub struct AtnItem { pub dialog_options: u8, /// The event. pub event: AtnEvent, - /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …). - pub dictionary: String, + /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …), as stored: + /// ASCII, or the authoring platform's legacy encoding when localised. See [`Self::label`]. + pub dictionary: Vec, /// The event's parameters; `None` for events without any. pub descriptor: Option, } @@ -98,49 +101,71 @@ impl Default for AtnItem { with_dialog: false, dialog_options: 0, event: AtnEvent::Code(*b" "), - dictionary: String::new(), + dictionary: Vec::new(), descriptor: None, } } } +impl AtnItem { + /// The panel name as text: UTF-8 when it is, otherwise decoded byte by byte (Latin-1). + pub fn dictionary(&self) -> String { + decode_legacy_name(&self.dictionary) + } + + /// A readable name for the step: the descriptor's class name when Photoshop stored one + /// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id. + pub fn label(&self) -> String { + let class = self.descriptor.as_ref().map(|d| d.name.to_string_lossy()).unwrap_or_default(); + if !class.trim().is_empty() { + return class; + } + let dict = self.dictionary(); + if dict.trim().is_empty() { self.event.id() } else { dict } + } +} + /// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum AtnEvent { /// Stored as `long` + 4 bytes. Code([u8; 4]), - /// Stored as `TEXT` + length-prefixed ASCII. - Name(String), + /// Stored as `TEXT` + length-prefixed bytes (ASCII in every file seen). + Name(Vec), } impl AtnEvent { + /// A string-named event. + pub fn name(s: &str) -> Self { + AtnEvent::Name(s.as_bytes().to_vec()) + } /// The event id as text: the code's four characters, or the name. pub fn id(&self) -> String { match self { AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), - AtnEvent::Name(n) => n.clone(), + AtnEvent::Name(n) => String::from_utf8_lossy(n).into_owned(), } } /// `true` if the event is the code or name `s`. pub fn is(&self, s: &str) -> bool { match self { AtnEvent::Code(c) => c == s.as_bytes(), - AtnEvent::Name(n) => n == s, + AtnEvent::Name(n) => n == s.as_bytes(), } } } -/// Reads a u32 length + ASCII bytes (event and dictionary names). -fn read_ascii(r: &mut Reader<'_>) -> Result { +/// Reads a u32 length + bytes (event and dictionary names), verbatim. +fn read_ascii(r: &mut Reader<'_>) -> Result> { let n = r.u32()?; - Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned()) + Ok(r.bytes_u64(u64::from(n))?.to_vec()) } -fn write_ascii(out: &mut Vec, s: &str) -> Result<()> { +fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; out.put_u32(n); - out.put(s.as_bytes()); + out.put(s); Ok(()) } @@ -277,12 +302,12 @@ mod tests { with_dialog: true, dialog_options: 2, event: AtnEvent::Code(*b"GsnB"), - dictionary: "Gaussian Blur".into(), + dictionary: b"Gaussian Blur".to_vec(), descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), }; let trim = AtnItem { - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some( Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } .with( @@ -293,7 +318,7 @@ mod tests { ), ..Default::default() }; - let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() }; + let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: b"Flatten Image".to_vec(), ..Default::default() }; AtnFile { name: UnicodeString::new_nul("Set ✓"), expanded: true, @@ -324,6 +349,21 @@ mod tests { assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); assert!(parsed.actions[1].items[0].event.is("trim")); assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); + assert_eq!(parsed.actions[0].items[0].label(), "Gaussian Blur"); + assert_eq!(parsed.actions[1].items[0].label(), "Trim"); + // A localised panel name in the platform's legacy encoding survives a round trip and + // the Unicode class name is the readable label. + let localised = AtnItem { + event: AtnEvent::name("set"), + dictionary: b"D\x8efinir".to_vec(), + descriptor: Some(Descriptor { name: UnicodeString::new_nul("Définir"), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }), + ..Default::default() + }; + assert_eq!(localised.label(), "Définir"); + let f = AtnFile { actions: vec![AtnAction { items: vec![localised.clone()], ..Default::default() }], ..Default::default() }; + let bytes = write(&f).unwrap(); + assert_eq!(parse(&bytes).unwrap().actions[0].items[0], localised); + assert_eq!(AtnItem { event: AtnEvent::name("x"), ..Default::default() }.label(), "x"); } /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the @@ -338,8 +378,8 @@ mod tests { expanded: true, items: vec![AtnItem { expanded: true, - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some(Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), diff --git a/crates/psd/tests/corpus_atn.rs b/crates/psd/tests/corpus_atn.rs new file mode 100644 index 0000000000..d3b4ace534 --- /dev/null +++ b/crates/psd/tests/corpus_atn.rs @@ -0,0 +1,59 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` under +//! `corpus/atn/` (gitignored, fetched by `cargo xtask corpus --atn`) parses, and writing it back +//! reproduces the bytes exactly. A missing corpus fails. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_psd::atn; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { + out.push(p); + } + } +} + +/// Sets in the corpus; raise, never lower. +const FLOOR: usize = 16; + +#[test] +fn atn_corpus_parses_and_is_byte_stable() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + let (mut actions, mut steps) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = std::fs::read(p).unwrap(); + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + failures.push(format!("{name}: {e}")); + continue; + } + }; + if !file.warnings.is_empty() { + failures.push(format!("{name}: warnings {:?}", file.warnings)); + } + actions += file.actions.len(); + steps += file.actions.iter().map(|a| a.items.len()).sum::(); + match atn::write(&file) { + Ok(written) if written == bytes => {} + Ok(written) => failures.push(format!("{name}: rewritten bytes differ ({} vs {} bytes)", written.len(), bytes.len())), + Err(e) => failures.push(format!("{name}: write failed: {e}")), + } + println!("{name}: {} actions, {} steps", file.actions.len(), file.actions.iter().map(|a| a.items.len()).sum::()); + } + println!("{} files, {actions} actions, {steps} steps", files.len()); + assert!(files.len() >= FLOOR, "only {} .atn files; the corpus floor is {FLOOR}", files.len()); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/docs/development.md b/docs/development.md index bb0de12690..9987b2d609 100644 --- a/docs/development.md +++ b/docs/development.md @@ -427,8 +427,14 @@ recorded) when adding an event to the table: ```sh cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" +cargo run -p photocraft-psd --example atn_roundtrip -- "Default Actions.atn" # parse, write, diff ``` +The real-file oracles are `corpus/atn/` (16 sets from seven public repositories, see *Test +corpora*): `photocraft-psd`'s corpus test parses every file and rewrites it byte for byte; +`photocraft-engine`'s loads, saves and reloads every set and caps the steps that still have no +PhotoCraft equivalent. + Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. @@ -468,6 +474,7 @@ against committed **sha256 manifests**. All pins are in one place: | `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` | | `corpus/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-corpus.sha256` | | `corpus/affinity/` | 21 public Affinity documents and the 18 Affinity 3 samples for #1606, with exported PNG references | vector-art and affinity-samples (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` | +| `corpus/atn/` | 16 Photoshop Actions sets from seven public repositories (frequency separation, adjustments, Save for Web, generative workflows…), the real-file oracles of the `.atn` parser and import | sbaril, bjango, BKWLD, atntools, trimmer, alpha-utility, gif-creator upstreams (MIT, BSD, Unlicense) | `xtask/atn-corpus.sha256` | | `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) | ```sh diff --git a/xtask/atn-corpus.sha256 b/xtask/atn-corpus.sha256 new file mode 100644 index 0000000000..b6cfea8f9d --- /dev/null +++ b/xtask/atn-corpus.sha256 @@ -0,0 +1,16 @@ +8b778f618f0219e18a1bf3994561c1d428acba42c4b3fc499bf8a7e334483d9a alpha-utility/AlphaUtilityAdditions.atn +6421c4001e6fc0f3593f55a523c5ec973eff56521cfeb2943c45a2e79ec584bf atntools/test.atn +3401a0d51699c8c9b19b3b228078ed7adc21385250b6bf23c1b5ea57662cd19f bjango/Bjango Actions.atn +b240cd0aa441071fbdba2898f3f6c72f1e1be2117a8c80f673d937ecae65510c bjango/Bjango Adjustment Actions.atn +f35e6109187fcab10a08d64a5eb58e147678643d4bd3225c43e6d80e00499da5 bjango/Bjango Scale Actions.atn +8f550c2b77d6ba8348c23092bb2d6df7fd27195c3f6218f7fa27feccf7516e74 bkwld/Save layers for web.atn +3d6b254532ec231f26145d62ecde018bab3ac7ba6c2380304e875158d787e689 gif-creator/BannerActions.atn +057374895b523bc1b9bf1f7a7b4fcd1bf4e66467fd783d8dc667a9a15b8939fe sbaril/Dimension-v01.atn +9b8ae4c7f0ebf103f9f45f1c051dffdfb67fc9c083d291be9c29b740023d85f2 sbaril/Firefly-Actions.atn +56ea2003be73018b81dfee524e0a5bc3c31b5b110045430793e2dd03406e66a3 sbaril/Frequency-Separation.atn +7e1dd8db08e98b74020fd61bc20cfecb25100062a723c4367de4eaa903ea1e29 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4096px-Limit.atn +46f372beac54a565cf7dde30afe9c2a247c43a991635823c68b91e378c863b61 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4K-Limit-Topaz.atn +1fed6e67ce15623d02e543aeffb76c2c14165eecfb4e363611a6e0ec3982d592 sbaril/Harmonize-Enhancement.atn +76a2704d6cc2269293e1aa221724fd769b0e77b5c8731777d7f06991f8c85366 sbaril/Photo-v01.atn +9b47dec313d23b342b668a85ac3d10fef953bc0c7c50d980de3025b6d861c12e sbaril/Quick-Object-Extraction.atn +418ec00d0c732c70d328f271cb79823af850514a746c8ff78df95e230522449e trimmer/Trimmer.atn diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index e6c929a1be..4272ad3de2 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -34,7 +34,7 @@ const CRITICAL: &[&str] = &[ "crates/affinity/", "crates/raw/", ]; -/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]` +/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --atn | --photoshop [--local]] [--update-manifest]` pub fn cmd(args: &[&str]) -> Result<(), String> { let update = args.contains(&"--update-manifest"); let mut did = false; @@ -69,6 +69,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> { corpus_pins::AFFINITY.fetch(update)?; did = true; } + if args.contains(&"--atn") { + corpus_pins::ATN.fetch(update)?; + did = true; + } if args.contains(&"--photoshop") { if args.contains(&"--local") { corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?; @@ -146,6 +150,8 @@ fn list() { manifest xtask/exr-corpus.sha256. Fetch: --exr corpus/affinity/ [{}] public Affinity documents and #1606 samples (CC0, MIT), with PNG references; manifest xtask/affinity-corpus.sha256. Fetch: --affinity + corpus/atn/ [{}] Photoshop Actions sets from seven public repositories (MIT, BSD, Unlicense); + manifest xtask/atn-corpus.sha256. Fetch: --atn corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in) @@ -163,6 +169,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma status(corpus_pins::EXR.is_current()), &OPENEXR_COMMIT[..12], status(corpus_pins::AFFINITY.is_current()), + status(corpus_pins::ATN.is_current()), status(png_ok), ); } diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 917412709a..3337dc894a 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -240,7 +240,74 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus { }, }; /// Every pinned corpus, in fetch order. -pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY]; +/// Photoshop Actions files (`.atn`) from public repositories under permissive licences, the +/// real-file oracles of `photocraft-psd::atn` and the engine's `.atn` import (2026-10-12). +/// https://github.com/sbaril/Photoshop-Scripts (MIT): `Actions/`. +pub const SBARIL_COMMIT: &str = "622493c9c617c9d2ff38500e8e512c0ad421eed9"; +/// https://github.com/bjango/Bjango-Actions (BSD, `Help/License.md`): three sets at the root. +pub const BJANGO_COMMIT: &str = "0fdc790b4931fd36816e6056dd84c2aa0d3ff054"; +/// https://github.com/BKWLD/photoshop-actions (MIT). +pub const BKWLD_COMMIT: &str = "d1a5d24e02bf23f78659106473ef7d8c747bea00"; +/// https://github.com/mrijk/atntools (MIT): the fixture of a Rust `.atn` reader. +pub const ATNTOOLS_COMMIT: &str = "529db30b631f32bc7970121af0393853c2154fa0"; +/// https://github.com/damsmcfly/photoshop-cleaner-trimmer (Unlicense). +pub const TRIMMER_COMMIT: &str = "fd3814df2a625ed7a015633eb91d8fe2f5bbe9a0"; +/// https://github.com/uclagamelab/AlphaUtilityAdditions (BSD-2-Clause). +pub const ALPHA_UTILITY_COMMIT: &str = "7bdae950a7db4c6c2224bcaf08f4c17ec18958cb"; +/// https://github.com/overbyte/photoshop-gif-creator-script (MIT). +pub const GIF_CREATOR_COMMIT: &str = "9fb78e8c09a21678afc84ccb31a435bd242894df"; + +/// `corpus/atn`: Photoshop Actions sets from seven public repositories, one folder each. +pub const ATN: PinnedCorpus = PinnedCorpus { + name: "atn", + dest: "atn", + upstreams: &[ + Upstream { prefix: "sbaril/", repo: "sbaril/Photoshop-Scripts", commit: SBARIL_COMMIT, subdir: "Actions", extras: &[("LICENSE", "sbaril/LICENSE")] }, + Upstream { prefix: "bjango/", repo: "bjango/Bjango-Actions", commit: BJANGO_COMMIT, subdir: "", extras: &[("Help/License.md", "bjango/License.md")] }, + Upstream { prefix: "bkwld/", repo: "BKWLD/photoshop-actions", commit: BKWLD_COMMIT, subdir: "", extras: &[("LICENSE", "bkwld/LICENSE")] }, + Upstream { prefix: "atntools/", repo: "mrijk/atntools", commit: ATNTOOLS_COMMIT, subdir: "", extras: &[("LICENSE", "atntools/LICENSE")] }, + Upstream { + prefix: "trimmer/", + repo: "damsmcfly/photoshop-cleaner-trimmer", + commit: TRIMMER_COMMIT, + subdir: "", + extras: &[("LICENSE", "trimmer/LICENSE")], + }, + Upstream { + prefix: "alpha-utility/", + repo: "uclagamelab/AlphaUtilityAdditions", + commit: ALPHA_UTILITY_COMMIT, + subdir: "", + extras: &[("LICENSE", "alpha-utility/LICENSE")], + }, + Upstream { + prefix: "gif-creator/", + repo: "overbyte/photoshop-gif-creator-script", + commit: GIF_CREATOR_COMMIT, + subdir: "", + extras: &[("LICENSE", "gif-creator/LICENSE")], + }, + ], + manifest: "xtask/atn-corpus.sha256", + exts: &["atn"], + subset: false, + sources_md: |c| { + format!( + "# Photoshop Actions (.atn) corpus\n\nAction sets from public repositories, unmodified, each in its own folder with its licence: \ + `sbaril/` (https://github.com/sbaril/Photoshop-Scripts/tree/{SBARIL_COMMIT}/Actions, MIT), \ + `bjango/` (https://github.com/bjango/Bjango-Actions/tree/{BJANGO_COMMIT}, BSD), \ + `bkwld/` (https://github.com/BKWLD/photoshop-actions/tree/{BKWLD_COMMIT}, MIT), \ + `atntools/` (https://github.com/mrijk/atntools/tree/{ATNTOOLS_COMMIT}, MIT), \ + `trimmer/` (https://github.com/damsmcfly/photoshop-cleaner-trimmer/tree/{TRIMMER_COMMIT}, Unlicense), \ + `alpha-utility/` (https://github.com/uclagamelab/AlphaUtilityAdditions/tree/{ALPHA_UTILITY_COMMIT}, BSD-2-Clause), \ + `gif-creator/` (https://github.com/overbyte/photoshop-gif-creator-script/tree/{GIF_CREATOR_COMMIT}, MIT). \ + Fetched and sha256-verified by `cargo xtask corpus --atn` against `{}`. Gitignored; never commit these files.\n", + c.manifest + ) + }, +}; + +pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY, &ATN]; /// `corpus/pixls`: real camera raws from (released into the public /// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..d849d58d55 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -25,7 +25,7 @@ commands: layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) - corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] + corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, --photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO) From 5035aa4116184dbffb40d1167c145e9715bedc89 Mon Sep 17 00:00:00 2001 From: Lorenzo Fasolino Date: Tue, 13 Oct 2026 16:15:27 +0200 Subject: [PATCH 0071/1452] fix(i18n): Italian Screen blend mode and Dodge tool names were swapped Task T-0001 del lab artcraft-lab. File toccati: crates/ui-egui/src/i18n/it.tsv | 8 ++++---- crates/ui-egui/src/i18n/mod.rs | 16 ++++++++++++++++ docs/localization-it.md | 9 +++++++++ docs/localization-parity.md | 1 + 4 files changed, 30 insertions(+), 4 deletions(-) Evidenze: artifacts/T-0001/ (nel lab, non in questo repo). --- crates/ui-egui/src/i18n/it.tsv | 8 ++++---- crates/ui-egui/src/i18n/mod.rs | 16 ++++++++++++++++ docs/localization-it.md | 9 +++++++++ docs/localization-parity.md | 1 + 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 08d8adcc14..95a77d5313 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1655,7 +1655,7 @@ Document Dimensions Dimensioni documento Document Profile Profilo documento Document Sizes Dimensioni documento - Dodge Tool Strumento Scherma + Dodge Tool Strumento Schiarisci Down Giù Drag to draw a gradient Trascina per disegnare una sfumatura Drag to move the active layer Trascina per spostare livello attivo @@ -1989,7 +1989,7 @@ Same folder Stessa cartella Save in background Salva in background Scratch Disks Dischi virtuali - Screen Schermo + Screen Scherma Screen resolution Risoluzione schermo Scrubby slider acceleration Accelerazione slider scrubby Sessions only Solo sessioni @@ -2228,7 +2228,7 @@ Move Sposta Pencil Matita Clone Stamp Timbro clone - Dodge Scherma + Dodge Schiarisci Burn Brucia Quick Selection Selezione rapida History Brush Pennello storia @@ -2795,7 +2795,7 @@ curves Smooth Leviga Patch Toppa Content-Aware Move Spostamento in base al contenuto Perspective Crop Taglierina prospettica - Dodge/Burn Scherma/Brucia + Dodge/Burn Schiarisci/Brucia Dynamics Dinamica Tilt Inclinazione Defaults restored. Predefiniti ripristinati. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 2fdffdfc69..bd4e290153 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1225,6 +1225,22 @@ mod tests { } } + /// The Italian catalog names the Dodge *tools* and the `Screen` *blend mode* the way Adobe + /// does: `Schiarisci` is the tool (`Lighten` already was), `Scherma` is the blend mode. They + /// were swapped — `Screen` read as `Schermo`, "the display" — which is the kind of mistake + /// only a speaker of the language catches, so the wording is pinned here. + #[test] + fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() { + let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered"); + for (source, translation) in [("Dodge", "Schiarisci"), ("Dodge Tool", "Strumento Schiarisci"), ("Screen", "Scherma")] { + assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); + } + // The qualified blend modes keep Adobe's "Scherma…" family: they are not the tool. + for (source, translation) in [("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] { + assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); + } + } + // Dynamic dialog labels generated by label_of() are not seen by the tl! scanner. #[test] fn color_lookup_export_scope_labels_are_translated() { diff --git a/docs/localization-it.md b/docs/localization-it.md index 7b0444410e..7a09598d4b 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -44,9 +44,18 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast | HDR Toning | Mappatura tonale HDR | tone mapping; *Viraggio* stays for colour toning (*Viraggio separato*) | | Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) | | Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* | +| Screen (blend mode) | Scherma | the blend mode; *Schermo* stays for the display (*Modalità schermo*, *Risoluzione schermo*) | +| Dodge / Dodge Tool | Schiarisci / Strumento Schiarisci | the *tools*, as *Lighten* already was; the qualified blend modes keep Adobe's family (*Colore scherma*, *Scherma lineare*) | ## Native review +2026-10-13: a second pass reviewed the blend-mode and tool names against Adobe's Italian +vocabulary and found the two words swapped: `Screen` was *Schermo* (the display) and `Dodge` / +`Dodge Tool` were *Scherma* / *Strumento Scherma*. Corrected to *Scherma*, *Schiarisci* and +*Strumento Schiarisci*, with *Color Dodge* / *Linear Dodge* (`Colore scherma`, `Scherma lineare`) +left alone: they are blend modes, not the tool, and they already used Adobe's wording. The +wording is now pinned by a test (`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`). + 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, bit depth labels). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c73eb094b..76ab9e844c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -85,3 +85,4 @@ engine localization or native review is complete. | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | +| 2026-10-13 | minor | Italian: the `Screen` blend mode and the `Dodge` tools had their names swapped (`Schermo`/`Scherma` → `Scherma`/`Schiarisci`); pinned by a test | From dde4b8dca8c46e28c0dc823954a9ac9a5161a217 Mon Sep 17 00:00:00 2001 From: Lorenzo Fasolino Date: Tue, 13 Oct 2026 16:15:28 +0200 Subject: [PATCH 0072/1452] docs(perf): how to measure one scenario and read its numbers Task T-0003 del lab artcraft-lab. File toccati: docs/development.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) Evidenze: artifacts/T-0003/ (nel lab, non in questo repo). --- docs/development.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/development.md b/docs/development.md index bb0de12690..49d7a1c444 100644 --- a/docs/development.md +++ b/docs/development.md @@ -332,6 +332,40 @@ different machine classes are never compared, and `--quick` numbers are only com quick baseline. Machines that run other work give noisy numbers: check the load average in the summary before trusting a regression, and record it next to any number you quote. +**Measuring one scenario.** A full run is tens of minutes; one row is seconds once its bench is +built. `xtask perf`'s own flags are `--quick`, `--update-baseline`, `--threshold`, `--bench` +(repeatable, one bench per name), `--skip-build` and `--reuse`; it has no way to pick a single +*row*. The bench binary takes those filters itself — `--only ` (row names are matched +with `contains`), `--group ` and `--reps N` — so to time one row run the bench directly: + +```sh +cargo run --release -p photocraft-ui-egui --example perf_scenarios -- \ + --only "Select > Modify > Smooth" --json target/perf/p26.json +``` + +That writes the row's own report and stops there: it does **not** evaluate the budget, so read +`p50_ms`/`p95_ms`/`max_ms` out of the JSON yourself. Use `cargo xtask perf --bench ` when you +want the verdict, the merged `results.json`/`summary.md` and the regression check. + +Read the sample count before quoting a percentile. `--reps` defaults to 7 in `--quick` and 15 +otherwise, it is per bench, and some rows cap it themselves (the `#211` algorithm rows use +`b.reps.min(5)`); percentiles are nearest-rank, so **at n ≤ 5 the "p95" is simply the maximum**. A +budgeted scenario the baseline has no row for fails the run (`missing` is in `Status::fails()`), +so add the `[[scenario]]` and its baseline row together. + +**A red `cargo xtask perf` is not always your budget.** The committed baseline carries +`failed.P12` (a wgpu panic writing `pc_compose_uniforms`), so the command exits non-zero before +any of this is about your change: read the row, not the exit code. And do not read the committed +verdicts as a clean-machine measurement: `perf/baseline.json` was written on 2026-10-05 at a load +average near 225 on a 14-core Apple M4 Pro, which is why P26 and P27 are over budget there while +`perf/budgets.toml` records 20–45 ms p95 for P26 on a clean 16-core machine (`enforce = false` +until CI cores are measured). Quote a number with the load average and the machine class beside it. + +**Flipping `enforce`.** `enforce = true` makes the nightly fail whenever that budget breaks, and a +baseline file belongs to exactly one machine class. Flip the row and refresh the baseline on the +nightly's own class in the same change, or `docs/scorecard.md` turns "BUDGET BROKEN" from numbers +nobody measured on that machine. + **Adding a scenario.** Add a row to a bench (keep its name stable: it is the key), give the bench `--json` support through `photocraft_testkit::perf::{row, report, write_report}`, then add a `[[scenario]]` to `perf/budgets.toml` and run `cargo xtask scorecard`. From b4664381ed1cc90cac548df2717f39c462ea0f2a Mon Sep 17 00:00:00 2001 From: Lorenzo Fasolino Date: Tue, 13 Oct 2026 16:15:28 +0200 Subject: [PATCH 0073/1452] chore(contributors): credit LorenzoFasolino as Lorenzo Fasolino Contributors entry for the About window: real name and display name. The public GitHub profile name is empty, so sync_github_name stays false rather than falling back to nothing. --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bbda6719ec..30ba09061c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -154,3 +154,8 @@ sync_github_name = true [people.VanTan666] display_name = "VanTan" sync_github_name = false + +[people.LorenzoFasolino] +real_name = "Lorenzo Fasolino" +display_name = "Lorenzo Fasolino" +sync_github_name = false From 08d14079bd487361a6974075c3bba7d17d37c546 Mon Sep 17 00:00:00 2001 From: Lin Heyang <850784156@qq.com> Date: Tue, 13 Oct 2026 23:48:38 +0800 Subject: [PATCH 0074/1452] i18n: translate missing Simplified Chinese tool labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Translate `Vertical Type Tool` as `直排文字工具` - Translate `Pencil Tool` as `铅笔工具` - Translate `Color Sampler Tool` as `颜色取样器工具` These labels previously fell back to English in the Simplified Chinese UI. --- crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fb6083985c..c98e956360 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1639,6 +1639,7 @@ Brush Pose 画笔姿态 Brush Tip Shape 画笔笔尖形状 Brush Tool 画笔工具 + Pencil Tool 铅笔工具 Build-up 累积 Burn Tool 加深工具 Centimeters 厘米 @@ -1693,6 +1694,7 @@ ExtraBold 特粗 ExtraLight 特细 Eyedropper Tool 吸管工具 + Color Sampler Tool 颜色取样器工具 Fade 渐隐 Fill Opacity 填充不透明度 Film & Video 电影与视频 @@ -1709,6 +1711,7 @@ Highlights from: 高光下限: History Brush Tool 历史记录画笔工具 Horizontal Type Tool 横排文字工具 + Vertical Type Tool 直排文字工具 Horizontal Type Mask Tool 横排文字蒙版工具 Vertical Type Mask Tool 直排文字蒙版工具 Image Size: 图像大小: From 7fc8b5d8b425ce6e3743020447f09c44108bd2cf Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:57:40 +0700 Subject: [PATCH 0075/1452] fix(i18n): translate pending screen capture message in all catalogs --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 20 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 178f3d183d..ce4d081834 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2346,6 +2346,7 @@ cameraRaw Color Barvy Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce Click to pick · Esc to cancel Kliknutí vybere · Esc zruší Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení + Preparing screen capture… Příprava snímku obrazovky… Scroll to zoom Rolováním přibližujte Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte Show/Hide All Panels Zobrazit/skrýt všechny panely diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 15a8f3d53d..3ca856faf5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2384,6 +2384,7 @@ Sample a pixel anywhere on the screen Ein Pixel an beliebiger Stelle auf dem Bildschirm auswählen Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen + Preparing screen capture… Bildschirmaufnahme wird vorbereitet… Scroll to zoom Zum Zoomen scrollen Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen Show/Hide All Panels Alle Bedienfelder ein-/ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 33ed8e1581..9f1ac0a096 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3524,6 +3524,7 @@ fontWeight Thin Πολύ λεπτά # Screen color picker (#1232) Pick screen color Επιλογή χρώματος οθόνης Pick a screen pixel or press Esc to cancel Επιλέξτε ένα εικονοστοιχείο της οθόνης ή πατήστε Esc για ακύρωση + Preparing screen capture… Προετοιμασία λήψης στιγμιότυπου οθόνης… Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4982af80ac..a4952caeab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2415,6 +2415,7 @@ cameraRaw Amount Cantidad Sample a pixel anywhere on the screen Tomar un píxel en cualquier lugar de la pantalla Click to pick · Esc to cancel Clic para elegir · Esc para cancelar Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar + Preparing screen capture… Preparando captura de pantalla… Scroll to zoom Desplace para ampliar Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar Show/Hide All Panels Mostrar/ocultar todos los paneles diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e29496ad5a..7d05dc66a9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2769,6 +2769,7 @@ cameraRaw Blending Fusion Sample a pixel anywhere on the screen Prélever un pixel n’importe où sur l’écran Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler + Preparing screen capture… Préparation de la capture d’écran… Scroll to zoom Défiler pour zoomer Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster Show/Hide All Panels Afficher/masquer tous les panneaux diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2f83eb910f..d962cc5e6a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3408,6 +3408,7 @@ cameraRaw Yellow Sárga PhotoCraft — Screen Color Picker PhotoCraft — Képernyő-színválasztó Photomerge Fotóegyesítés Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz + Preparing screen capture… Képernyőkép előkészítése… Pick screen color Szín kiválasztása a képernyőről Pick Color From Image Szín felvétele a képről Pins Tűk diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ba8ac96672..103e937b52 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2756,6 +2756,7 @@ cameraRaw Blending Pembauran Sample a pixel anywhere on the screen Ambil sampel piksel di mana saja pada layar Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan + Preparing screen capture… Menyiapkan tangkapan layar… Scroll to zoom Gulir untuk memperbesar Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dc52628d25..76290410dc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2418,6 +2418,7 @@ cameraRaw Amount Quantità Sample a pixel anywhere on the screen Campiona un pixel in qualsiasi punto dello schermo Click to pick · Esc to cancel Clic per scegliere · Esc per annullare Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare + Preparing screen capture… Preparazione dell’acquisizione dello schermo… Scroll to zoom Scorri per ingrandire Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare Show/Hide All Panels Mostra/nascondi tutti i pannelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9461dd82b3..672171b5d6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2319,6 +2319,7 @@ cameraRaw Color カラー Sample a pixel anywhere on the screen 画面上の任意のピクセルから色を取得 Click to pick · Esc to cancel クリックで選択 · Escでキャンセル Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル + Preparing screen capture… 画面キャプチャを準備しています… Scroll to zoom スクロールしてズーム Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Show/Hide All Panels すべてのパネルを表示/非表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f33aefd407..5597920fdd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2932,6 +2932,7 @@ cameraRaw Color 색상 Sample a pixel anywhere on the screen 화면의 아무 픽셀에서 색상 추출 Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소 Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소 + Preparing screen capture… 화면 캡처 준비 중… Scroll to zoom 스크롤하여 확대/축소 Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기 Show/Hide All Panels 모든 패널 표시/숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e5f74527d5..e62bd74a4d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2354,6 +2354,7 @@ Sample a pixel anywhere on the screen Een pixel ergens op het scherm bemonsteren Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren + Preparing screen capture… Schermafbeelding voorbereiden… Scroll to zoom Scrollen om te zoomen Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken Show/Hide All Panels Alle panelen tonen/verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8291a0a3b1..db8f0923e5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3359,6 +3359,7 @@ cameraRaw Roughness Chropowatość Sample a pixel anywhere on the screen Pobierz kolor piksela w dowolnym miejscu ekranu Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować + Preparing screen capture… Przygotowywanie zrzutu ekranu… Scroll to zoom Przewijaj, aby zmieniać powiększenie Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować Open in camera raw Otwórz w Camera Raw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 002d26806d..655193fb77 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2773,6 +2773,7 @@ cameraRaw Whites Brancos Sample a pixel anywhere on the screen Amostrar um pixel em qualquer lugar da tela Click to pick · Esc to cancel Clique para escolher · Esc para cancelar Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar + Preparing screen capture… Preparando captura de tela… Scroll to zoom Role para ampliar Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar Show/Hide All Panels Mostrar/ocultar todos os painéis diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9be736f717..3fe3d3ccf3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2616,6 +2616,7 @@ cameraRaw Roughness Неровность Sample a pixel anywhere on the screen Выберите пиксель в любом месте экрана Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены + Preparing screen capture… Подготовка снимка экрана… Scroll to zoom Колесо — изменить масштаб Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать Show/Hide All Panels Показать/скрыть все панели diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 412a2e958e..a275461413 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2757,6 +2757,7 @@ cameraRaw Blending Stapanje Sample a pixel anywhere on the screen Uzorkujte piksel bilo gde na ekranu Click to pick · Esc to cancel Kliknite da uzmete boju · Esc za otkazivanje Pick a screen pixel or press Esc to cancel Izaberite piksel na ekranu ili pritisnite Esc da otkažete + Preparing screen capture… Priprema snimka ekrana… Scroll to zoom Pomerajte točkić za zumiranje Show/Hide All Panels Prikaži/sakrij sve panele Show/Hide Panels Prikaži/sakrij panele diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 999bcc396b..9c58bd8f23 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2333,6 +2333,7 @@ Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın + Preparing screen capture… Ekran görüntüsü hazırlanıyor… Scroll to zoom Yakınlaştırmak için kaydırın Show/Hide All Panels Tüm Panelleri Göster/Gizle Show/Hide Panels Panelleri Göster/Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 72f4cffb71..1cf9d6b830 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2588,6 +2588,7 @@ cameraRaw Roughness Шорсткість Sample a pixel anywhere on the screen Взяти зразок пікселя будь-де на екрані Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування + Preparing screen capture… Підготовка знімка екрана… Scroll to zoom Прокрутіть для масштабування Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити Show/Hide All Panels Показати/приховати всі панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 590c4b87e6..1df1a7c562 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2146,6 +2146,7 @@ layers Fill: Độ tô: open source mã nguồn mở Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Vị trí bảng được lưu trong không gian làm việc. Phím tắt, menu và thanh công cụ là tùy chọn. Pick a screen pixel or press Esc to cancel Chọn một điểm ảnh trên màn hình hoặc nhấn Esc để hủy + Preparing screen capture… Đang chuẩn bị ảnh chụp màn hình… Position and Size Vị trí và kích thước Preserve Transparency Giữ độ trong suốt Preset Name Tên thiết lập sẵn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 50ba2bb7b8..889cf505bd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2354,6 +2354,7 @@ cameraRaw Blending 混合 Sample a pixel anywhere on the screen 在屏幕任意位置采样像素 Click to pick · Esc to cancel 单击选取 · Esc 取消 Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消 + Preparing screen capture… 正在准备屏幕截图… Scroll to zoom 滚动以缩放 Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口 Show/Hide All Panels 显示/隐藏所有面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ca23a7451b..3fc1ed28eb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2324,6 +2324,7 @@ cameraRaw Blending 混合 Sample a pixel anywhere on the screen 在螢幕任意位置取樣像素 Click to pick · Esc to cancel 按一下選取 · Esc 取消 Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消 + Preparing screen capture… 正在準備螢幕截圖… Scroll to zoom 捲動以縮放 Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗 Show/Hide All Panels 顯示/隱藏所有面板 From 8eef90bf7ede52a52ebf2f9b305a6980ddf4d3cf Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 17:56:23 +0200 Subject: [PATCH 0076/1452] Tests: generated large-file fixtures and a P20 number (#5107) photocraft_psd::fixtures (feature testgen) generates the FILE-216-2 fixtures at test time: a 20 MP layered PSD and a 150-layer A4 layout (groups, masks, hidden layers, drop shadows, known merged colours), a 2.02 GB PSD just under Photoshop's cap and the 4-5 GB PSBs, streamed row by row. io tests/large_files.rs opens, checks, saves and re-opens each (the 2 GB PSD and 4.38 GB PSB are opt-in). The ui-egui large_file_bench times opening the big two, wired to P20 and a new P60. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/examples/big_psb_check.rs | 10 +- crates/io/tests/large_files.rs | 166 ++++++ crates/psd/examples/big_psb.rs | 182 +------ crates/psd/src/fixtures.rs | 566 ++++++++++++++++++++ crates/psd/src/lib.rs | 2 + crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/examples/large_file_bench.rs | 125 +++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 6 +- docs/scorecard.md | 9 +- perf/budgets.toml | 21 +- scorecard/file_compat.toml | 4 +- 12 files changed, 908 insertions(+), 193 deletions(-) create mode 100644 crates/io/tests/large_files.rs create mode 100644 crates/psd/src/fixtures.rs create mode 100644 crates/ui-egui/examples/large_file_bench.rs diff --git a/crates/io/examples/big_psb_check.rs b/crates/io/examples/big_psb_check.rs index 9b1eb80d47..3d0f407583 100644 --- a/crates/io/examples/big_psb_check.rs +++ b/crates/io/examples/big_psb_check.rs @@ -1,14 +1,10 @@ //! Opens the files written by `photocraft-psd`'s `big_psb` example and checks their pixels. //! //! `cargo run --release -p photocraft-io --example big_psb_check -- ...` +//! +//! The opt-in tests in `tests/large_files.rs` generate and check the same files. -fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 { - if c == 3 { - return 255; - } - let x = if rle { 0 } else { x }; - (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8 -} +use photocraft_psd::fixtures::sample; fn main() { for path in std::env::args().skip(1) { diff --git a/crates/io/tests/large_files.rs b/crates/io/tests/large_files.rs new file mode 100644 index 0000000000..f492dd2d65 --- /dev/null +++ b/crates/io/tests/large_files.rs @@ -0,0 +1,166 @@ +//! Large-file fixtures (#216, #5107), generated at test time by `photocraft_psd::fixtures`: a 20 MP +//! layered PSD, a 150-layer layout, a PSD just under Photoshop's 2 GB cap and a 4.4 GB PSB. Each +//! is opened through `photocraft_io`, checked (size, layers, masks, effects, merged pixels), saved +//! again and re-opened. +//! +//! The two layered documents run in memory; the streamed files are written to +//! `$PHOTOCRAFT_FIXTURE_DIR` (default: the system temp dir), removed afterwards, and are opt-in: +//! `cargo test --release -p photocraft-io --test large_files -- --ignored`. + +use std::path::PathBuf; + +use photocraft_doc::{Document, LayerContent}; +use photocraft_geom::Rect; +use photocraft_psd::Version; +use photocraft_psd::fixtures::{Grid, LAYERED_20MP, LAYOUT_150, PSB_4GB, PSD_2GB, Streamed}; + +/// Raster layers, groups, masked layers, layers with effects and hidden layers in `doc`. +fn census(doc: &Document) -> [usize; 5] { + let mut n = [0; 5]; + for (_, _, l) in doc.walk() { + match l.content { + LayerContent::Raster(_) => n[0] += 1, + LayerContent::Group(_) => n[1] += 1, + _ => {} + } + n[2] += usize::from(l.mask.is_some()); + n[3] += usize::from(!l.effects.items.is_empty()); + n[4] += usize::from(!l.visible); + } + n +} + +fn check_grid(g: &Grid, doc: &Document, what: &str) { + assert_eq!((doc.size.width, doc.size.height), (g.width, g.height), "{what}"); + let layers: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect(); + let want = [ + g.layers as usize + 1, + g.rows() as usize, + layers.iter().filter(|l| l.masked).count(), + layers.iter().filter(|l| l.shadow).count(), + layers.iter().filter(|l| l.hidden).count(), + ]; + assert_eq!(census(doc), want, "{what}: [raster, groups, masked, with effects, hidden]"); + for (x, y) in g.sample_points() { + let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).px; + let got = px.first().copied().unwrap_or_default(); + let want = g.expected(x, y); + for c in 0..3 { + let v = (got[c] * 255.0).round() as i32; + assert!((v - i32::from(want[c])).abs() <= 1, "{what}: ({x}, {y}) channel {c}: got {v}, want {}", want[c]); + } + assert!(got[3] > 0.999, "{what}: ({x}, {y}) is not opaque"); + } +} + +/// Builds `g`, opens it, checks it, saves it as PSD and checks the re-opened file. +fn grid_roundtrip(g: Grid, what: &str) { + let t = std::time::Instant::now(); + let bytes = g.build(Version::Psd).and_then(|f| f.to_bytes()).unwrap(); + let gen_s = t.elapsed().as_secs_f64(); + let t = std::time::Instant::now(); + let res = photocraft_io::import("fixture.psd", &bytes).unwrap(); + let open_s = t.elapsed().as_secs_f64(); + check_grid(&g, &res.document, what); + let out = photocraft_io::export(&res.document, "fixture.psd", &Default::default()).unwrap(); + let again = photocraft_io::import("fixture.psd", &out.bytes).unwrap(); + check_grid(&g, &again.document, &format!("{what}, saved and re-opened")); + eprintln!( + "{what}: {:.1} MB, generated in {gen_s:.2} s, opened in {open_s:.2} s, saved as {:.1} MB", + bytes.len() as f64 / 1e6, + out.bytes.len() as f64 / 1e6 + ); +} + +/// 150 layers in 15 groups on A4 at 300 ppi, with masks, hidden layers, closed groups and drop +/// shadows. +#[test] +fn layout_with_150_layers_opens_and_saves() { + grid_roundtrip(LAYOUT_150, "150-layer layout"); +} + +/// A 20 MP layered photo: background, five layers in two groups, a mask and a drop shadow. +#[test] +fn layered_20_mp_psd_opens_and_saves() { + grid_roundtrip(LAYERED_20MP, "20 MP layered"); +} + +fn fixture_dir() -> PathBuf { + let dir = std::env::var_os("PHOTOCRAFT_FIXTURE_DIR").map_or_else(std::env::temp_dir, PathBuf::from).join("photocraft-large-files"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Removes the file when the test ends, passed or not. +struct Remove(PathBuf); + +impl Drop for Remove { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn check_streamed(case: &Streamed, doc: &Document, what: &str) { + assert_eq!((doc.size.width, doc.size.height), (case.width, case.height), "{what}"); + assert_eq!(doc.layers.len(), 1, "{what}"); + let s = doc.layers[0].surface().unwrap(); + let (w, h) = (case.width, case.height); + for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), (w / 2, h / 2), (w / 3, h * 2 / 3), (12_345 % w, h - 7)] { + let got = s.pixel(x as i32, y as i32); + assert_eq!(got.len(), 4, "{what}"); + for (c, v) in got.iter().enumerate() { + let want = f32::from(case.sample(x, y, c as u32)) / 255.0; + assert!((v - want).abs() <= 1.0 / 510.0, "{what}: ({x}, {y}) channel {c}: got {v}, want {want}"); + } + } +} + +/// Writes `case`, opens it, checks it, saves it again (the writer picks PSD or PSB) and checks the +/// re-opened file. +fn streamed_roundtrip(case: &Streamed) { + let path = fixture_dir().join(case.file_name()); + let _cleanup = Remove(path.clone()); + let t = std::time::Instant::now(); + let len = case.write_file(&path).unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().len(), len); + eprintln!("{}: {:.2} GB written in {:.1} s", case.name, len as f64 / 1e9, t.elapsed().as_secs_f64()); + + let t = std::time::Instant::now(); + let bytes = std::fs::read(&path).unwrap(); + let res = photocraft_io::import(&case.file_name(), &bytes).unwrap(); + drop(bytes); + eprintln!("{}: read and opened in {:.1} s, warnings {:?}", case.name, t.elapsed().as_secs_f64(), res.warnings); + check_streamed(case, &res.document, case.name); + + let t = std::time::Instant::now(); + let out = photocraft_io::export(&res.document, &case.file_name(), &Default::default()).unwrap(); + drop(res); + // Version 2 = PSB: a re-save past 2 GB must switch to PSB; a smaller one stays PSD. + let psb = out.bytes.get(4..6) == Some(&[0, 2]); + assert!(psb || (case.version == Version::Psd && (out.bytes.len() as u64) < photocraft_psd::fixtures::PSD_MAX_FILE_BYTES), "{}", case.name); + eprintln!( + "{}: saved {:.2} GB ({}) in {:.1} s, warnings {:?}", + case.name, + out.bytes.len() as f64 / 1e9, + if psb { "PSB" } else { "PSD" }, + t.elapsed().as_secs_f64(), + out.warnings + ); + let again = photocraft_io::import(if psb { "again.psb" } else { "again.psd" }, &out.bytes).unwrap(); + drop(out); + check_streamed(case, &again.document, &format!("{}, saved and re-opened", case.name)); +} + +/// A 17,000² layered PSD of 2.02 GB, just under Photoshop's 2 GB PSD cap. +#[test] +#[ignore = "writes a 2 GB file and needs ~12 GB of memory; run in release"] +fn psd_just_under_2_gb_opens_and_saves() { + streamed_roundtrip(&PSD_2GB); +} + +/// A 25,000² layered PSB of 4.38 GB: layer channels of 625 MB, data past 4 GiB (#375). +#[test] +#[ignore = "writes a 4.4 GB file and needs ~25 GB of memory; run in release"] +fn psb_of_4_gb_opens_and_saves() { + streamed_roundtrip(&PSB_4GB); +} diff --git a/crates/psd/examples/big_psb.rs b/crates/psd/examples/big_psb.rs index 332936c1e5..23bf4de776 100644 --- a/crates/psd/examples/big_psb.rs +++ b/crates/psd/examples/big_psb.rs @@ -1,186 +1,24 @@ -//! Writes multi-gigabyte PSB files for large-file tests (#375, #216), straight from the Adobe -//! PSD spec and independent of this crate's writer, so the fixtures don't inherit its limits. +//! Writes multi-gigabyte PSB files (and the 2 GB PSD) for large-file tests (#375, #216), straight +//! from the Adobe PSD spec and independent of this crate's writer, so the fixtures don't inherit +//! its limits. The generator is `photocraft_psd::fixtures` (feature `testgen`). //! //! `cargo run --release -p photocraft-psd --example big_psb -- [case...]` //! -//! Every sample follows [`sample`], so a reader can check any pixel without the file. RLE cases -//! keep each row constant (tiny files, full decoded size); raw cases vary along x too. +//! Every sample follows `fixtures::sample`, so a reader can check any pixel without the file. RLE +//! cases keep each row constant (tiny files, full decoded size); raw cases vary along x too. -use std::fs::File; -use std::io::{self, BufWriter, Write}; -use std::path::Path; +use photocraft_psd::fixtures::{PSB_CASES, PSD_2GB}; -/// One generated file. -struct Case { - name: &'static str, - width: u32, - height: u32, - depth: u16, - /// Raw (0) or RLE (1). - rle: bool, - /// One full-canvas RGBA layer, or a flattened file (composite only). - layered: bool, -} - -const CASES: &[Case] = &[ - // Composite of 2.52 GB in one decode. - Case { name: "flat8-29k-rle", width: 29_000, height: 29_000, depth: 8, rle: true, layered: false }, - // A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB. - Case { name: "flat8-40k-raw", width: 40_000, height: 40_000, depth: 8, rle: false, layered: false }, - // 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB. - Case { name: "layer8-25k-raw", width: 25_000, height: 25_000, depth: 8, rle: false, layered: true }, - // Layer channels of 900 MB, composite of 2.7 GB. - Case { name: "layer8-30k-rle", width: 30_000, height: 30_000, depth: 8, rle: true, layered: true }, - // Layer channels of 2.18 GB each. - Case { name: "layer16-33k-rle", width: 33_000, height: 33_000, depth: 16, rle: true, layered: true }, -]; - -/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`). RLE files ignore `x`. -/// 16-bit files store `v * 257`. -pub fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 { - if c == 3 { - return 255; - } - let x = if rle { 0 } else { x }; - (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8 -} - -fn main() -> io::Result<()> { +fn main() -> std::io::Result<()> { let args: Vec = std::env::args().skip(1).collect(); let out = args.first().map_or_else(|| std::env::temp_dir().join("photocraft-big-psb"), std::path::PathBuf::from); std::fs::create_dir_all(&out)?; let wanted = args.get(1..).unwrap_or(&[]); - for case in CASES.iter().filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) { - let path = out.join(format!("{}.psb", case.name)); + for case in PSB_CASES.iter().chain([&PSD_2GB]).filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) { + let path = out.join(case.file_name()); let t = std::time::Instant::now(); - write_case(case, &path)?; - let len = std::fs::metadata(&path)?.len(); + let len = case.write_file(&path)?; println!("{}: {:.2} GB in {:.1} s", path.display(), len as f64 / 1e9, t.elapsed().as_secs_f64()); } Ok(()) } - -fn row_bytes(c: &Case) -> u64 { - u64::from(c.width) * u64::from(c.depth / 8) -} - -/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder. -fn rle_row(n: u64, b: u8) -> Vec { - let mut v = Vec::new(); - let mut left = n; - while left > 0 { - let run = left.min(128); - if run == 1 { - v.extend_from_slice(&[0, b]); - } else { - v.extend_from_slice(&[(257 - run) as u8, b]); - } - left -= run; - } - v -} - -/// The bytes of one row of channel `ch`. -fn row(c: &Case, y: u32, ch: u32, buf: &mut Vec) { - buf.clear(); - if c.rle { - buf.extend(rle_row(row_bytes(c), sample(0, y, ch, true))); - return; - } - for x in 0..c.width { - let s = sample(x, y, ch, false); - buf.push(s); - if c.depth == 16 { - buf.push(s); - } - } -} - -/// Size of a channel block (compression word, RLE counts, rows) of `planes` planes. -fn channel_len(c: &Case, planes: u64) -> u64 { - let rows = planes * u64::from(c.height); - if c.rle { 2 + rows * 4 + rows * rle_row(row_bytes(c), 0).len() as u64 } else { 2 + rows * row_bytes(c) } -} - -/// Writes the compression word, the counts and the rows of `chans`. -fn write_planes(w: &mut impl Write, c: &Case, chans: &[u32]) -> io::Result<()> { - w.write_all(&u16::from(c.rle).to_be_bytes())?; - if c.rle { - let n = rle_row(row_bytes(c), 0).len() as u32; - for _ in 0..chans.len() as u64 * u64::from(c.height) { - w.write_all(&n.to_be_bytes())?; - } - } - let mut buf = Vec::new(); - for &ch in chans { - for y in 0..c.height { - row(c, y, ch, &mut buf); - w.write_all(&buf)?; - } - } - Ok(()) -} - -fn write_case(c: &Case, path: &Path) -> io::Result<()> { - let mut w = BufWriter::with_capacity(8 << 20, File::create(path)?); - // Header: PSB (version 2), 3 channels, RGB. - w.write_all(b"8BPS")?; - w.write_all(&2u16.to_be_bytes())?; - w.write_all(&[0; 6])?; - w.write_all(&3u16.to_be_bytes())?; - w.write_all(&c.height.to_be_bytes())?; - w.write_all(&c.width.to_be_bytes())?; - w.write_all(&c.depth.to_be_bytes())?; - w.write_all(&3u16.to_be_bytes())?; - // Colour mode data and image resources: empty. - w.write_all(&0u32.to_be_bytes())?; - w.write_all(&0u32.to_be_bytes())?; - - if c.layered { - // Transparency (-1), then R, G, B: one block per channel. - let ids: [(i16, u32); 4] = [(-1, 3), (0, 0), (1, 1), (2, 2)]; - let ch_len = channel_len(c, 1); - let name = b"Layer 1"; - let mut rec = Vec::new(); - for v in [0i32, 0, c.height as i32, c.width as i32] { - rec.extend_from_slice(&v.to_be_bytes()); - } - rec.extend_from_slice(&4u16.to_be_bytes()); - for (id, _) in ids { - rec.extend_from_slice(&id.to_be_bytes()); - rec.extend_from_slice(&ch_len.to_be_bytes()); - } - rec.extend_from_slice(b"8BIMnorm"); - rec.extend_from_slice(&[255, 0, 0, 0]); - // Extra data: no mask, no blending ranges, the Pascal name padded to 4. - let mut extra = Vec::new(); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.push(name.len() as u8); - extra.extend_from_slice(name); - while extra.len() % 4 != 0 { - extra.push(0); - } - rec.extend_from_slice(&(extra.len() as u32).to_be_bytes()); - rec.extend_from_slice(&extra); - - let mut layer_info = 2 + rec.len() as u64 + 4 * ch_len; - let pad = layer_info % 2; - layer_info += pad; - // Layer and mask information: layer info (8-byte length) + empty global mask (4 bytes). - w.write_all(&(8 + layer_info + 4).to_be_bytes())?; - w.write_all(&layer_info.to_be_bytes())?; - w.write_all(&1i16.to_be_bytes())?; - w.write_all(&rec)?; - for (_, ch) in ids { - write_planes(&mut w, c, &[ch])?; - } - w.write_all(&vec![0; pad as usize])?; - w.write_all(&0u32.to_be_bytes())?; - } else { - w.write_all(&0u64.to_be_bytes())?; - } - // The merged image. - write_planes(&mut w, c, &[0, 1, 2])?; - w.flush() -} diff --git a/crates/psd/src/fixtures.rs b/crates/psd/src/fixtures.rs new file mode 100644 index 0000000000..a8bd3d43c9 --- /dev/null +++ b/crates/psd/src/fixtures.rs @@ -0,0 +1,566 @@ +//! Large-file stress fixtures (#216, #375, #5107), generated at test time and never committed. +//! +//! Two kinds: +//! +//! * [`Streamed`]: multi-gigabyte PSD/PSB files written straight from the Adobe PSD spec, row by +//! row, independent of [`PsdBuilder`] (so they don't inherit its limits, and the generator needs +//! a few megabytes of memory however large the file). One full-canvas layer (or none) plus the +//! merged image; every sample follows [`sample`], so a reader can check any pixel without the +//! file. [`PSD_2GB`] is the largest file Photoshop saves as PSD; [`PSB_CASES`] are 2.5–5 GB. +//! * [`Grid`]: layered documents built with [`PsdBuilder`]: a background and a grid of layers in +//! groups, with masks, a hidden layer, a closed group and drop shadows. [`LAYERED_20MP`] is the +//! 20 MP photo-sized document, [`LAYOUT_150`] the 150-layer A4 layout. [`Grid::expected`] is +//! the merged colour at any point away from the shadows ([`Grid::sample_points`] lists some). +//! +//! Photoshop's limits, which the presets respect: a PSD is at most 30,000 px a side and 2 GB; a +//! PSB at most 300,000 px a side. + +use std::io::{self, Write}; + +use crate::builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder}; +use crate::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; +use crate::error::Result; +use crate::file::PsdFile; +use crate::header::Version; +use crate::layer::Rect; +use crate::tagged::TaggedBlock; + +/// Photoshop's PSD size cap: files of 2 GB or more must be PSB. +pub const PSD_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Photoshop's PSD side cap (px). +pub const PSD_MAX_SIDE: u32 = 30_000; + +/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`) in a [`Streamed`] file. +/// RLE files ignore `x` (constant rows compress to almost nothing). 16-bit files store `v * 257`. +pub fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 { + if c == 3 { + return 255; + } + let x = if rle { 0 } else { x }; + (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8 +} + +/// A large RGB file written row by row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Streamed { + /// File stem, e.g. `layer8-25k-raw`. + pub name: &'static str, + /// PSD (v1) or PSB (v2). + pub version: Version, + /// Width (px). + pub width: u32, + /// Height (px). + pub height: u32, + /// 8 or 16. + pub depth: u16, + /// RLE (PackBits) or raw. + pub rle: bool, + /// One full-canvas RGBA layer, or a flattened file (merged image only). + pub layered: bool, +} + +const fn psb(name: &'static str, side: u32, depth: u16, rle: bool, layered: bool) -> Streamed { + Streamed { name, version: Version::Psb, width: side, height: side, depth, rle, layered } +} + +/// A 2.02 GB layered PSD just under Photoshop's 2 GB cap: 17,000² px, one raw full-canvas layer +/// (289 MB per channel) and the raw merged image. +pub const PSD_2GB: Streamed = + Streamed { name: "psd-layer8-17k-raw", version: Version::Psd, width: 17_000, height: 17_000, depth: 8, rle: false, layered: true }; + +/// The 4–5 GB PSB generated for #375 and #216. +pub const PSB_CASES: [Streamed; 5] = [ + // Composite of 2.52 GB in one decode. + psb("flat8-29k-rle", 29_000, 8, true, false), + // A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB. + psb("flat8-40k-raw", 40_000, 8, false, false), + // 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB. + PSB_4GB, + // Layer channels of 900 MB, composite of 2.7 GB. + psb("layer8-30k-rle", 30_000, 8, true, true), + // Layer channels of 2.18 GB each. + psb("layer16-33k-rle", 33_000, 16, true, true), +]; + +/// The layered 4.38 GB PSB (25,000² px, raw): layer data and the merged image past 4 GiB. +pub const PSB_4GB: Streamed = psb("layer8-25k-raw", 25_000, 8, false, true); + +/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder. +fn rle_row(n: u64, b: u8) -> Vec { + let mut v = Vec::new(); + let mut left = n; + while left > 0 { + let run = left.min(128); + // `run` is 1..=128, so 257 - run is 129..=256 and only 256 (run 1) needs the literal form. + if run == 1 { + v.extend_from_slice(&[0, b]); + } else { + v.extend_from_slice(&[(257 - run) as u8, b]); + } + left -= run; + } + v +} + +fn too_big(what: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, format!("{what} does not fit a PSD (v1) length field")) +} + +impl Streamed { + /// File name with the right extension. + pub fn file_name(&self) -> String { + format!("{}.{}", self.name, if self.version == Version::Psb { "psb" } else { "psd" }) + } + + fn psb(&self) -> bool { + self.version == Version::Psb + } + + fn row_bytes(&self) -> u64 { + u64::from(self.width) * u64::from(self.depth / 8) + } + + /// Bytes of one RLE row (all rows have the same length). + fn rle_len(&self) -> u64 { + rle_row(self.row_bytes(), 0).len() as u64 + } + + /// Size of a channel block (compression word, RLE counts, rows) of `planes` planes. + fn channel_len(&self, planes: u64) -> u64 { + let rows = planes * u64::from(self.height); + let count = if self.psb() { 4 } else { 2 }; + if self.rle { 2 + rows * count + rows * self.rle_len() } else { 2 + rows * self.row_bytes() } + } + + /// Writes a length field: 8 bytes in PSB, 4 in PSD. + fn put_len(&self, out: &mut Vec, v: u64, what: &str) -> io::Result<()> { + if self.psb() { + out.extend_from_slice(&v.to_be_bytes()); + } else { + out.extend_from_slice(&u32::try_from(v).map_err(|_| too_big(what))?.to_be_bytes()); + } + Ok(()) + } + + /// The layer record of the one layer: transparency (-1), then R, G, B. + fn layer_record(&self) -> io::Result> { + let ch_len = self.channel_len(1); + let name = b"Layer 1"; + let mut rec = Vec::new(); + for v in [0, 0, self.height, self.width] { + rec.extend_from_slice(&v.to_be_bytes()); + } + rec.extend_from_slice(&4u16.to_be_bytes()); + for id in [-1i16, 0, 1, 2] { + rec.extend_from_slice(&id.to_be_bytes()); + self.put_len(&mut rec, ch_len, "a layer channel")?; + } + rec.extend_from_slice(b"8BIMnorm"); + rec.extend_from_slice(&[255, 0, 0, 0]); + // Extra data: no mask, no blending ranges, the Pascal name padded to 4. + let mut extra = Vec::new(); + extra.extend_from_slice(&0u32.to_be_bytes()); + extra.extend_from_slice(&0u32.to_be_bytes()); + extra.push(name.len() as u8); + extra.extend_from_slice(name); + while extra.len() % 4 != 0 { + extra.push(0); + } + rec.extend_from_slice(&(extra.len() as u32).to_be_bytes()); + rec.extend_from_slice(&extra); + Ok(rec) + } + + /// The header, the empty colour-mode and resource sections and the layer section's head + /// (everything before the first channel's data), plus the padding after the layer channels. + fn head(&self) -> io::Result<(Vec, u64)> { + let mut h = Vec::new(); + h.extend_from_slice(b"8BPS"); + h.extend_from_slice(&(if self.psb() { 2u16 } else { 1 }).to_be_bytes()); + h.extend_from_slice(&[0; 6]); + h.extend_from_slice(&3u16.to_be_bytes()); + h.extend_from_slice(&self.height.to_be_bytes()); + h.extend_from_slice(&self.width.to_be_bytes()); + h.extend_from_slice(&self.depth.to_be_bytes()); + h.extend_from_slice(&3u16.to_be_bytes()); + // Colour mode data and image resources: empty. + h.extend_from_slice(&0u32.to_be_bytes()); + h.extend_from_slice(&0u32.to_be_bytes()); + if !self.layered { + self.put_len(&mut h, 0, "the layer section")?; + return Ok((h, 0)); + } + let rec = self.layer_record()?; + let mut layer_info = 2 + rec.len() as u64 + 4 * self.channel_len(1); + let pad = layer_info % 2; + layer_info += pad; + let len_field = if self.psb() { 8 } else { 4 }; + // Layer and mask information: layer info (with its length) + empty global mask (4 bytes). + self.put_len(&mut h, len_field + layer_info + 4, "the layer section")?; + self.put_len(&mut h, layer_info, "the layer info")?; + h.extend_from_slice(&1i16.to_be_bytes()); + h.extend_from_slice(&rec); + Ok((h, pad)) + } + + /// The exact size of the file [`write_to`](Self::write_to) writes, without writing it. + pub fn file_len(&self) -> io::Result { + let (head, pad) = self.head()?; + let layers = if self.layered { 4 * self.channel_len(1) + pad + 4 } else { 0 }; + Ok(head.len() as u64 + layers + self.channel_len(3)) + } + + /// The bytes of one row of channel `ch`. + fn row(&self, y: u32, ch: u32, buf: &mut Vec) { + buf.clear(); + if self.rle { + buf.extend(rle_row(self.row_bytes(), sample(0, y, ch, true))); + return; + } + for x in 0..self.width { + let s = sample(x, y, ch, false); + buf.push(s); + if self.depth == 16 { + buf.push(s); + } + } + } + + /// Writes the compression word, the counts and the rows of `chans`. + fn write_planes(&self, w: &mut impl Write, chans: &[u32]) -> io::Result<()> { + w.write_all(&u16::from(self.rle).to_be_bytes())?; + if self.rle { + let n = self.rle_len(); + let one = if self.psb() { + u32::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() + } else { + u16::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() + }; + for _ in 0..chans.len() as u64 * u64::from(self.height) { + w.write_all(&one)?; + } + } + let mut buf = Vec::new(); + for &ch in chans { + for y in 0..self.height { + self.row(y, ch, &mut buf); + w.write_all(&buf)?; + } + } + Ok(()) + } + + /// Streams the file into `w` (buffer it). Returns the bytes written. A PSD whose lengths + /// don't fit its 32-bit fields is refused before anything is written. + pub fn write_to(&self, w: &mut impl Write) -> io::Result { + let len = self.file_len()?; + let (head, pad) = self.head()?; + w.write_all(&head)?; + if self.layered { + // Transparency, then R, G, B. + for ch in [3, 0, 1, 2] { + self.write_planes(w, &[ch])?; + } + w.write_all(&vec![0; pad as usize])?; + w.write_all(&0u32.to_be_bytes())?; + } + self.write_planes(w, &[0, 1, 2])?; + w.flush()?; + Ok(len) + } + + /// Writes the file to `path` (created or truncated). Returns its size. + #[cfg(not(target_arch = "wasm32"))] + pub fn write_file(&self, path: &std::path::Path) -> io::Result { + let mut w = io::BufWriter::with_capacity(8 << 20, std::fs::File::create(path)?); + self.write_to(&mut w) + } + + /// The 8-bit value of channel `c` (3 = alpha) at (`x`, `y`): the layer's and the merged + /// image's (they are the same). + pub fn sample(&self, x: u32, y: u32, c: u32) -> u8 { + sample(x, y, c, self.rle) + } +} + +/// A layered document: a background and a grid of layers, one group per grid row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Grid { + /// Canvas width (px). + pub width: u32, + /// Canvas height (px). + pub height: u32, + /// Layers in the grid (the background is extra). + pub layers: u32, + /// Grid columns; a group per row of `cols` layers. + pub cols: u32, +} + +/// A 20 MP (5472 × 3648) layered photo: background, 5 layers in 2 groups (one closed), a +/// mask and a drop shadow. +pub const LAYERED_20MP: Grid = Grid { width: 5472, height: 3648, layers: 5, cols: 3 }; + +/// A 150-layer A4 layout at 300 ppi (2480 × 3508): background + 149 layers in 15 groups, with +/// masks, hidden layers, closed groups and drop shadows. +pub const LAYOUT_150: Grid = Grid { width: 2480, height: 3508, layers: 149, cols: 10 }; + +/// What a grid layer carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GridLayer { + /// Bounds in document coordinates. + pub rect: Rect, + /// Base colour; the layer adds its local x to it (a horizontal ramp). + pub base: [u8; 3], + /// Has a mask showing only the left half. + pub masked: bool, + /// Hidden. + pub hidden: bool, + /// Has a drop shadow (`lfx2`). + pub shadow: bool, +} + +/// The `lfx2` descriptor of a drop shadow: multiply, black, 75 %, 120°, distance 5, size 5. +fn drop_shadow() -> Vec { + let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; + let color = Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + let shadow = Descriptor::new("DrSh") + .with("enab", Value::Boolean(true)) + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("Clr ", Value::Descriptor(color)) + .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.0 }) + .with("uglg", Value::Boolean(false)) + .with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 }) + .with("Dstn", px(5.0)) + .with("Ckmt", px(0.0)) + .with("blur", px(5.0)); + let root = Descriptor::new("null") + .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }) + .with("masterFXSwitch", Value::Boolean(true)) + .with("DrSh", Value::Descriptor(shadow)); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor::new(root).to_bytes()); + data +} + +impl Grid { + /// Grid rows (= groups). + pub fn rows(&self) -> u32 { + self.layers.div_ceil(self.cols.max(1)) + } + + fn cell(&self) -> (u32, u32) { + (self.width / self.cols.max(1), self.height / self.rows().max(1)) + } + + /// Gap between a layer and its cell's edge (larger than any shadow's reach). + fn inset(&self) -> u32 { + let (cw, ch) = self.cell(); + (cw.min(ch) / 8).max(1) + } + + /// Layer `i` of the grid (0-based, bottom to top), or `None` past the end. + pub fn layer(&self, i: u32) -> Option { + if i >= self.layers { + return None; + } + let (cw, ch) = self.cell(); + let inset = self.inset(); + let (col, row) = (i % self.cols.max(1), i / self.cols.max(1)); + let rect = + Rect::from_xywh((col * cw + inset) as i32, (row * ch + inset) as i32, cw.saturating_sub(2 * inset).max(1), ch.saturating_sub(2 * inset).max(1)); + let masked = i % 5 == 1; + let hidden = i % 13 == 12; + Some(GridLayer { + rect, + base: [(i * 37 + 20) as u8, (i * 91 + 50) as u8, (i * 53 + 90) as u8], + masked, + hidden, + shadow: i % 7 == 3 && !masked && !hidden, + }) + } + + /// Name of grid layer `i`. + pub fn layer_name(i: u32) -> String { + format!("Layer {}", i + 1) + } + + /// Name of the group of grid row `r`. + pub fn group_name(r: u32) -> String { + format!("Row {}", r + 1) + } + + /// Groups of rows `r % 4 == 3` are closed in the layers panel. + pub fn group_open(r: u32) -> bool { + r % 4 != 3 + } + + /// The background's colour at (`x`, `y`): [`sample`] in runs of 16 px (RLE has runs to find). + pub fn background(x: u32, y: u32) -> [u8; 3] { + [0, 1, 2].map(|c| sample(x / 16, y, c, false)) + } + + /// Grid layer `i`'s pixel colour at document (`x`, `y`), ignoring visibility and mask. + fn layer_color(l: &GridLayer, x: u32) -> [u8; 3] { + let lx = x.wrapping_sub(l.rect.left as u32); + l.base.map(|b| (u32::from(b) + lx) as u8) + } + + /// The merged colour at (`x`, `y`). Exact except within a few pixels of a shadowed layer's + /// edges (the merged image this generator stores leaves shadows out). + pub fn expected(&self, x: u32, y: u32) -> [u8; 3] { + let (cw, ch) = self.cell(); + let (col, row) = (x / cw.max(1), y / ch.max(1)); + if col < self.cols + && let Some(l) = self.layer(row * self.cols + col) + { + let (xi, yi) = (x as i32, y as i32); + let inside = xi >= l.rect.left && xi < l.rect.right && yi >= l.rect.top && yi < l.rect.bottom; + let shown = !l.masked || i64::from(xi) < i64::from(l.rect.left) + l.rect.width() / 2; + if inside && shown && !l.hidden { + return Self::layer_color(&l, x); + } + } + Self::background(x, y) + } + + /// Points to check, away from every shadow: in each of a few layers, its shown half, its + /// masked-out half, and the gap at its cell's corner. + pub fn sample_points(&self) -> Vec<(u32, u32)> { + let step = (self.layers / 12).max(1); + let mut v = vec![(0, 0), (self.width - 1, self.height - 1)]; + for i in (0..self.layers).step_by(step as usize) { + let Some(l) = self.layer(i) else { continue }; + let (w, h) = (l.rect.width() as u32, l.rect.height() as u32); + let (x0, y0) = (l.rect.left as u32, l.rect.top as u32); + v.push((x0 + w / 4, y0 + h / 2)); + v.push((x0 + w * 3 / 4, y0 + h / 2)); + v.push((x0 - self.inset() / 2, y0 - self.inset() / 2)); + } + v + } + + /// Builds the file (8-bit RGB, RLE): layers bottom to top, the merged image from + /// [`expected`](Self::expected). + pub fn build(&self, version: Version) -> Result { + let (w, h) = (self.width, self.height); + let mut b = PsdBuilder::new(w, h).version(version); + let mut bg = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + bg.extend(Self::background(x, y)); + bg.push(255); + } + } + b.push_layer(LayerSpec::new("Background", 0, 0, w, h, PixelData::Rgba8(bg))); + for row in 0..self.rows() { + b.begin_group(GroupSpec { open: Self::group_open(row), ..GroupSpec::new(Self::group_name(row)) }); + for i in row * self.cols..((row + 1) * self.cols).min(self.layers) { + let Some(l) = self.layer(i) else { continue }; + let (lw, lh) = (l.rect.width() as u32, l.rect.height() as u32); + let mut px = Vec::with_capacity(lw as usize * lh as usize * 4); + for _ in 0..lh { + for lx in 0..lw { + px.extend(Self::layer_color(&l, l.rect.left as u32 + lx)); + px.push(255); + } + } + let mut spec = LayerSpec::new(Self::layer_name(i), l.rect.left, l.rect.top, lw, lh, PixelData::Rgba8(px)); + spec.visible = !l.hidden; + if l.masked { + let data = (0..lh).flat_map(|_| (0..lw).map(move |x| if x < lw / 2 { 255 } else { 0 })).collect(); + spec.mask = Some(MaskSpec { rect: l.rect, data, default_color: 0, disabled: false }); + } + if l.shadow { + spec.extra_blocks.push(TaggedBlock::new(*b"lfx2", drop_shadow())); + } + b.push_layer(spec); + } + b.end_group()?; + } + let mut merged = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + merged.extend(self.expected(x, y)); + merged.push(255); + } + } + b.composite(PixelData::Rgba8(merged)); + b.build() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn presets_respect_photoshop_limits() { + let psd = PSD_2GB.file_len().unwrap(); + assert!(psd < PSD_MAX_FILE_BYTES && psd > 2_000_000_000, "{psd}"); + const { assert!(PSD_2GB.width <= PSD_MAX_SIDE && PSD_2GB.height <= PSD_MAX_SIDE) }; + for c in PSB_CASES { + assert!(c.file_len().is_ok() && c.width <= 300_000, "{}", c.name); + } + assert!(PSB_4GB.file_len().unwrap() > 4 << 30); + // A PSD too big for its 32-bit lengths is refused, not wrapped. + let huge = Streamed { width: 33_000, height: 33_000, ..PSD_2GB }; + assert!(huge.file_len().is_err()); + assert!(huge.write_to(&mut io::sink()).is_err()); + } + + /// Small streamed files parse, and their pixels are [`sample`]'s. + #[test] + fn small_streamed_files_parse() { + for version in [Version::Psd, Version::Psb] { + for (rle, layered, depth) in [(false, true, 8), (true, true, 8), (true, false, 16), (false, false, 8)] { + let c = Streamed { name: "t", version, width: 300, height: 7, depth, rle, layered }; + let mut out = Vec::new(); + let n = c.write_to(&mut out).unwrap(); + assert_eq!(n, out.len() as u64, "{c:?}"); + let file = PsdFile::from_bytes(&out).unwrap(); + let merged = file.image_data.decode(&file.header).unwrap(); + let bps = usize::from(depth / 8); + let plane = 300 * 7 * bps; + for (x, y, ch) in [(0u32, 0u32, 0u32), (299, 6, 2), (150, 3, 1)] { + let i = ch as usize * plane + (y as usize * 300 + x as usize) * bps; + assert_eq!(merged[i], c.sample(x, y, ch), "{c:?} ({x}, {y}) {ch}"); + } + assert_eq!(file.layers().len(), usize::from(layered), "{c:?}"); + } + } + } + + #[test] + fn grid_layout_is_consistent() { + for g in [LAYERED_20MP, LAYOUT_150] { + let last = g.layer(g.layers - 1).unwrap(); + assert!(last.rect.right <= g.width as i32 && last.rect.bottom <= g.height as i32); + assert!(g.layer(g.layers).is_none()); + let all: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect(); + assert!(all.iter().any(|l| l.masked) && all.iter().any(|l| l.shadow)); + for (x, y) in g.sample_points() { + assert!(x < g.width && y < g.height); + } + } + assert!((0..LAYOUT_150.layers).filter_map(|i| LAYOUT_150.layer(i)).any(|l| l.hidden)); + } + + /// A small grid builds, parses, and its merged image is [`Grid::expected`]. + #[test] + fn small_grid_builds() { + let g = Grid { width: 120, height: 90, layers: 14, cols: 4 }; + let file = g.build(Version::Psd).unwrap(); + let bytes = file.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + // Background + 14 layers + 4 groups (two records each). + assert_eq!(file.layers().len(), 1 + 14 + 8); + let merged = file.image_data.decode(&file.header).unwrap(); + for (x, y) in g.sample_points() { + let want = g.expected(x, y); + for c in 0..3 { + assert_eq!(merged[c * 120 * 90 + (y * 120 + x) as usize], want[c], "({x}, {y}) {c}"); + } + } + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 00d73c4003..1950e48dd4 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -51,6 +51,8 @@ pub mod descriptor; pub mod error; pub mod file; pub mod filter_effects; +#[cfg(any(test, feature = "testgen"))] +pub mod fixtures; pub mod grd; pub mod hdr; pub mod header; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d6a49c0038..b9a3870ca7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -57,8 +57,9 @@ photocraft-raw = { workspace = true, features = ["testgen"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } # Benchmark helpers for the `--json` benches `cargo xtask perf` runs (in-process RSS, percentiles). photocraft-testkit = { workspace = true } -# Only for examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row). -photocraft-psd = { workspace = true } +# examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row) and large_file_bench.rs +# (the generated 2 GB PSD and 4.4 GB PSB of P20, `fixtures`). +photocraft-psd = { workspace = true, features = ["testgen"] } [build-dependencies] # Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them. diff --git a/crates/ui-egui/examples/large_file_bench.rs b/crates/ui-egui/examples/large_file_bench.rs new file mode 100644 index 0000000000..fcf1228b26 --- /dev/null +++ b/crates/ui-egui/examples/large_file_bench.rs @@ -0,0 +1,125 @@ +//! Large-file open benchmark (P20, #210, #216, #5107): open the generated 2 GB PSD (17,000² px, a +//! raw full-canvas layer and merged image, just under Photoshop's PSD cap) and the 4.4 GB PSB +//! (25,000² px, data past 4 GiB) until the document is editable: read the file, decode it into a +//! document and open it in a session (the colour policy runs there), the way File › Open does +//! before the first canvas refresh. The canvas refresh is left out (large_image_bench and +//! huge_doc_bench time it). +//! +//! ```sh +//! cargo run --release -p photocraft-ui-egui --example large_file_bench -- [--reps 3] [--only psd|psb] [--cache DIR] [--clean] [--json out.json] +//! ``` +//! +//! The fixtures (`photocraft_psd::fixtures`, about 6.4 GB together) are generated once into +//! `--cache` (default: `$PHOTOCRAFT_FIXTURE_DIR`, else `fixtures/` in the target directory next +//! to this binary) and kept for the next run; `--clean` removes them afterwards. Each row also +//! records the peak resident memory of its opens. Needs about 30 GB of memory for the PSB. + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use photocraft_psd::fixtures::{PSB_4GB, PSD_2GB, Streamed}; +use photocraft_testkit::perf::{RssSampler, row}; +use serde_json::json; + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +/// `/fixtures`: the binary lives in `/release/examples/`. +fn default_cache() -> PathBuf { + if let Some(d) = std::env::var_os("PHOTOCRAFT_FIXTURE_DIR") { + return PathBuf::from(d); + } + std::env::current_exe().ok().and_then(|e| e.ancestors().nth(3).map(|t| t.join("fixtures"))).unwrap_or_else(std::env::temp_dir) +} + +/// Opens `path` like File › Open: read, decode, open in a session. Returns the session (the +/// caller drops it outside the timing) and whether a sampled pixel came back right. +fn open(case: &Streamed, path: &std::path::Path) -> Result<(Session, bool), String> { + let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let doc = photocraft_io::import(&case.file_name(), &bytes).map_err(|e| e.to_string())?.document; + drop(bytes); + let mut s = Session::new(); + s.open_document(doc, Some(path.display().to_string())); + let (x, y) = (case.width / 3, case.height * 2 / 3); + let ok = s.active().and_then(|st| st.doc.layers.first()).and_then(|l| l.surface()).is_some_and(|surf| { + let got = surf.pixel(x as i32, y as i32); + got.len() == 4 && got.iter().enumerate().all(|(c, v)| (v - f32::from(case.sample(x, y, c as u32)) / 255.0).abs() <= 1.0 / 510.0) + }); + Ok((s, ok)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(3).max(1); + let only = arg(&args, "--only"); + let dir = arg(&args, "--cache").map_or_else(default_cache, PathBuf::from); + let clean = args.iter().any(|a| a == "--clean"); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("create {}: {e}", dir.display()); + std::process::exit(1); + } + let sampler = RssSampler::start(Duration::from_millis(5)); + let cases = [("psd", "open 2 GB PSD (read + decode + document)", PSD_2GB), ("psb", "open 4.4 GB PSB (read + decode + document)", PSB_4GB)]; + let mut rows = Vec::new(); + let mut errors = Vec::new(); + for (key, name, case) in &cases { + if only.as_deref().is_some_and(|o| o != *key) { + continue; + } + let path = dir.join(case.file_name()); + let want = case.file_len().unwrap_or(0); + if std::fs::metadata(&path).map(|m| m.len()).ok() != Some(want) { + let t = Instant::now(); + match case.write_file(&path) { + Ok(len) => println!("generated {} ({:.2} GB) in {:.1} s", path.display(), len as f64 / 1e9, t.elapsed().as_secs_f64()), + Err(e) => { + errors.push(json!({"name": name, "error": format!("generate {}: {e}", path.display())})); + continue; + } + } + } + let mut samples = Vec::new(); + let mut peak = None; + for _ in 0..reps { + sampler.reset(); + let t = Instant::now(); + match open(case, &path) { + Ok((s, ok)) => { + let ms = t.elapsed().as_secs_f64() * 1000.0; + peak = peak.max(sampler.peak()); + println!("{name:<44} {ms:>9.0} ms peak RSS {:>6} MB{}", sampler.peak().unwrap_or(0) >> 20, if ok { "" } else { " WRONG PIXELS" }); + if !ok { + errors.push(json!({"name": name, "error": "the opened document's pixels don't match the fixture"})); + break; + } + samples.push(ms); + drop(s); + } + Err(e) => { + println!("{name}: {e}"); + errors.push(json!({"name": name, "error": e})); + break; + } + } + } + if !samples.is_empty() { + rows.push(row(name, &samples, peak, None)); + } + if clean { + let _ = std::fs::remove_file(&path); + } + } + if let Some(out) = arg(&args, "--json") { + let context = + json!({"reps": reps, "cache": dir.display().to_string(), "errors": errors, "threads": std::thread::available_parallelism().map(|n| n.get()).ok()}); + let report = photocraft_testkit::perf::report("large_file_bench", context, rows, Some(&sampler)); + if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) { + eprintln!("{e}"); + } + } + if !errors.is_empty() { + std::process::exit(1); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d2c9d8ccde..bc32ff306e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -22,7 +22,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | -| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | +| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Large-file fixtures generated at test time (20 MP layered, 150-layer layout, 2.02 GB PSD, 4.38 GB PSB), opened, saved and re-opened by io tests/large_files.rs; FILE-216-2 done; P20/P60 open times measurable (#5107) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | | 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | diff --git a/docs/gaps.md b/docs/gaps.md index f7929c276d..0a7d955259 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -47,7 +47,8 @@ what an agent can't finish alone. within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object - blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375). + blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375; a generated 4.38 GB PSB opens, saves and re-opens + through io on Windows, io tests/large_files.rs, 2026-10-13). No test opens our exports in a third-party editor (FILE-216-4). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. - **Estimate:** 150–250 h. **Human:** opening exports in Photoshop on the owner's Mac (or a @@ -255,6 +256,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: generated large-file fixtures (20 MP, 150 layers, 2 GB PSD, 4.38 GB PSB) round-trip in io tests/large_files.rs; the 4.38 GB PSB opens on Windows through io (#375 evidence); FILE-216-2 done (#5107) | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/scorecard.md b/docs/scorecard.md index af13a3f265..52f7a1d1f5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,8 +6,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | +| [Performance](#performance) | 3 | 22 | 23 | 57 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 4 not measurable yet (baseline 2026-10-05) | +| [File compatibility](#file-compatibility) | 8 | 12 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -43,6 +43,8 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P17 | Move a layer on a 32-bit document with 20 layers | ≤ 100 ms p95 | 386 | 681 | 681 | 1.2 GB | 1.6 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P18 | Open a 20 MP layered PSD: first pixels (today: fully opened) | ≤ 1000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | over budget (target not met yet) | [#210](https://github.com/storytold/photocraft/issues/210) | | P19 | Open a 20 MP layered PSD: editable | ≤ 3000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | +| P20 | Open a 2 GB PSD: editable | opens with progress, editable, never blank | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | +| P60 | Open a 4–5 GB PSB: editable | opens with progress, editable, never blank | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | | P21 | Content-Aware Fill on 10 MP | ≤ 1500 ms p95 | 218 | 256 | 256 | 1.8 GB | 1.8 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | | P22 | Spot Healing stroke on 10 MP | ≤ 500 ms p95 | 157 | 247 | 247 | 1.8 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | | P24 | Import 500 brushes (longest UI-thread step) | ≤ 100 ms max | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | @@ -81,7 +83,6 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | Id | Scenario | Target | Why | Issue | |---|---|---|---|---| | P15 | Zoom / pan frame time on a 15000×10000 16-bit document | ≤ 16 ms per frame | no headless canvas frame harness yet (zoom and pan sample the GPU texture in the egui paint callback) | [#209](https://github.com/storytold/photocraft/issues/209) | -| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216) | [#210](https://github.com/storytold/photocraft/issues/210) | | P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) | | P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) | | P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) | @@ -120,7 +121,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) | -| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | partial | [#216](https://github.com/storytold/photocraft/issues/216) | 4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI | +| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | done | [#216](https://github.com/storytold/photocraft/issues/216) | Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P60). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk, ~16 GB of memory) (#5107) | | FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | | FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..7feb202e34 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -60,6 +60,12 @@ package = "photocraft-ui-egui" args = ["--size", "14000x14000", "--op", "refresh"] note = "196 MP: open and full refresh" +[[bench]] +name = "large_file_bench" +package = "photocraft-ui-egui" +args = ["--reps", "3"] +note = "generated 2 GB PSD and 4.4 GB PSB (6.4 GB cached in /fixtures): read + decode + open; needs ~16 GB of memory" + # Hook: the layout-document bench lands with perf/layout-documents (#125/#128). It runs (and its # rows are kept in results.json) as soon as the example exists; add scenarios for its rows then. [[bench]] @@ -250,12 +256,23 @@ bench = "perf_scenarios" row = "open 20 MP layered PSD (decode + first composite)" budget_ms = 3000 +# P20/P60: generated fixtures (`photocraft_psd::fixtures`, #5107); the row checks a pixel, so a +# blank document fails it. No time budget yet: the target is the behaviour (progress, editable). [[scenario]] id = "P20" -title = "Open a 2 GB PSD / 4–5 GB PSB" +title = "Open a 2 GB PSD: editable" issue = 210 +bench = "large_file_bench" +row = "open 2 GB PSD (read + decode + document)" +target = "opens with progress, editable, never blank" + +[[scenario]] +id = "P60" +title = "Open a 4–5 GB PSB: editable" +issue = 210 +bench = "large_file_bench" +row = "open 4.4 GB PSB (read + decode + document)" target = "opens with progress, editable, never blank" -not_measurable = "opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216)" [[scenario]] id = "P21" diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 40b242ea53..4b1fd7163b 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -162,9 +162,9 @@ note = "ci.yml has a corpus job (name: corpus tests) on every PR and on main: th [[item]] id = "FILE-216-2" target = "Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests" -status = "partial" +status = "done" issue = 216 -note = "4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI" +note = "Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P60). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk, ~16 GB of memory) (#5107)" [[item]] id = "FILE-216-3" From afb7de2d3aaae2e2c0617404d265244d6fdca37d Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:01:46 +0200 Subject: [PATCH 0077/1452] Performance: brush dab on a CMYK document within budget (#5108) A brush dab on an A4 300 ppi CMYK page spent ~5 ms per touched tile in the GPU canvas's tile upload: every pixel of the 256x256 tile went through the CMYK -> sRGB transform on one thread. CMYK and Lab tiles now convert in 16-row bands in parallel, and a run of identical pixels (the transparent or flat area around a dab) is converted once. Texels are bit-identical to the per-pixel conversion. The band workers enter the document's CMYK profile scope; before, the rayon workers converting tiles did not, so a CMYK document tagged with its own profile uploaded tiles through the built-in coated CMYK instead. P33 (Windows 11, 32 threads, RTX 5070 Ti, 15 reps): p95 13.7-16.8 ms -> 2.6-3.3 ms. enforce stays false: the committed baseline machine measures every row over budget, so it is left for the next baseline. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/gpu/Cargo.toml | 2 + crates/gpu/src/lib.rs | 136 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6820d6dee5..068634de7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4424,6 +4424,7 @@ version = "0.8.2" dependencies = [ "half", "log", + "photocraft-cms", "photocraft-color", "photocraft-compose", "photocraft-doc", diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 6a0b649cd7..81b51f4c35 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -27,3 +27,5 @@ wgpu = { version = "30" } pollster = "1" # Shape layers in the effect parity tests. photocraft-vector = { workspace = true } +# A document-tagged CMYK profile in the tile conversion test. +photocraft-cms = { workspace = true } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 279c316adc..741cbfacdb 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -2030,43 +2030,87 @@ fn convert_tiles(surface: &Surface, tiles: Vec<(TileCoord, Arc)>, kind: Te } } } + // CMYK and Lab tiles convert pixel by pixel through a colour transform (a CMYK tile took + // ~5 ms on one thread, the whole of a brush dab's refresh on an A4 CMYK page, #5108): those + // go in bands of rows, so even the one tile a dab touches spreads over the threads. Workers + // don't inherit this thread's CMYK profile scope, so each band enters it. + let bands = if colour_managed(&surface.format(), kind) { TILE_SIZE / MANAGED_BAND_ROWS } else { 1 }; + let space = photocraft_color::convert::active_cmyk_space(); for batch in groups.chunks(CONVERT_BATCH) { // A tile's content is the same at every coordinate sharing it: convert it at the first. - let jobs: Vec<_> = batch.iter().filter_map(|(t, cs)| cs.first().map(|c| (t, *c))).collect(); - let converted = fx::par_map(jobs, |(t, c)| convert_tile(surface, Some(t), kind, c)); - for ((t, cs), bytes) in batch.iter().zip(&converted) { - put(t, cs, bytes); + let jobs: Vec<_> = batch.iter().filter_map(|(t, cs)| cs.first().map(|c| (t, *c))).flat_map(|(t, c)| (0..bands).map(move |b| (t, c, b))).collect(); + let parts = fx::par_map(jobs, |(t, c, b)| { + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + if bands == 1 { + return convert_tile(surface, Some(t), kind, c); + } + let r = c.rect(); + let y0 = r.y0 + b * MANAGED_BAND_ROWS; + convert_rows(surface, Some(t), kind, Rect::new(r.x0, y0, r.x1, y0 + MANAGED_BAND_ROWS)) + }) + }); + for ((t, cs), bytes) in batch.iter().zip(parts.chunks(bands as usize)) { + put(t, cs, &bytes.concat()); } } groups.len() } +/// Rows per parallel band of a colour-managed (CMYK, Lab) tile conversion. +const MANAGED_BAND_ROWS: i32 = 16; + +/// Whether `fmt` pixels reach `kind` texels through a per-pixel colour transform (CMYK, Lab). +fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool { + use photocraft_color::ColorMode; + matches!(kind, TexKind::Rgba8 | TexKind::Rgba16F) && matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) +} + /// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere. fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: TileCoord) -> Vec { if let (Some(t), TexKind::Rgba8Direct | TexKind::R8Direct) = (tile, kind) { return t.bytes().to_vec(); } - let n = (TILE_SIZE * TILE_SIZE) as usize; let fmt = surface.format(); - let ch = fmt.channels(); if let (Some(t), TexKind::Rgba16F) = (tile, kind) && let Some(out) = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())) { return out; } + convert_rows(surface, tile, kind, c.rect()) +} + +/// [`convert_tile`]'s general path for `rect` (rows of one tile): each pixel through +/// `photocraft_raster::to_rgba`. A run of identical pixels (the transparent or flat areas around a +/// brush dab) is converted once: the same samples give the same texels. +fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect: Rect) -> Vec { + let n = rect.width() as usize * rect.height() as usize; + let fmt = surface.format(); + let ch = fmt.channels(); let raw: Vec = match tile { - Some(_) => surface.read_region(c.rect()), + Some(_) => surface.read_region(rect), None => surface.default_pixel().repeat(n), }; let mut out = Vec::with_capacity(n * kind.bytes_per_pixel()); - for px in raw.chunks_exact(ch) { + // The previous pixel and its colour. + let mut last: Option<(usize, [f32; 4])> = None; + for (i, px) in raw.chunks_exact(ch).enumerate() { + let mut rgba = || -> [f32; 4] { + if let Some((j, v)) = last + && raw.get(j * ch..j * ch + ch).is_some_and(|p| p.iter().zip(px).all(|(a, b)| a.to_bits() == b.to_bits())) + { + return v; + } + let v = photocraft_raster::to_rgba(&fmt, px); + last = Some((i, v)); + v + }; match kind { TexKind::Rgba8Direct | TexKind::Rgba8 => { - let v = photocraft_raster::to_rgba(&fmt, px); + let v = rgba(); out.extend(v.map(|x| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); } TexKind::Rgba16F => { - let v = photocraft_raster::to_rgba(&fmt, px); + let v = rgba(); for x in v { out.extend(f32_to_f16(x).to_le_bytes()); } @@ -2489,6 +2533,78 @@ mod tests { assert!(rgb_gray32_to_f16(&PixelFormat { sample: SampleType::F32, ..PixelFormat::CMYKA8 }, &[]).is_none()); } + /// CMYK and Lab tiles convert in parallel bands of rows, each run of equal pixels once + /// (#5108): the texels are still exactly the per-pixel conversion's, at 8 and 16 bits, with + /// the built-in CMYK profile and with a document's own (which the worker threads enter). + #[test] + fn colour_managed_tiles_match_the_per_pixel_conversion() { + use photocraft_cms::synth::{CmykParams, cmyk_profile}; + use photocraft_color::ColorMode; + use photocraft_color::convert::{CmykSpace, with_cmyk_space}; + let icc = Arc::new( + cmyk_profile(&CmykParams { description: "GPU test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }) + .to_bytes(), + ); + let own = CmykSpace::for_profile(Some(&icc)); + assert!(own.is_some(), "the synthetic profile is not the built-in one"); + let area = Rect::new(0, 0, TILE_SIZE * 3, TILE_SIZE); + for (mode, sample, space) in [ + (ColorMode::Cmyk, SampleType::U8, None), + (ColorMode::Cmyk, SampleType::U16, None), + (ColorMode::Cmyk, SampleType::U8, own.clone()), + (ColorMode::Cmyk, SampleType::U16, own.clone()), + (ColorMode::Lab, SampleType::U8, None), + (ColorMode::Lab, SampleType::U16, None), + ] { + let fmt = PixelFormat { mode, sample, alpha: true }; + let kind = TexKind::for_surface(Role::Content, fmt); + assert!(colour_managed(&fmt, kind), "{fmt:?}"); + let ch = fmt.channels(); + let mut s = Surface::new(fmt); + // Tile 0: noise. Tile 1: transparent with a soft dab (runs of equal pixels). Tile 2: + // a flat colour with a few lines of noise. + let n = (TILE_SIZE * TILE_SIZE) as usize * ch; + let noise: Vec = (0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) as f32 / u32::MAX as f32).collect(); + s.write_region(Rect::new(0, 0, TILE_SIZE, TILE_SIZE), &noise); + let mut dab = [0.8, 0.6, 0.1, 0.05, 0.0]; + for r in (1..30).rev() { + dab[ch - 1] = 1.0 - r as f32 / 30.0; + s.fill_rect(Rect::new(TILE_SIZE + 100 - r, 90 - r, TILE_SIZE + 100 + r, 90 + r), &dab[..ch]); + } + s.fill_rect(Rect::new(TILE_SIZE * 2, 0, TILE_SIZE * 3, TILE_SIZE), &[0.3, 0.2, 0.7, 0.1, 1.0][..ch]); + s.write_region(Rect::new(TILE_SIZE * 2, 40, TILE_SIZE * 3, 43), &noise[..TILE_SIZE as usize * 3 * ch]); + let tiles: Vec<_> = area.tiles().filter_map(|c| s.tile(c).map(|t| (c, t.clone()))).collect(); + assert_eq!(tiles.len(), 3, "{fmt:?}"); + let mut seen = 0; + with_cmyk_space(space.as_ref(), || { + convert_tiles(&s, tiles, kind, |_, cs, bytes| { + for &c in cs { + // The old path: every pixel through `to_rgba` on this thread, in this scope. + let want: Vec = s + .read_region(c.rect()) + .chunks_exact(ch) + .flat_map(|px| { + let v = photocraft_raster::to_rgba(&fmt, px); + match kind { + TexKind::Rgba16F => v.iter().flat_map(|x| f32_to_f16(*x).to_le_bytes()).collect::>(), + _ => v.map(|x| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8).to_vec(), + } + }) + .collect(); + assert!( + bytes == want.as_slice(), + "{fmt:?} {c:?} with {}", + if space.is_some() { "the document's profile" } else { "the built-in profile" } + ); + assert_eq!(convert_tile(&s, s.tile(c), kind, c), want, "{fmt:?} {c:?}"); + seen += 1; + } + }); + }); + assert_eq!(seen, 3, "{fmt:?}"); + } + } + #[test] fn grid_rounds_out() { assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256)); From ebd3c12270083e9bee36e91cb8ec1fdc18e74671 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:20:12 +0200 Subject: [PATCH 0078/1452] Large documents: a gigapixel New Document draws without freezing (#4017) The reduced CPU canvas composites one-colour tiles once per colour instead of every document pixel, and GPU canvas textures the device refuses fall back to the CPU canvas instead of disabling the GPU for the session. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 156 +++++++++++++++++++-- crates/compose/src/tests.rs | 102 ++++++++++++++ crates/compose/src/uniform.rs | 107 ++++++++++++++ crates/ui-egui/examples/perf_scenarios.rs | 29 ++++ crates/ui-egui/src/gpu_canvas.rs | 31 +++- crates/ui-egui/src/gpu_canvas_mip_tests.rs | 24 ++++ docs/scorecard.md | 3 +- perf/budgets.toml | 7 + 8 files changed, 439 insertions(+), 20 deletions(-) create mode 100644 crates/compose/src/uniform.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f1625b7a22..67a349b8a4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -29,6 +29,7 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; +mod uniform; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; @@ -377,6 +378,38 @@ pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Bu /// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an /// explicit band height (see [`render_bands`]). fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32) -> Buffer { + render_reduced_with(doc, b, w, h, damage, band_rows, true, &mut ReducedStats::default()) +} + +/// What a reduced render composited (tests check that one-colour areas are not composited). +#[derive(Debug, Default)] +struct ReducedStats { + /// Whether one-colour areas were looked for ([`uniform::UniformAreas`]). + uniform: bool, + /// Document pixels composited one by one. + composited: u64, +} + +/// A tile of a reduced render: one colour (premultiplied), or its composited pixels. +enum Part { + Solid([f32; 4]), + Px(Buffer), +} + +/// A column of document tiles in a reduced render (`b`-relative source columns). +struct TileColumn { + /// The columns rendered: the tile's, within the source area. + x0: usize, + x1: usize, + /// The whole tile's columns within `b`, whose values decide whether it is one colour. + whole: (i64, i64), + /// Runs of (output column, how many of the rendered columns it averages). + runs: Vec<(usize, f32)>, +} + +/// [`render_reduced_in_bands`]; `uniform` off composites every pixel (the reference in tests). +#[allow(clippy::too_many_arguments)] +fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32, uniform: bool, stats: &mut ReducedStats) -> Buffer { let (fw, fh) = (b.width() as usize, b.height() as usize); let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); let full = Rect::from_xywh(0, 0, w as u32, h as u32); @@ -420,22 +453,14 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti let (cols, rows) = (map(fw, w), map(fh, h)); let cols = &cols[sx0..sx1]; let mut acc = vec![[0.0f32; 4]; ow * oh]; - let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { - let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + // Each output row sums its source rows in a band (in order, top to bottom), each row's pixels + // left to right: `row(y, out)` adds source row `y` (band-relative) to output row `out`. + let sum_band = |acc: &mut [[f32; 4]], by0: usize, by1: usize, row: &(dyn Fn(usize, &mut [[f32; 4]]) + Sync)| { let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1); - let sw = sx1 - sx0; - // Each output row sums its source rows in this band (in order, top to bottom). let sum_row = |t: usize, out: &mut [[f32; 4]]| { let (a, z) = span(t, fh, h); for y in a.max(by0)..z.min(by1) { - let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw]; - for (p, &c) in src.iter().zip(cols) { - let o = &mut out[c as usize - ox0]; - for i in 0..3 { - o[i] += p[i] * p[3]; - } - o[3] += p[3]; - } + row(y - by0, out); } }; let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow]; @@ -448,9 +473,110 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti for (i, out) in part.chunks_mut(ow).enumerate() { sum_row(t0 + i, out); } - Ok(()) - }); - debug_assert!(ok.is_ok()); + }; + let add = |o: &mut [f32; 4], p: &[f32; 4]| { + for i in 0..3 { + o[i] += p[i] * p[3]; + } + o[3] += p[3]; + }; + if let Some(uniform) = uniform.then(|| uniform::UniformAreas::new(doc)).flatten() { + stats.uniform = true; + // One-colour tiles (a new document's background, the empty parts of a sparse one) are + // composited once per colour and summed per run of source columns, not per pixel + // (#4017). Tiles lie on the document's tile grid whatever `damage` is, so a damage + // render sums exactly what the whole reduction does. + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(doc, &patterns); + let t = RENDER_TILE as usize; + // The document tile grid (the surfaces' own), in `b`-relative coordinates: the start of + // the tile holding `v` (negative when `b` starts inside a tile). + let grid = |v: usize, o: i32| (i64::from(o) + v as i64).div_euclid(t as i64) * t as i64 - i64::from(o); + let tile_cols: Vec = (grid(sx0, b.x0)..sx1 as i64) + .step_by(t) + .map(|gx| { + let (x0, x1) = ((gx.max(0) as usize).max(sx0), ((gx + t as i64) as usize).min(sx1)); + let mut runs: Vec<(usize, f32)> = Vec::new(); + for &c in &cols[x0 - sx0..x1 - sx0] { + match runs.last_mut() { + Some((last, n)) if *last == c as usize - ox0 => *n += 1.0, + _ => runs.push((c as usize - ox0, 1.0)), + } + } + TileColumn { x0, x1, whole: (gx.max(0), (gx + t as i64).min(fw as i64)), runs } + }) + .collect(); + let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize; + let mut y = grid(sy0, b.y0); + while y < sy1 as i64 { + let y1 = (y + band_h as i64).min(sy1 as i64); + let tiles: Vec<(Rect, Rect)> = (y..y1) + .step_by(t) + .flat_map(|gy| { + tile_cols.iter().map(move |&TileColumn { x0, x1, whole: (gx0, gx1), .. }| { + let at = |x0: i64, y0: i64, x1: i64, y1: i64| Rect::new(b.x0 + x0 as i32, b.y0 + y0 as i32, b.x0 + x1 as i32, b.y0 + y1 as i32); + let (gy0, gy1) = (gy.max(0), (gy + t as i64).min(fh as i64)); + (at(gx0, gy0, gx1, gy1), at(x0 as i64, gy0.max(sy0 as i64), x1 as i64, gy1.min(sy1 as i64))) + }) + }) + .collect(); + let one = |(whole, part): &(Rect, Rect)| -> Part { + let render = |r: Rect| render_tiled_with(doc, r, RENDER_TILE, &cx); + match uniform.color(*whole, |r| render(r).px.first().copied()) { + Some(p) => Part::Solid([p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]), + None => Part::Px(render(*part)), + } + }; + #[cfg(not(target_arch = "wasm32"))] + let parts: Vec = { + use rayon::prelude::*; + tiles.par_iter().map(one).collect() + }; + #[cfg(target_arch = "wasm32")] + let parts: Vec = tiles.iter().map(one).collect(); + stats.composited += parts.iter().map(|p| if let Part::Px(b) = p { b.px.len() as u64 } else { 0 }).sum::(); + let (by0, by1) = (y.max(sy0 as i64) as usize, y1 as usize); + let n = tile_cols.len(); + let band_y = y; + sum_band(&mut acc, by0, by1, &|row: usize, out: &mut [[f32; 4]]| { + let y = (by0 + row) as i64; + let j = (y - band_y) as usize / t; + let ty = (y - (band_y + (j * t) as i64).max(sy0 as i64)) as usize; + for (TileColumn { x0, x1, runs, .. }, part) in tile_cols.iter().zip(parts.iter().skip(j * n).take(n)) { + match part { + Part::Solid(pa) => { + for &(c, k) in runs { + let o = &mut out[c]; + for i in 0..4 { + o[i] += pa[i] * k; + } + } + } + Part::Px(buf) => { + let tw = x1 - x0; + for (p, &c) in buf.px.iter().skip(ty * tw).take(tw).zip(&cols[x0 - sx0..x1 - sx0]) { + add(&mut out[c as usize - ox0], p); + } + } + } + } + }); + y = y1; + } + } else { + let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { + stats.composited += band.px.len() as u64; + let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + let sw = sx1 - sx0; + sum_band(&mut acc, by0, by1, &|y: usize, out: &mut [[f32; 4]]| { + for (p, &c) in band.px[y * sw..(y + 1) * sw].iter().zip(cols) { + add(&mut out[c as usize - ox0], p); + } + }); + Ok(()) + }); + debug_assert!(ok.is_ok()); + } for (i, p) in acc.iter_mut().enumerate() { let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h)); let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 69504e4a93..a41ddfa62a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2570,3 +2570,105 @@ fn effect_cache_follows_a_group_childs_blend_if() { assert!(before.px != cold.px, "Blend If should change the group's shadow"); assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); } + +/// A document whose one-colour areas are found: shared background tiles, a painted patch over +/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one. +fn sparse_doc(w: u32, h: u32) -> Document { + let mut d = doc_white(w, h); + let mut paint = Layer::raster("paint", PixelFormat::RGBA8); + let s = paint.surface_mut().unwrap(); + for y in 200..330 { + s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]); + } + s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]); + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]); + m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]); + paint.mask = Some(m); + paint.blend = BlendMode::Multiply; + let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]); + deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true))); + let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]); + hidden.visible = false; + d.layers.push(Layer::group("g", vec![paint, deep, hidden])); + d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 }))); + d +} + +#[test] +fn one_colour_areas_reduce_like_composited_ones() { + // #4017: one-colour tiles are composited once and summed per run of columns: the same + // averages as compositing every pixel (to float rounding), and a damage render gives exactly + // the whole reduction's pixels, so a reduced canvas updated in parts shows no seams. + let d = sparse_doc(2000, 2100); + for (b, w, h, band) in [ + (d.bounds(), 37, 41, 0), + (d.bounds(), 250, 2100, 256), + (d.bounds(), 1999, 3, 512), + (Rect::new(-300, -77, 2200, 2300), 61, 29, 0), + (Rect::new(13, 5, 2000, 2100), 1, 1, 256), + ] { + let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default()); + let mut stats = ReducedStats::default(); + let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats); + assert!(stats.uniform); + assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}"); + assert_eq!(all.rect, reference.rect); + for (p, q) in all.px.iter().zip(&reference.px) { + assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}"); + } + for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] { + let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default()); + for y in part.rect.y0..part.rect.y1 { + for x in part.rect.x0..part.rect.x1 { + assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}"); + } + } + } + } +} + +#[test] +fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() { + let base = sparse_doc(300, 300); + let edits: [fn(&mut Layer); 4] = [ + |l| l.blend = BlendMode::Dissolve, + |l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0, + |l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)), + |l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)), + ]; + for edit in edits { + let mut d = base.clone(); + edit(&mut d.layers[0]); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); + assert_eq!(stats.composited, 300 * 300); + } + let mut d = base.clone(); + d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true }))); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); +} + +#[test] +fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() { + // #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel + // (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges + // hold two values; the rest is one shared white tile. + let (w, h) = (78_740u32, 39_370u32); + for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] { + let mut d = match fill { + Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c), + None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8), + }; + d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8)); + let mut stats = ReducedStats::default(); + let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats); + let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1); + assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}"); + let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]); + assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}"); + } +} diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs new file mode 100644 index 0000000000..e9423d0e69 --- /dev/null +++ b/crates/compose/src/uniform.rs @@ -0,0 +1,107 @@ +//! One-colour areas of a composite, known without compositing them pixel by pixel. +//! +//! A new document is a canvas of one shared white tile (or no tiles at all), and a sparse one is +//! mostly that. When every layer composites pixel by pixel from its own pixels (no effects, +//! feathered masks, dissolve, dithering or layer kinds laid out on the canvas), an area over which +//! every surface holds one value composites to one colour: one pixel of it is composited and +//! stands for the rest. The reduced canvas of a gigapixel document (#4017) then costs what its +//! painted tiles cost, not a composite of every document pixel. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use photocraft_color::blend::BlendMode; +use photocraft_doc::{Adjustment, Document, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// The surfaces a document composites from, when its composite at each pixel depends only on +/// their values at that pixel. +pub(crate) struct UniformAreas<'a> { + surfaces: Vec<&'a Surface>, + /// One value per tile (by address), or `None` when its pixels differ: each tile is scanned once. + tile_values: Mutex>>>, + /// The composite colour of each combination of surface values met so far. + colors: Mutex, [f32; 4]>>, +} + +impl<'a> UniformAreas<'a> { + /// `None` when some layer's composite depends on more than its pixels at the same place. + pub(crate) fn new(doc: &'a Document) -> Option { + if doc.mode == photocraft_color::ColorMode::Multichannel { + return None; + } + let mut surfaces = Vec::new(); + for (_, _, l) in doc.walk() { + let effects = l.effects.enabled && !l.effects.items.is_empty(); + if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve { + return None; + } + if let Some(m) = &l.mask { + if m.feather != 0.0 { + return None; + } + surfaces.push(&m.surface); + } + match &l.content { + LayerContent::Raster(s) => surfaces.push(s), + LayerContent::Group(g) if g.artboard.is_none() => {} + LayerContent::Adjustment(a) if per_pixel(a) => {} + _ => return None, + } + } + Some(Self { surfaces, tile_values: Mutex::default(), colors: Mutex::default() }) + } + + /// The colour every pixel of `r` composites to, when every surface holds one value over it; + /// `composite` renders one pixel the first time a combination of values is met. + pub(crate) fn color(&self, r: Rect, composite: impl FnOnce(Rect) -> Option<[f32; 4]>) -> Option<[f32; 4]> { + if r.is_empty() { + return None; + } + let mut key = Vec::new(); + for s in &self.surfaces { + key.extend_from_slice(&self.value_over(s, r)?); + } + if let Some(c) = self.colors.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + return Some(*c); + } + let c = composite(Rect::from_xywh(r.x0, r.y0, 1, 1))?; + self.colors.lock().unwrap_or_else(PoisonError::into_inner).insert(key, c); + Some(c) + } + + /// The one encoded value `s` holds over `r`, if it holds one. + fn value_over(&self, s: &Surface, r: Rect) -> Option> { + let mut found: Option> = None; + for c in r.tiles() { + let v = match s.tile(c) { + None => Some(Box::from(s.default_bytes())), + Some(t) => { + let id = std::sync::Arc::as_ptr(t) as usize; + let cached = self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).get(&id).cloned(); + match cached { + Some(v) => v, + None => { + let bpp = s.default_bytes().len().max(1); + let mut px = t.bytes().chunks_exact(bpp); + let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); + self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); + v + } + } + } + }?; + if found.as_ref().is_some_and(|f| *f != v) { + return None; + } + found = Some(v); + } + found + } +} + +/// Whether adjustment `a` maps each pixel on its own (dithered ones vary with the position). +fn per_pixel(a: &Adjustment) -> bool { + !matches!(a, Adjustment::GradientMap { dither: true, .. } | Adjustment::ColorLookup { dither: true, .. }) +} diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1b71258473..1ac4c8ea87 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -252,6 +252,8 @@ struct Sizes { big_photo: (u32, u32), /// A4 at 300 ppi. a4: (u32, u32), + /// A new 10 × 5 m document at 200 ppi (3.1 GP, #4017). + huge_new: (u32, u32), } const FULL: Sizes = Sizes { @@ -263,6 +265,7 @@ const FULL: Sizes = Sizes { ten_mp: (3872, 2592), big_photo: (6000, 4000), a4: (2480, 3508), + huge_new: (78_740, 39_370), }; const QUICK: Sizes = Sizes { @@ -274,6 +277,7 @@ const QUICK: Sizes = Sizes { ten_mp: (1200, 800), big_photo: (1600, 1066), a4: (1240, 1754), + huge_new: (20_000, 10_000), }; /// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke, @@ -866,6 +870,7 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) { } } liquify_preview_scenario(b); + huge_new_scenario(b, sz); let cmyk = "brush dab on A4 300 ppi CMYK + refresh"; if b.wanted(cmyk) { let (w, h) = sz.a4; @@ -929,6 +934,30 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) { } } +/// #4017: File > New at 10 × 5 m, 200 ppi (3.1 GP), and the first refresh of the canvas that +/// draws a document too large for GPU textures (the reduced CPU canvas, `canvas::ensure_texture`). +fn huge_new_scenario(b: &mut Bench, sz: &Sizes) { + let name = "new 3 GP document + first reduced canvas refresh"; + if !b.wanted(name) { + return; + } + let (w, h) = sz.huge_new; + let reps = b.reps.min(3); + b.time(name, &mut Session::new(), reps, false, |_, s, _| { + let t = Instant::now(); + exec(s, "file.new", json!({"width": w, "height": h, "resolution": 200, "background": "white"}))?; + let doc = &s.active().ok_or("no document")?.doc; + let factor = w.max(h).div_ceil(photocraft_ui_egui::canvas::MAX_TEXTURE).max(1); + let reduced = photocraft_compose::render_reduced(doc, w / factor, h / factor); + if reduced.px.first() != Some(&[1.0; 4]) { + return Err("the new document is not white".into()); + } + let v = ms(t); + exec(s, "file.close", json!({}))?; + Ok(v) + }); +} + /// A source-resolution Liquify brush update on the visible part of a 3500×2200 layer. /// Reports the old whole-document proxy cost alongside the full-resolution cropped update. fn liquify_preview_scenario(b: &mut Bench) { diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index bcf0e69516..0126f5aaf1 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1553,11 +1553,34 @@ fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu: impl DocTextures { /// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit - /// the canvas budget. - fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { - if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget { + /// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB + /// of textures, #4017) lowers the budget below it, so that canvas and larger ones are drawn + /// on the CPU canvas instead of the out-of-memory error disabling the GPU for the session. + fn new(device: &wgpu::Device, res: &mut Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { + let bytes = canvas_texture_bytes(size, tile, texel_bytes(format)); + if bytes > res.canvas_budget { return None; } + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let set = Self::create(device, res, size, tile, format); + // Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen. + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut error = None; + for pop in [memory.pop(), validation.pop()] { + if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) { + error.get_or_insert(e); + } + } + if let Some(e) = error { + log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20); + res.canvas_budget = res.canvas_budget.min(bytes.saturating_sub(1)); + return None; + } + Some(set) + } + + fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self { let mut tiles = Vec::new(); for ty in (0..size[1]).step_by(tile as usize) { for tx in (0..size[0]).step_by(tile as usize) { @@ -1592,7 +1615,7 @@ impl DocTextures { tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); } } - Some(Self { size, format, tiles }) + Self { size, format, tiles } } /// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index a1c3e8ee14..b61b77ff1b 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -63,6 +63,30 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { gpu.rs.renderer.write().callback_resources.get_mut::().unwrap().separate_mip_targets = separate; check_mips(&gpu); } + // On the same device (see the top of the file). + canvas_textures_the_gpu_refuses_leave_it_working(&gpu); +} + +/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error +/// disabled the GPU for the session. A refused set is now caught: nothing is kept, the budget +/// drops below it (that canvas goes to the CPU canvas) and the device stays usable. A texture +/// wider than the device allows is refused the same way, synchronously, on any adapter. +fn canvas_textures_the_gpu_refuses_leave_it_working(gpu: &GpuCanvas) { + let max = gpu.rs.device.limits().max_texture_dimension_2d; + let (size, tile) = ([max + 1, 1], max + 1); + let bytes = canvas_texture_bytes(size, tile, texel_bytes(FORMAT)); + { + let mut renderer = gpu.rs.renderer.write(); + let res = renderer.callback_resources.get_mut::().unwrap(); + res.canvas_budget = u64::MAX; + assert!(DocTextures::new(&gpu.rs.device, res, size, tile, FORMAT).is_none()); + assert!(res.canvas_budget < bytes, "{} >= {bytes}", res.canvas_budget); + } + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); + // Smaller canvases still get GPU textures. + gpu.upload_buffer_full(3, &Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]), SampleType::U8); + assert!(gpu.rs.renderer.read().callback_resources.get::().unwrap().docs.contains_key(&3)); + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); } fn check_mips(gpu: &GpuCanvas) { diff --git a/docs/scorecard.md b/docs/scorecard.md index af13a3f265..1e038f1617 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 23 | 57 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 18 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | @@ -75,6 +75,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P57 | Chisel bevel size change, 12 MP text paragraph, size 250 (GPU) | track only | – | – | – | – | – | not run | | | P58 | Chisel bevel size change, 12 MP ellipse, size 8 (CPU) | track only | – | – | – | – | – | not run | | | P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | | +| P61 | File > New 10 x 5 m at 200 ppi (3.1 GP) + first reduced canvas refresh | track only | – | – | – | – | – | not run | [#4017](https://github.com/storytold/photocraft/issues/4017) | **Not measurable yet** (the code can't do it yet; no number is recorded): diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..d02f7d1ce1 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -518,3 +518,10 @@ id = "P59" title = "Chisel bevel size change, 12 MP text paragraph, size 8 (CPU)" bench = "bevel_bench" row = "bevel size change to 8, text (CPU)" + +[[scenario]] +id = "P61" +title = "File > New 10 x 5 m at 200 ppi (3.1 GP) + first reduced canvas refresh" +issue = 4017 +bench = "perf_scenarios" +row = "new 3 GP document + first reduced canvas refresh" From 25541891d13d2c317af155859a16b2502ab434d4 Mon Sep 17 00:00:00 2001 From: Lorenzo Fasolino Date: Tue, 13 Oct 2026 18:31:27 +0200 Subject: [PATCH 0079/1452] fix(i18n): keep the Italian Dodge tool as Scherma; only Screen was wrong Review feedback on #5062: Adobe's Italian Photoshop calls the Dodge tool "Strumento Scherma" (Burn is "Strumento Brucia") and "Schiarisci" is the Lighten blend mode. Renaming the tool to Schiarisci would have given two different things the same name and split the Dodge/Burn/Sponge family the catalog already had (Scherma, Brucia, Spugna). Only the Screen blend mode changes: it read "Schermo" (the display) and is now "Scherma". The test pins the tool family and the blend modes. --- crates/ui-egui/src/i18n/it.tsv | 6 +++--- crates/ui-egui/src/i18n/mod.rs | 16 +++++++++------- docs/localization-it.md | 14 +++++++------- docs/localization-parity.md | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 95a77d5313..c05f96caa7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1655,7 +1655,7 @@ Document Dimensions Dimensioni documento Document Profile Profilo documento Document Sizes Dimensioni documento - Dodge Tool Strumento Schiarisci + Dodge Tool Strumento Scherma Down Giù Drag to draw a gradient Trascina per disegnare una sfumatura Drag to move the active layer Trascina per spostare livello attivo @@ -2228,7 +2228,7 @@ Move Sposta Pencil Matita Clone Stamp Timbro clone - Dodge Schiarisci + Dodge Scherma Burn Brucia Quick Selection Selezione rapida History Brush Pennello storia @@ -2795,7 +2795,7 @@ curves Smooth Leviga Patch Toppa Content-Aware Move Spostamento in base al contenuto Perspective Crop Taglierina prospettica - Dodge/Burn Schiarisci/Brucia + Dodge/Burn Scherma/Brucia Dynamics Dinamica Tilt Inclinazione Defaults restored. Predefiniti ripristinati. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 559edc7114..dc350c9cf1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1244,18 +1244,20 @@ mod tests { } } - /// The Italian catalog names the Dodge *tools* and the `Screen` *blend mode* the way Adobe - /// does: `Schiarisci` is the tool (`Lighten` already was), `Scherma` is the blend mode. They - /// were swapped — `Screen` read as `Schermo`, "the display" — which is the kind of mistake - /// only a speaker of the language catches, so the wording is pinned here. + /// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe + /// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend + /// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and + /// must not be used for the tool. `Screen` used to read `Schermo`, "the display". #[test] fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() { let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered"); - for (source, translation) in [("Dodge", "Schiarisci"), ("Dodge Tool", "Strumento Schiarisci"), ("Screen", "Scherma")] { + for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] { assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); } - // The qualified blend modes keep Adobe's "Scherma…" family: they are not the tool. - for (source, translation) in [("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] { + // The tool family stays together, and the blend modes keep Adobe's wording. + for (source, translation) in + [("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] + { assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); } } diff --git a/docs/localization-it.md b/docs/localization-it.md index 7a09598d4b..f983c499e4 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -45,16 +45,16 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast | Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) | | Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* | | Screen (blend mode) | Scherma | the blend mode; *Schermo* stays for the display (*Modalità schermo*, *Risoluzione schermo*) | -| Dodge / Dodge Tool | Schiarisci / Strumento Schiarisci | the *tools*, as *Lighten* already was; the qualified blend modes keep Adobe's family (*Colore scherma*, *Scherma lineare*) | +| Dodge / Burn / Sponge (tools) | Scherma / Brucia / Spugna | the *tools*, one family. *Scherma* is also the `Screen` blend mode (Adobe's own collision). *Schiarisci* is the `Lighten` blend mode and is never the Dodge tool | ## Native review -2026-10-13: a second pass reviewed the blend-mode and tool names against Adobe's Italian -vocabulary and found the two words swapped: `Screen` was *Schermo* (the display) and `Dodge` / -`Dodge Tool` were *Scherma* / *Strumento Scherma*. Corrected to *Scherma*, *Schiarisci* and -*Strumento Schiarisci*, with *Color Dodge* / *Linear Dodge* (`Colore scherma`, `Scherma lineare`) -left alone: they are blend modes, not the tool, and they already used Adobe's wording. The -wording is now pinned by a test (`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`). +2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the +word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*): +a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend +mode and would have given two different things the same name. The wording is pinned by a test +(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which +also keeps the tool family together. 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 76ab9e844c..9c50c9798f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -85,4 +85,4 @@ engine localization or native review is complete. | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | -| 2026-10-13 | minor | Italian: the `Screen` blend mode and the `Dodge` tools had their names swapped (`Schermo`/`Scherma` → `Scherma`/`Schiarisci`); pinned by a test | +| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test | From 6d37936db37f45aa884c6a9cc1186f1a2bfe7c93 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:50 +0300 Subject: [PATCH 0080/1452] i18n(sr): translate this PR's strings in the new Serbian catalog Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sr.tsv | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 72c6b4bee7..f5487f55ab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4202,3 +4202,11 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + +# Smart Filter editors + Blending Options ({filter}) Opcije stapanja ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Sakrij efekte i pametne filtere sloja ({key}-klik: svi slojevi) + Opacity must be between 0% and 100%. Neprozirnost mora biti između 0% i 100%. + Show the layer's effects and Smart Filters ({key}-click: all layers) Prikaži efekte i pametne filtere sloja ({key}-klik: svi slojevi) + The document or Smart Filter changed. Reopen its dialog. Dokument ili pametni filter se promenio. Ponovo otvorite njegov dijalog. + Wait for the current job to finish. Sačekajte da se trenutni posao završi. From 12ec8fceea65596de23fb866914529843754d8d5 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:53 +0300 Subject: [PATCH 0081/1452] i18n(sr): translate this PR's strings in the new Serbian catalog Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sr.tsv | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b2da769bf1..5e2bcc73cc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4204,3 +4204,20 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + +# Layers panel options + Add “copy” to Copied Layers and Groups Dodaj „kopija” kopiranim slojevima i grupama + Animation Options Opcije animacije + Entire Document Ceo dokument + Expand New Effects Proširi nove efekte + Filter Options Opcije filtera + Layer Bounds Granice sloja + Layers Panel Options Opcije panela Slojevi + New Artboard… Nova radna tabla… + New Group… Nova grupa… + Prevent Auto-Nesting Spreči automatsko ugnežđavanje + Preview on canvas when reordering layers Pregled na platnu pri promeni redosleda slojeva + Show Layers Prikaži slojeve + Show layer mask badges Prikaži oznake maske sloja + Thumbnail Contents Sadržaj sličice + Thumbnail Size Veličina sličice From dfb44a5e3e6c215f3365d647693f2708e13eec53 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:54:43 +0300 Subject: [PATCH 0082/1452] i18n(sr): translate Use Default Masks on Fill Layers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sr.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 5e2bcc73cc..4744ccdc3b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4221,3 +4221,6 @@ shape Appearance Izgled Show layer mask badges Prikaži oznake maske sloja Thumbnail Contents Sadržaj sličice Thumbnail Size Veličina sličice + +# Fill layers + Use Default Masks on Fill Layers Koristi podrazumevane maske na slojevima popune From c0faf7300cb8f16a71c62bd08666551243c2b249 Mon Sep 17 00:00:00 2001 From: zolyy Date: Tue, 13 Oct 2026 20:10:46 +0300 Subject: [PATCH 0083/1452] Type tool: click a path to start type on it (TYPE-218-1, #218) A Type tool click on a shown path (the active shape layer's, else the work path) starts type on that path at the point clicked, as in Photoshop: one history step, placeholder selected, the paragraph alignment kept about the click. Over such a path the pointer is the path-type I-beam, and the Type tool shows the paths as the vector tools do. - text: warp::arc_length_at, the arc length of the nearest point on a path. - engine: type.attachToPath takes "at": [x, y] (a document point) instead of "start". - ui: the caret, selection and underline follow the curve; a click in path type puts the caret at the glyph there; glyphs past the path end are not highlighted. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 + crates/engine/src/type_extra_cmds.rs | 24 +++- crates/engine/src/type_extra_cmds/tests.rs | 28 ++++ crates/text/src/warp.rs | 35 +++++ crates/ui-egui/src/canvas.rs | 9 ++ crates/ui-egui/src/type_tool.rs | 153 ++++++++++++++++++++- crates/ui-egui/src/vector_ui.rs | 3 +- docs/gaps.md | 7 +- docs/scorecard.md | 2 +- scorecard/type.toml | 2 +- 10 files changed, 253 insertions(+), 14 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..6dda48b4b1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,7 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.zolyy] +real_name = "Gall Zoltan" +sync_github_name = false diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index efa2d791cc..c63f7d3d7c 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -368,13 +368,24 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => false, Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, }; + // A document point: the type starts where the path passes nearest it (the Type tool's click). + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + Some(xy.filter(|(x, y)| x.is_finite() && y.is_finite()).ok_or_else(|| bad("\"at\" must be [x, y] in document pixels"))?) + } + }; + if at.is_some() && p.get("start").is_some_and(|v| !v.is_null()) { + return Err(bad("pass \"start\" or \"at\", not both")); + } let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; if doc.effective_locks(id).all { return Err(EngineError::Other("the type layer is locked".into())); } let sub = path_source(doc, p)?; - with_text(s, p, "Type on a Path", |t, _| { + let start = with_text(s, p, "Type on a Path", |t, _| { // The path lives in document space; the type layer keeps it in its own text space, so // the type lands on the path where it is drawn. let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; @@ -384,8 +395,15 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); } + let start = match at { + None => start, + Some((x, y)) => { + let q = map(Point::new(x, y)); + photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32 + } + }; t.shape = TextShape::Path { path, start, flip }; - Ok(()) + Ok(start) })?; Ok(json!({"layer": id.0, "start": start, "flip": flip})) } @@ -696,7 +714,7 @@ pub fn specs() -> Vec { "type.attachToPath", "Type on a Path", &[], - r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"at":[x,y]? (instead of "start": begin where the path passes nearest this document point, as a Type tool click does),"flip":bool=false (run along the path the other way, on its other side)}"##, has_text, attach_to_path ), diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 9f604eed1b..a2f7f734bd 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -313,6 +313,34 @@ fn type_attaches_to_a_path_and_releases_it() { assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); } +/// The Type tool's click on a path: `"at"` starts the type where the path passes nearest the +/// click, and the paragraph alignment is kept about that point, as in Photoshop. +#[test] +fn type_attaches_to_a_path_at_a_document_point() { + let mut s = session(8); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (290.0, 60.0)])])); + Ok(()) + }) + .unwrap(); + for (align, centred) in [("left", false), ("center", true)] { + let r = s.execute("type.create", json!({"x": 150, "y": 60, "text": "Path", "size": 24, "align": align, "color": "#000000"})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert!(s.execute("type.attachToPath", json!({"at": [150, "y"]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150, 60], "start": 3})).is_err()); + let r = s.execute("type.attachToPath", json!({"at": [150, 64]})).unwrap(); + assert!((r["start"].as_f64().unwrap() - 140.0).abs() < 0.5, "{r}"); + let b = ink(&s, id); + assert!(b.y1 <= 61, "the type sits on the path: {b:?}"); + if centred { + assert!(b.x0 < 145 && b.x1 > 155, "centred on the click: {b:?}"); + } else { + assert!((148..=156).contains(&b.x0), "starts at the click: {b:?}"); + } + } +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index f148a18e1b..439fc453bf 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -110,6 +110,13 @@ impl Warp { } } +/// The arc length along `path`, measured as type on it measures it (from the end it starts at +/// with `flip`), of the point on the path nearest (`x`, `y`): the `start` that puts the type's +/// origin where the Type tool clicked on the path. `None` for a path without length. +pub fn arc_length_at(path: &Subpath, flip: bool, x: f64, y: f64) -> Option { + PathFrame::new(path, 0.0, flip).map(|f| f.project(x, y)) +} + /// Most cubic segments of a type path that are followed (a hostile file cannot make this large). const MAX_PATH_SEGMENTS: usize = 4096; @@ -163,6 +170,22 @@ impl PathFrame { self.cum.last().copied().unwrap_or(0.0) } + /// Arc length of the point on the flattened path nearest (`x`, `y`). + fn project(&self, x: f64, y: f64) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, w) in self.pts.windows(2).enumerate() { + let [a, b] = [w[0], w[1]]; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let d = (a.0 + dx * t - x).hypot(a.1 + dy * t - y); + if d < best.0 { + best = (d, self.cum.get(i).copied().unwrap_or(0.0) + len2.sqrt() * t); + } + } + best.1 + } + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { let last = self.pts.len() - 1; @@ -211,6 +234,18 @@ mod tests { } } + /// The Type tool's click on a path: the nearest point's arc length, from the other end with flip. + #[test] + fn arc_length_at_projects_onto_the_path() { + let line = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)]); + let at = |flip, x, y| arc_length_at(&line, flip, x, y).unwrap(); + assert!((at(false, 30.0, -8.0) - 30.0).abs() < 1e-6); + assert!((at(false, 110.0, 20.0) - 120.0).abs() < 1e-6, "on the second segment"); + assert!((at(true, 30.0, -8.0) - 120.0).abs() < 1e-6, "flipped: measured from the far end"); + assert!(at(false, -50.0, 0.0).abs() < 1e-6, "clamped to the start"); + assert!(arc_length_at(&Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), false, 0.0, 0.0).is_none()); + } + #[test] fn styles_resolve_and_none_is_identity() { assert_eq!(psd_style("Arc"), Some("warpArc")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db0efe31fa..27ea383254 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3884,6 +3884,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: egui::CursorIcon::Crosshair } Tool::Move => egui::CursorIcon::Move, + // Over a path it can follow, the Type tool's path-type pointer (Photoshop). + Tool::Type + if app.drag.is_none() && { + let [x, y] = xf.to_doc(p); + crate::type_tool::path_at(app, x, y).is_some() + } => + { + crate::type_tool::path_cursor(&painter, p) + } // Dragging is handled with the pan itself, above. Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..531138721e 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -158,6 +158,14 @@ fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); + // Type on a path: the point's place along the path is the position in the (single) line. + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); + let (tx, ty) = match shape { + Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => { + photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0)) + } + _ => (tx, ty), + }; photocraft_text::hit_char(&l, &text, tx, ty).0 } @@ -319,6 +327,8 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { p["x"] = json!(start[0].round()); p["y"] = json!(start[1].round()); } + // A click on a shown path starts type on that path, from the point clicked (Photoshop). + let on_path = if p.get("box").is_none() { path_at(app, start[0], start[1]) } else { None }; let key = session_key(app); p["coalesce"] = json!(key); if let Ok(v) = app.run("type.create", p) @@ -327,6 +337,14 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { if o.type_aa != "sharp" { let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key})); } + if let Some(mut q) = on_path { + q["layer"] = json!(id); + q["at"] = json!([start[0], start[1]]); + q["coalesce"] = json!(key); + if let Err(e) = app.run("type.attachToPath", q) { + app.ui.status = e; + } + } // A Type Mask's text is a hidden working layer: the canvas shows the mask overlay // instead, and committing turns it into the selection. if mask { @@ -338,6 +356,53 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { } } +/// The shown path a Type tool click at document point (`x`, `y`) puts type on, as +/// `type.attachToPath` params naming it (`shape`, `subpath`): the active shape layer's path, else +/// the work path, within a few screen pixels, as in Photoshop. Only the horizontal Type tool +/// starts path type for now; vertical type on a path is not laid out yet. +pub(crate) fn path_at(app: &PhotocraftApp, x: f64, y: f64) -> Option { + if app.ui.tool != crate::state::Tool::Type || app.ui.text_edit.is_some() || !app.ui.view.shows(app.ui.view.show.target_path) { + return None; + } + let tol = f64::from(6.0 / app.point_zoom().max(0.01)); + let hit = |p: &photocraft_doc::vector::Path| { + let s = match photocraft_vector::edit::hit(p, Point::new(x, y), tol, &[])? { + photocraft_vector::edit::Hit::Anchor([s, _]) | photocraft_vector::edit::Hit::Segment([s, _], _) => s, + photocraft_vector::edit::Hit::Handle(..) => return None, + }; + p.subpaths.get(s).filter(|sp| sp.knots.len() >= 2).map(|_| s) + }; + if let Some((id, p)) = crate::vector_ui::active_shape_path(app) + && let Some(s) = hit(&p) + { + return Some(json!({"shape": id, "subpath": s})); + } + let work = app.session.active()?.doc.work_path.as_ref()?; + hit(work).map(|s| json!({"subpath": s})) +} + +/// The Type tool's pointer over a path it can follow: Photoshop's I-beam crossed by a dotted +/// wavy line, drawn on the canvas. +pub(crate) fn path_cursor(painter: &egui::Painter, at: Pos2) -> egui::CursorIcon { + let beam = |c: Color32, w: f32| { + let s = Stroke::new(w, c); + painter.line_segment([at + egui::vec2(0.0, -8.0), at + egui::vec2(0.0, 8.0)], s); + for dy in [-8.0, 8.0] { + painter.line_segment([at + egui::vec2(-3.0, dy), at + egui::vec2(3.0, dy)], s); + } + }; + beam(Color32::WHITE, 3.0); + beam(Color32::BLACK, 1.0); + // The wave: short dashes along a sine through the beam's lower half. + let wave = |t: f32| at + egui::vec2(-9.0 + 18.0 * t, 5.0 - 3.0 * t + 2.0 * (t * std::f32::consts::TAU * 1.5).sin()); + for i in (0..12).step_by(2) { + let (a, b) = (wave(i as f32 / 12.0), wave((i + 1) as f32 / 12.0)); + painter.line_segment([a, b], Stroke::new(3.0, Color32::WHITE)); + painter.line_segment([a, b], Stroke::new(1.0, Color32::BLACK)); + } + egui::CursorIcon::None +} + fn session_key(app: &mut PhotocraftApp) -> String { format!("type-{}", app.ui.alloc_id()) } @@ -998,10 +1063,24 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let p = aff.apply(Point::new(x as f64, y as f64)); xf.to_screen(p.x as f32, p.y as f32) }; + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); + // Type on a path: the caret, selection and underline follow the curve the glyphs sit on. + let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path); // Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise. let scr = |x: f32, y: f32| -> Pos2 { let (x, y) = l.to_text(x, y); - scr_t(x, y) + match &bend { + Some(w) => { + let (x, y) = w.apply(f64::from(x), f64::from(y)); + scr_t(x as f32, y as f32) + } + None => scr_t(x, y), + } + }; + // A line-space run from `x0` to `x1` at height `y`: sampled, so it bends with path type. + let run = |x0: f32, x1: f32, y: f32| -> Vec { + let n = if bend.is_some() { ((x1 - x0) / 4.0).ceil().clamp(1.0, 512.0) as usize } else { 1 }; + (0..=n).map(|i| scr(x0 + (x1 - x0) * i as f32 / n as f32, y)).collect() }; // Tell the OS where the caret is: this is what enables the IME and places its candidate window. { @@ -1017,7 +1096,6 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if crate::type_transform::draw(app, painter, xf, mods) { return; } - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { @@ -1034,7 +1112,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX for ln in &l.lines { // Horizontal: just under the baseline; vertical: the column's centre line. let y = if l.vertical { ln.baseline } else { ln.baseline + ln.descent * 0.25 }; - painter.line_segment([scr(ln.x0.min(0.0), y), scr(ln.x1.max(ln.x0 + 1.0), y)], Stroke::new(1.0, t.accent.gamma_multiply(0.8))); + painter.add(egui::Shape::line(run(ln.x0.min(0.0), ln.x1.max(ln.x0 + 1.0), y), Stroke::new(1.0, t.accent.gamma_multiply(0.8)))); } } } @@ -1047,7 +1125,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q))); if x0 < x1 { let y = ln.baseline + ln.descent * 0.5; - painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent)); + painter.add(egui::Shape::line(run(x0, x1, y), Stroke::new(1.5, t.accent))); } } } @@ -1064,8 +1142,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX x1 = x1.max(ln.x1 + (ln.ascent + ln.descent) * 0.25); x0 = x0.min(ln.x1); } - if x0 < x1 { - let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent); + let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent); + if bend.is_some() { + // On a path each glyph turns on its own, so each gets its own highlight. + // Glyphs past the end of the path are not drawn, so neither is their highlight. + let shown = |p: f32, q: f32| bend.as_ref().is_some_and(|w| w.place_glyph(f64::from(p + q) / 2.0) != photocraft_text::warp::Placement::Hidden); + for (p, q) in xs.into_iter().filter(|&(p, q)| shown(p, q)) { + painter.add(egui::Shape::convex_polygon(vec![scr(p, top), scr(q, top), scr(q, bot), scr(p, bot)], fill, Stroke::NONE)); + } + } else if x0 < x1 { painter.add(egui::Shape::convex_polygon(vec![scr(x0, top), scr(x1, top), scr(x1, bot), scr(x0, bot)], fill, Stroke::NONE)); } } @@ -2541,6 +2626,62 @@ mod tests { assert!(app.ui.text_edit.is_none()); } + /// TYPE-218-1: a Type tool click on the work path starts type on the path from the point + /// clicked, in one history step with the placeholder selected; a click off it is point text. + #[test] + fn click_on_a_path_starts_type_on_the_path() { + use photocraft_doc::text::TextShape; + let mut app = app(); + app.session + .edit("path", |doc, _| { + doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(20.0, 100.0), (380.0, 100.0)])])); + Ok(()) + }) + .unwrap(); + assert!(path_at(&app, 200.0, 101.0).is_some()); + assert!(path_at(&app, 200.0, 160.0).is_none(), "off the path"); + let before = app.session.active().unwrap().history.entries().len(); + assert!(!pointer_down(&mut app, 200.0, 101.0, egui::Modifiers::NONE)); + pointer_up(&mut app, [200.0, 101.0], [200.0, 101.0]); + let ed = app.ui.text_edit.clone().unwrap(); + assert_eq!((ed.anchor, ed.caret), (0, PLACEHOLDER.chars().count()), "the placeholder is selected"); + let shape = text_layer(&app.session.active().unwrap().doc, LayerId(ed.layer)).unwrap().shape.clone(); + assert!(matches!(shape, TextShape::Path { start, flip: false, .. } if (start - 180.0).abs() < 0.5), "{shape:?}"); + assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one step for the click"); + insert(&mut app, "On the line"); + assert_eq!(layer_text(&app), "On the line"); + commit(&mut app); + // With the vertical tool a click does not start path type. + app.ui.tool = crate::state::Tool::VerticalType; + assert!(path_at(&app, 200.0, 101.0).is_none()); + app.ui.tool = crate::state::Tool::Type; + pointer_up(&mut app, [200.0, 160.0], [200.0, 160.0]); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert_eq!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, TextShape::Point); + } + + /// A click in type on a path puts the caret at the glyph there along the curve, not where the + /// straight line would have it. + #[test] + fn caret_hits_type_on_a_path_along_the_curve() { + let mut app = app(); + app.session + .edit("path", |doc, _| { + doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(300.0, 10.0), (300.0, 195.0)])])); + Ok(()) + }) + .unwrap(); + pointer_up(&mut app, [300.0, 20.0], [300.0, 20.0]); + insert(&mut app, "ABCDEFGH"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert!(matches!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, photocraft_doc::text::TextShape::Path { .. })); + // The type runs down the path: near its start the caret is at the beginning, far down at the end. + assert_eq!(offset_at(&mut app, id, 300.0, 18.0), 0); + // (The straight line would put both of these at the start: they are left of its first glyph.) + let (mid, far) = (offset_at(&mut app, id, 296.0, 60.0), offset_at(&mut app, id, 296.0, 190.0)); + assert!(0 < mid && mid < far, "{mid} {far}"); + } + /// Preferences ▸ Units & Rulers ▸ Type switches the display unit of type metrics: points by /// default, pixels by the document's resolution, or millimetres. #[test] diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e9d998b046..3b4f21895a 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -931,7 +931,8 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); + // The Type tool shows the paths too: a click on one starts type on it (Photoshop). + let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection | Tool::Type) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..4aaba8506f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G8: a Type tool click on a path starts path type, TYPE-218-1 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -78,7 +78,9 @@ what an agent can't finish alone. - **Estimate:** 80–140 h. ### G8. Type depth -- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script +- **Missing:** the rest of type on a path (TYPE-218-1: glyphs along the curve, `type.attachToPath` and + the Type tool's click on a shown path landed; the caret on the curve, dragging the start, vertical + path type and PSD path text remain), paragraph direction UI (TYPE-218-2), complex-script fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type @@ -263,6 +265,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..b9a02ae955 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -210,7 +210,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | diff --git a/scorecard/type.toml b/scorecard/type.toml index ee8a876a41..dc15b5bb32 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -9,7 +9,7 @@ id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" status = "partial" issue = 218 -note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2" From ab596db6ae4682054b456dda4a6a30f89e42967d Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:24:03 +1100 Subject: [PATCH 0084/1452] Codecs: open Amiga IFF/ILBM bitmaps read-only Amiga IFF/ILBM (EA 85) artwork opens read-only: the FORM chunks BMHD, CMAP and BODY, both the interleaved ILBM and planar PBM variants, 1-8 bitplanes through the palette (a grey ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) compression. Chunk lengths and Run lengths are checked, so a malformed file errors rather than panicking; other bit-plane counts return Unsupported. No writer, so .iff/.lbm/.ilbm join the open filters and the format opens in Photoshop parity's Amiga IFF row. --- apps/photocraft-web/src/web.rs | 2 +- apps/photocraft/src/services.rs | 3 +- crates/codecs/README.md | 7 + crates/codecs/src/codecs/iff.rs | 287 +++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 13 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/detect_caps.rs | 13 ++ docs/file-format-parity.md | 2 +- docs/gaps.md | 12 +- 10 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 crates/codecs/src/codecs/iff.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..fa2d98d640 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,7 +18,7 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..9bafe574ef 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,7 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..04e3209fba 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -95,6 +96,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. +* **Amiga IFF (read-only).** `FORM ... ILBM` (interleaved) and `FORM ... PBM ` (planar) bitmaps + from the published IFF/ILBM layout: 1–8 bitplanes decoded through the `CMAP` palette (a grey + ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) + compression. Other bit-plane counts or compressions return `CodecError::Unsupported`; a + malformed chunk length or run errors rather than panicking. There is no IFF writer; save as PNG. + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/codecs/iff.rs b/crates/codecs/src/codecs/iff.rs new file mode 100644 index 0000000000..421d55c894 --- /dev/null +++ b/crates/codecs/src/codecs/iff.rs @@ -0,0 +1,287 @@ +//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the planar +//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte +//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data). +//! Read-only: modern saves should use PNG. +//! +//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2, +//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear +//! `Unsupported`/`Malformed` error, never a panic. + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Iff; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +fn be32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +fn be16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(u16::from_be_bytes(s)) +} + +/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` planar) at offset 8. +pub(crate) fn looks_like_iff(b: &[u8]) -> bool { + b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM ")) +} + +/// The bitmap header fields decoding needs. +struct Header { + width: u32, + height: u32, + planes: u8, + masking: u8, + compression: u8, + /// `ILBM` interleaves a row's planes; `PBM ` stores a whole plane before the next. + interleaved: bool, +} + +/// One four-byte chunk's id and byte range. +struct Chunk<'a> { + id: [u8; 4], + data: &'a [u8], +} + +/// Walk the `FORM`'s chunks from offset 12 to `end` (the form length), stopping at the first +/// malformed length so a hostile file can't run past the buffer. +fn chunks(b: &[u8]) -> impl Iterator> { + let end = be32(b, 4).map_or(b.len(), |n| (n as usize).saturating_add(8).min(b.len())); + let mut pos = 12usize; + std::iter::from_fn(move || { + if pos + 8 > end { + return None; + } + let id = b.get(pos..pos + 4)?.try_into().ok()?; + let len = be32(b, pos + 4)? as usize; + let start = pos + 8; + let data = b.get(start..start.checked_add(len)?)?; + // Chunks are padded to an even length. + pos = start.checked_add(len)?.checked_add(len & 1)?; + Some(Chunk { id, data }) + }) +} + +fn parse_header(b: &[u8], interleaved: bool) -> Result { + let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?; + let d = bmhd.data; + if d.len() < 20 { + return Err(bad("BMHD chunk is too short")); + } + let width = u32::from(be16(d, 0).ok_or_else(|| bad("BMHD width"))?); + let height = u32::from(be16(d, 2).ok_or_else(|| bad("BMHD height"))?); + let planes = *d.get(8).ok_or_else(|| bad("BMHD planes"))?; + let masking = *d.get(9).ok_or_else(|| bad("BMHD masking"))?; + let compression = *d.get(10).ok_or_else(|| bad("BMHD compression"))?; + if width == 0 || height == 0 { + return Err(bad("zero-sized bitmap")); + } + if !(1..=8).contains(&planes) { + return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)"))); + } + if masking > 2 { + return Err(unsupported(format!("masking {masking}"))); + } + if compression > 1 { + return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)"))); + } + Ok(Header { width, height, planes, masking, compression, interleaved }) +} + +/// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80` +/// control byte is a no-op, as the layout specifies. +fn unpack_byte_run1(src: &[u8], out: &mut [u8]) -> Result { + let (mut i, mut o) = (0usize, 0usize); + while o < out.len() { + let n = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 data"))? as i8; + i += 1; + if n >= 0 { + let count = n as usize + 1; + let lit = src.get(i..i.checked_add(count).ok_or_else(|| bad("ByteRun1 overflow"))?).ok_or_else(|| bad("truncated ByteRun1 run"))?; + if count > out.len() - o { + return Err(bad("ByteRun1 run crosses the plane row")); + } + out[o..o + count].copy_from_slice(lit); + i += count; + o += count; + } else if n != -128 { + let count = (1 - n as i32) as usize; + let value = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 repeat"))?; + i += 1; + if count > out.len() - o { + return Err(bad("ByteRun1 repeat crosses the plane row")); + } + out[o..o + count].fill(value); + o += count; + } + } + Ok(i) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !looks_like_iff(b) { + return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)")); + } + let interleaved = b.get(8..12) == Some(b"ILBM"); + let h = parse_header(b, interleaved)?; + limits.check(h.width, h.height, ChannelLayout::Rgb, SampleType::U8)?; + + // Palette: three bytes per colour; missing entries read as black. + let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data); + let colors = 1usize << h.planes; + let palette = |idx: usize| -> [u8; 3] { + cmap.and_then(|p| p.get(idx * 3..idx * 3 + 3)).map(|c| [c[0], c[1], c[2]]).unwrap_or({ + // No palette: a grey ramp across the index range. + let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8; + [v, v, v] + }) + }; + + let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?; + let (w, hh) = (h.width as usize, h.height as usize); + // Each plane row is stored as a whole number of 16-bit words. + let plane_row = w.div_ceil(16) * 2; + // A masking type 1 bitmap carries one extra (mask) plane per row. + let rows_planes: usize = if h.masking == 1 { usize::from(h.planes) + 1 } else { usize::from(h.planes) }; + + // Decode every stored plane row up front, then recombine. + let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + let mut stored = vec![0u8; total_bytes]; + let mut cursor = 0usize; + for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) { + match h.compression { + 0 => { + let src = body.get(cursor..cursor.checked_add(plane_row).ok_or_else(|| bad("BODY overflow"))?).ok_or_else(|| bad("truncated BODY data"))?; + let n = row.len().min(src.len()); + row[..n].copy_from_slice(&src[..n]); + cursor += plane_row; + } + _ => { + let tail = body.get(cursor..).ok_or_else(|| bad("truncated BODY data"))?; + cursor += unpack_byte_run1(tail, row)?; + } + } + } + + let mut out = Vec::new(); + out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(3)).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + let planes = usize::from(h.planes); + for y in 0..hh { + for x in 0..w { + let index = if h.interleaved { + // Row-major, interleaved: row y, plane p sits at (y * rows_planes + p). + (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + } else { + // Planar: plane p's rows come first, then the next plane. + (0..planes).filter(|&p| bit_at(&stored, (p * hh + y) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + }; + let c = palette(index); + out.extend_from_slice(&c); + } + } + Image::from_u8(h.width, h.height, ChannelLayout::Rgb, out) +} + +/// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first). +fn bit_at(stored: &[u8], base: usize, x: usize) -> bool { + stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `FORM ILBM` with a `BMHD`, `CMAP` and `BODY`. `planes` plane rows per row, + /// `compression` 0 (stored) or 1 (ByteRun1), MSB-first. + fn ilbm(w: u16, h: u16, planes: u8, palette: &[[u8; 3]], compression: u8, body: &[u8]) -> Vec { + let mut b = b"FORM".to_vec(); + b.extend_from_slice(&0u32.to_be_bytes()); // patched below + b.extend_from_slice(b"ILBM"); + // BMHD (20 bytes) + b.extend_from_slice(b"BMHD"); + b.extend_from_slice(&20u32.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&[0, 0, 0, 0]); // x/y origin + b.push(planes); + b.push(0); // masking + b.push(compression); + b.push(0); // pad + b.extend_from_slice(&[0, 0]); // transparent colour + b.extend_from_slice(&[1, 1]); // aspect + b.extend_from_slice(&[0, 0, 0, 0]); // page size + // CMAP + b.extend_from_slice(b"CMAP"); + b.extend_from_slice(&((palette.len() * 3) as u32).to_be_bytes()); + for c in palette { + b.extend_from_slice(c); + } + // BODY + b.extend_from_slice(b"BODY"); + b.extend_from_slice(&(body.len() as u32).to_be_bytes()); + b.extend_from_slice(body); + if body.len() % 2 == 1 { + b.push(0); + } + let size = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&size.to_be_bytes()); + b + } + + #[test] + fn reads_a_stored_two_plane_bitmap() { + // 2×1, 2 planes: pixel 0 index 0 (plane bits 0,0), pixel 1 index 3 (bits 1,1). + // Plane 0 = 0b0100_0000, plane 1 = 0b0100_0000, each padded to 2 bytes. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120]]; + let b = ilbm(2, 1, 2, &pal, 0, &body); + assert!(looks_like_iff(&b)); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!(img.data(), &[10, 20, 30, 100, 110, 120]); + } + + #[test] + fn reads_byte_run1_compressed_rows() { + // Same image, ByteRun1: literal run of two bytes (0x01) then the two plane bytes each row. + let body = [0x01, 0b0100_0000, 0, 0x01, 0b0100_0000, 0]; + let pal = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [250, 251, 252]]; + let b = ilbm(2, 1, 2, &pal, 1, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 250, 251, 252]); + } + + #[test] + fn no_palette_falls_back_to_a_grey_ramp() { + // 2×1, 1 plane, no CMAP: indices 0 and 1 → black and white. + let body = [0b0100_0000, 0]; + let b = ilbm(2, 1, 1, &[], 0, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]); + } + + #[test] + fn hostile_input_errors_not_panics() { + // Truncations of a valid file never panic. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let full = ilbm(2, 1, 2, &[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], 0, &body); + for cut in [0, 4, 8, 12, 40, full.len() - 1] { + assert!(decode(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + // Too many planes is unsupported. + let bad = ilbm(1, 1, 9, &[], 0, &[0, 0]); + assert!(matches!(decode(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..1b1683f4a3 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod exr_cryptomatte; pub(crate) mod fits; pub(crate) mod gif_frames; pub(crate) mod heif; +pub(crate) mod iff; pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..e5fd1e1199 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,6 +33,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only. + Iff, } /// What a format can hold **and** what this crate reads/writes for it. @@ -65,6 +67,7 @@ pub struct FormatCaps { /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), + (Format::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", @@ -95,7 +98,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +118,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Iff, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +152,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Iff => "Amiga IFF", } } @@ -174,6 +179,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Iff => &["iff", "lbm", "ilbm"], } } @@ -198,6 +204,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Iff => "image/x-iff", } } @@ -253,6 +260,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb], alpha: false, ..base }, } } @@ -307,6 +315,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::iff::looks_like_iff(b) { + return Some(Format::Iff); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..88cc171678 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -79,6 +79,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Iff => iff::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -231,6 +232,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..12bfdc4c31 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,19 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_iff() { + // FORM + a four-byte length + the bitmap type. + assert_eq!(detect(b"FORM\0\0\0\x14ILBM"), Some(Format::Iff)); + assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff)); + assert_eq!(from_extension("art.LBM"), Some(Format::Iff)); + assert_eq!(from_extension("art.ilbm"), Some(Format::Iff)); + assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff)); + // A bare FORM without a bitmap type is not IFF. + assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None); + assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..d6681d4224 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -47,7 +47,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | -| Amiga IFF | R/W | no | no | | +| Amiga IFF | R/W | yes | no | Read-only: 1–8 planes with CMAP, stored and ByteRun1 | | Wireless Bitmap | R/W | no | no | | | MPO / JPS (stereo) | R/W | no | no | | | PICT | R | no | no | Legacy | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..949eb68328 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049, and Amiga IFF read; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -220,10 +220,13 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); C2PA (#1050). + Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF write (read landed), WBMP, MPO/JPS, + PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates + (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Amiga IFF/ILBM (`FORM ... ILBM`/`PBM `) 1–8-plane paletted bitmaps + open read-only, stored or ByteRun1-compressed (`crates/codecs/src/codecs/iff.rs`); writing is + still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -263,6 +266,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G10: Amiga IFF/ILBM read landed (1–8 planes, CMAP, stored and ByteRun1); IFF write remains open | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From 69cd2abafc10a60a563060461355db5641b5bf27 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:19:24 +0300 Subject: [PATCH 0085/1452] Brush Settings: disable Wetness/Mix controls outside Mixer Brush Pass the active tool to the section renderer and disable the complete Wetness/Mix Transfer blocks when they do not apply, preserving saved settings. Cover all themes/tools, disabled input, active edits and real tool-cache swaps with three regressions, including exact tools.setBrush journal routing. Addresses the Wetness/Mix tool-applicability subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel.rs | 8 +- crates/ui-egui/src/brush_panel_tests.rs | 58 ++++++++++ crates/ui-egui/src/brush_sections.rs | 146 +++++++++++++++++++++++- docs/brush-parity.md | 4 +- docs/gaps.md | 6 +- 5 files changed, 211 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 515e6e5a21..efae719f10 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -369,9 +369,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The picked preset is recorded by name (`brush.presets.setCurrent`, identity, so // look-alike duplicates stay distinct), since the engine can't infer it from the // brush afterwards. - ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref())) - .inner - .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); + ui.add_enabled_ui(on, |ui| { + section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), app.ui.tool) + }) + .inner + .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); }); }); }); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..4713b9610a 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,64 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn transfer_mixer_edits_use_the_brush_command_and_survive_real_tool_swaps() { + use crate::state::Tool; + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 6); + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + a.session.tools.brush.transfer.enabled = true; + a.session.tools.brush.transfer.wetness = paint::Dynamic { jitter: 0.41, minimum: 0.43, control: Control::PenPressure, fade_steps: 339 }; + a.session.tools.brush.transfer.mix = paint::Dynamic { jitter: 0.63, minimum: 0.67, control: Control::PenPressure, fade_steps: 449 }; + crate::paint_mouse::sync_tool_brush(a); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + h.hover_at(h.get_by_label("Opacity Jitter").rect().center()); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -250.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().session.tools.brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + assert_eq!(h.state().session.journal.len(), journal + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"transfer": {"wetness": {"jitter": 0.42_f32}}})); + let mixer = h.state().session.tools.brush.clone(); + + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + let brush = h.state().session.tools.brush.clone(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, brush); + assert_eq!(h.state().session.journal.len(), journal + 1, "inactive edits and tool swaps add no brush command"); + let a = h.state_mut(); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, mixer, "the Mixer Brush keeps its own edited settings"); + assert!( + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled()) + ); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..65e5abc821 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -8,6 +8,7 @@ use photocraft_engine::BrushSettings; use photocraft_engine::paint::{self, BrushPreset, Control, Dynamic, MAX_BRUSH_SIZE, MaskMode, Pattern, PatternStyle, TipShape}; use crate::brush_preview; +use crate::state::Tool; use crate::theme::{self, Tokens}; use crate::widgets; @@ -518,12 +519,16 @@ fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { signed_pct(ui, tl!("Purity"), &mut c.purity, -1.0, 1.0); } -fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) { +fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings, tool: Tool) { let tr = &mut b.transfer; dynamic(ui, "brush-opacity-ctl", tl!("Opacity Jitter"), &mut tr.opacity, 1.0, Some(tl!("Minimum")), false); dynamic(ui, "brush-flow-ctl", tl!("Flow Jitter"), &mut tr.flow, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + // Wetness and mix only affect the Mixer Brush. Keep their settings visible and saved + // for other tools, but disable the whole blocks, including controllers and minimums. + ui.add_enabled_ui(tool == Tool::MixerBrush, |ui| { + dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); + dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + }); } /// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options @@ -576,7 +581,8 @@ fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) { /// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids. /// Returns the name of a preset whose tip was picked in the Brush Tip Shape grid, for /// `brush.presets.setCurrent`. -pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>) -> Option { +/// The active `tool` determines which Transfer controls apply. +pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>, tool: Tool) -> Option { let t = Tokens::get(ui.ctx()); let note = |ui: &mut egui::Ui, s: &str| { ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5))); @@ -591,7 +597,7 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: 4 => dual_brush(ui, b, presets), 5 => color_dynamics(ui, b), 6 => { - transfer(ui, b); + transfer(ui, b, tool); mixer_options(ui, b); } 7 => pose(ui, b), @@ -637,7 +643,7 @@ mod tests { ready = true; return; } - section_body(ui, b, section, &[], None); + section_body(ui, b, section, &[], None, Tool::MixerBrush); }, b, ); @@ -668,6 +674,134 @@ mod tests { } } + struct TransferState { + brush: BrushSettings, + tool: Tool, + } + + fn transfer_mixer_brush() -> BrushSettings { + let mut b = BrushSettings::default(); + b.transfer.enabled = true; + for (dynamic, jitter, minimum, fade_steps) in [ + (&mut b.transfer.opacity, 0.13, 0.17, 119), + (&mut b.transfer.flow, 0.27, 0.29, 229), + (&mut b.transfer.wetness, 0.41, 0.43, 339), + (&mut b.transfer.mix, 0.63, 0.67, 449), + ] { + *dynamic = Dynamic { control: Control::Fade, jitter, minimum, fade_steps }; + } + b + } + + fn transfer_mixer_harness(kind: theme::ThemeKind, tool: Tool) -> Harness<'static, TransferState> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(340.0, 1400.0)).build_ui_state( + move |ui, state: &mut TransferState| { + if !ready { + crate::PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + ui.add_enabled_ui(state.brush.transfer.enabled, |ui| section_body(ui, &mut state.brush, 6, &[], None, state.tool)); + }, + TransferState { brush: transfer_mixer_brush(), tool }, + ); + h.run_steps(3); + h + } + + /// #3825: Mixer-only blocks follow the tool, including sliders, minimums and Fade steps. + #[test] + fn transfer_mixer_controls_follow_the_tool_and_section_in_every_theme() { + for kind in theme::ThemeKind::ALL { + let mut h = transfer_mixer_harness(kind, Tool::Brush); + let before = h.state().brush.clone(); + for tool in Tool::ALL { + h.state_mut().tool = tool; + h.run_steps(2); + for (mixer_only, values) in [(false, [13.0, 17.0, 119.0, 27.0, 29.0, 229.0]), (true, [41.0, 43.0, 339.0, 63.0, 67.0, 449.0])] { + for value in values { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + assert_eq!(field.accesskit_node().is_disabled(), mixer_only && tool != Tool::MixerBrush, "{kind:?}, {tool:?}, {value}"); + } + } + for (index, node) in h.query_all_by_role(Role::ComboBox).enumerate() { + assert_eq!(node.accesskit_node().is_disabled(), index >= 2 && tool != Tool::MixerBrush, "{kind:?}, {tool:?}: controller {index}"); + } + assert_eq!(h.state().brush, before, "changing tool context only changes availability"); + } + h.state_mut().tool = Tool::MixerBrush; + h.state_mut().brush.transfer.enabled = false; + h.run_steps(2); + assert!(h.query_all_by_role(Role::SpinButton).all(|node| node.accesskit_node().is_disabled()), "section Off still disables every field"); + assert!(h.query_all_by_role(Role::ComboBox).all(|node| node.accesskit_node().is_disabled())); + h.state_mut().brush.transfer.enabled = true; + h.state_mut().brush.transfer.wetness.control = Control::Off; + h.run_steps(2); + let minimum = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!(minimum.accesskit_node().is_disabled(), "Control Off still disables the applicable minimum"); + } + } + + #[test] + fn transfer_mixer_controls_ignore_disabled_edits_and_keep_values_on_reactivation() { + let mut h = transfer_mixer_harness(theme::ThemeKind::ProMedium, Tool::Brush); + let before = h.state().brush.clone(); + for value in [41.0, 43.0, 339.0, 63.0, 67.0, 449.0] { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + let at = field.rect().center(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + h.hover_at(at); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at + vec2(60.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "inactive field {value} ignores keyboard and pointer input"); + } + for label in ["Wetness Jitter", "Mix Jitter"] { + let row = h.get_by_label(label).rect(); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "the inactive {label} track ignores a drag"); + } + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + let row = h.get_by_label("Wetness Jitter").rect(); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert!(h.state().brush.transfer.wetness.jitter > 0.6, "the same track accepts a drag when applicable"); + h.state_mut().brush.transfer.wetness.jitter = 0.41; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + let after = h.state().brush.clone(); + h.state_mut().tool = Tool::Brush; + h.run_steps(2); + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + assert_eq!(h.state().brush, after); + assert!(h.query_all_by_role(Role::SpinButton).any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled())); + } + fn set_controls(b: &mut BrushSettings, control: Control) { for d in [ &mut b.shape_dynamics.size, diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..d973482f83 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Transfer Wetness/Mix tool applicability; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -26,6 +26,7 @@ tablets against Photoshop side by side. | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | | Hue Jitter uses a standard percentage-amount track instead of a hue spectrum | done | #3825 (Hue Jitter appearance only); rendered track checked in all 14 themes; numeric and pointer edits retain percentage units and engine command routing. | | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | +| Transfer Wetness/Mix blocks disabled outside Mixer Brush, retaining their settings | done | #3825 (tool applicability only); UI regressions cover all 14 themes and 67 tools, disabled input, section/Control Off, command routing and per-tool settings | | Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..f2571d35b9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Transfer Mixer-only controls, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,9 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Transfer’s Wetness Jitter and Mix Jitter + blocks are disabled outside the Mixer Brush, retaining their saved settings. Opacity + and Flow keep their existing availability; other layout and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -263,6 +266,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G4: Transfer Wetness/Mix tool applicability (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From f28d3978ea32bb94ba5aff786ba1cf4f46b33a35 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 20:20:02 +0200 Subject: [PATCH 0086/1452] Sort the engine module list after the merge (cargo fmt) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5a71019877..989f9ff3b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -54,8 +54,8 @@ pub mod group_view_cmds; pub mod guide_layout; pub mod hidden_target; pub mod history_cmds; -pub mod history_source_cmds; pub mod history_select_cmds; +pub mod history_source_cmds; pub mod image_cmds; pub mod inspect; pub mod installed_profiles; From 085c29bea76bb9f13a23e897a81d969081ea39a0 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 05:50:25 +1100 Subject: [PATCH 0087/1452] Wrap OPEN_EXTS so cargo fmt --check passes. CI failed rustfmt on the DICOM extension lines in the desktop and web open-file lists. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index ea3a8b564e..290561ee2e 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 5bcf454438..f359aea96d 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", + "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From f805b12a17763ca64cc5c489ab2f5b64aec2c9ff Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 21:47:41 +0200 Subject: [PATCH 0088/1452] PSD: a flat Bitmap file saves in Bitmap mode and opens as a Background again #3560's test expected the flat Bitmap file to be saved with a layer section. With #4814 Bitmap is written in its own mode, which has no layers in Photoshop: the test now checks the Bitmap header, the missing layer section and that the saved file reopens with its Background. --- crates/io/src/psd_export.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4a2cd1a348..43d829da05 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1853,8 +1853,14 @@ mod tests { let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; let bg = &doc.layers[0]; assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); + // Bitmap has no layers in Photoshop (#4814): the file is saved flat in Bitmap mode, without + // a layer section, and opens as a Background again. let out = document_to_psd(&doc); - assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0)); + assert_eq!(out.header.color_mode, PsdMode::Bitmap); + assert!(out.layer_info.as_ref().is_none_or(|li| li.layers.is_empty())); + let back = crate::import("flat.psd", &out.to_bytes().unwrap()).unwrap().document; + let bg = &back.layers[0]; + assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } #[test] From 811a40bda2db6b3d7b70665517a68beb300df8dd Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:47:21 +0300 Subject: [PATCH 0089/1452] Brush Pose: preserve independent tilt-axis overrides Keep X/Y flags through ABR import, presets, commands and painting; retain legacy combined inputs and expose both controls with regression coverage. Co-authored-by: GPT-6 --- crates/engine/src/brush_cmds.rs | 29 ++++++++- crates/engine/src/brush_cmds/tests.rs | 51 +++++++++++++++ crates/io/src/abr_map.rs | 3 +- crates/io/src/abr_map/tests.rs | 39 ++++++++++- crates/paint/src/brush.rs | 84 ++++++++++++++++++++++-- crates/paint/src/brush_tests.rs | 87 +++++++++++++++++++++++++ crates/paint/src/dynamics.rs | 4 +- crates/ui-egui/src/brush_panel_tests.rs | 52 ++++++++++++++- crates/ui-egui/src/brush_sections.rs | 38 ++++++++++- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/widgets.rs | 21 ++++-- docs/brush-parity.md | 4 +- docs/control-protocol.md | 2 + docs/gaps.md | 8 ++- 33 files changed, 445 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..8be6929356 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -133,6 +133,18 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Value { _ => (a != b).then(|| b.clone()), } } - diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})) + let mut patch = diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})); + // This readback is derived from the axis flags. Sending it alongside a + // one-axis delta would also overwrite the other axis through the legacy alias. + if let Some(pose) = patch.get_mut("pose").and_then(Value::as_object_mut) { + pose.remove("overrideTilt"); + } + patch } /// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same @@ -1142,7 +1160,14 @@ pub fn specs() -> Vec { spec!("brush.presets.setCurrent", "Set Current Brush Preset", r##"{"name":string}"##, always, presets_set_current, true), spec!("brush.defineFromSelection", "Define Brush Preset…", r##"{"name":string}"##, has_selection_and_pixels, define_from_selection, true), spec!("brush.get", "Get Brush", "{}", always, |s, _| Ok(brush_json(&s.tools.brush)), false), - spec!("tools.setBrush", "Set Brush", r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged)}"##, always, set_brush, true), + spec!( + "tools.setBrush", + "Set Brush", + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + always, + set_brush, + true + ), ] } diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..8ffd008ae1 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -652,6 +652,57 @@ fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() { } } +#[test] +fn pose_axes_commands_keep_minimal_patches_legacy_alias_and_preset_replay() { + let mut s = session(100, 80); + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + s.execute("tools.setBrush", json!({"brush": {"pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, tilt_y: -18.0, ..input }); + let before = s.tools.brush.clone(); + s.execute("tools.setBrush", json!({"pose": {"overrideTiltX": false}})).unwrap(); + assert_eq!( + photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), + StrokePoint { tilt_y: -18.0, ..input }, + "turning X off retains Y override and live X input" + ); + let mixed = s.tools.brush.clone(); + assert_eq!(brush_patch(&before, &mixed), json!({"pose": {"overrideTiltX": false}}), "the derived combined flag must not enter an axis-only patch"); + let wire = s.execute("brush.get", json!({})).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], false); + assert_eq!(wire["pose"]["overrideTiltY"], true); + assert_eq!(wire["pose"]["overrideTilt"], false, "legacy readback reports whether both axes are overridden"); + s.execute("brush.presets.save", json!({"name": "Independent pose"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), input, "legacy false clears both axes even after axis editing"); + s.execute("tools.setBrush", json!({"preset": "Independent pose"})).unwrap(); + assert_eq!(s.tools.brush.pose, mixed.pose, "preset picking preserves independent state"); + // Explicit axis keys win over a legacy combined key in the same command. + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": true, "overrideTiltY": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, ..input }); + let mut replay = Session::new(); + for (id, params) in &s.journal { + replay.execute(id, params.clone()).unwrap(); + } + assert_eq!(replay.tools.brush, s.tools.brush, "recorded commands reproduce the same axes"); +} + +#[test] +fn pose_axes_invalid_flags_fail_atomically() { + let mut s = session(100, 80); + let before = s.tools.brush.clone(); + let n = s.journal.len(); + for pose in [ + json!({"overrideTiltX": "yes"}), + json!({"overrideTiltY": null}), + json!({"overrideTiltX": true, "overrideTiltY": 1}), + json!({"overrideTilt": "yes", "overrideTiltX": false}), + ] { + assert!(s.execute("tools.setBrush", json!({"brush": {"pose": pose}})).is_err()); + assert_eq!(s.tools.brush, before); + assert_eq!(s.journal.len(), n, "a failed update records no command"); + } +} + #[test] fn new_brush_controls_round_trip_through_set_brush() { let mut s = session(40, 40); diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index c21800f13d..dfc5e78847 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -472,7 +472,8 @@ impl Mapper<'_> { tilt_y: (num(d, "brushPoseTiltY").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32, rotation: num(d, "brushPoseAngle").unwrap_or(0.0).rem_euclid(360.0) as f32, pressure: pct(d, "brushPosePressure").unwrap_or(1.0).clamp(0.0, 1.0), - override_tilt: boolean(d, "overridePoseTiltX").unwrap_or(false) || boolean(d, "overridePoseTiltY").unwrap_or(false), + override_tilt_x: boolean(d, "overridePoseTiltX").unwrap_or(false), + override_tilt_y: boolean(d, "overridePoseTiltY").unwrap_or(false), override_rotation: boolean(d, "overridePoseAngle").unwrap_or(false), override_pressure: boolean(d, "overridePosePressure").unwrap_or(false), }; diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..46ac1c5473 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -128,6 +128,43 @@ fn grit_pattern() -> PsdPattern { } } +#[test] +fn pose_axes_abr_import_preserves_independent_stylus_tilt() { + let input = photocraft_paint::StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, wheel: 0.24, time: 73.0, ..Default::default() }; + for subversion in [1, 2] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let preset = Descriptor::new("brushPreset") + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 47.0)))) + .with("useBrushPose", Value::Boolean(true)) + .with("brushPoseTiltX", prc(50.0)) + .with("brushPoseTiltY", prc(-20.0)) + .with("overridePoseTiltX", Value::Boolean(x)) + .with("overridePoseTiltY", Value::Boolean(y)); + let bytes = write_v6(subversion, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Pose axes").unwrap(); + assert_eq!(imported.presets.len(), 1); + let b = &imported.presets[0].brush; + assert!(b.pose.enabled); + assert_eq!((b.pose.tilt_x, b.pose.tilt_y), (45.0, -18.0), "ABR percentages retain the W3C degree conversion"); + let mut expected = input; + if x { + expected.tilt_x = 45.0; + } + if y { + expected.tilt_y = -18.0; + } + assert_eq!( + photocraft_paint::dynamics::apply_pose(b, input), + expected, + "v6.{subversion}, X={x}, Y={y}: one axis must not override the other's tablet data" + ); + let stored = serde_json::to_vec(&imported.presets[0]).unwrap(); + let restored: photocraft_paint::BrushPreset = serde_json::from_slice(&stored).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&restored.brush, input), expected, "preset persistence retains the independent flags"); + } + } +} + #[test] fn v6_settings_map_onto_brush_sections() { for sub in [1, 2] { @@ -166,7 +203,7 @@ fn v6_settings_map_onto_brush_sections() { assert!(b.transfer.enabled); assert_eq!((b.transfer.opacity.control, b.transfer.opacity.minimum), (Control::PenPressure, 0.1)); assert_eq!((b.transfer.flow.control, b.transfer.flow.jitter), (Control::Fade, 0.15)); - assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt); + assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt_x && !b.pose.override_tilt_y); assert_eq!((b.pose.tilt_x, b.pose.rotation), (45.0, 90.0)); assert!(b.wet_edges && b.noise && b.build_up); assert_eq!((b.opacity, b.flow, b.smoothing.amount, b.pressure_size), (0.8, 0.6, 0.4, true)); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 0dd4d21d65..7bf78ac6ee 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -405,9 +405,9 @@ pub struct SectionLocks { pub protect_texture: bool, } -/// Brush Pose: stylus values used instead of (override) or for missing device data. +/// Brush Pose: fixed values used for the selected stylus-component overrides. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] +#[serde(from = "PoseWire", into = "PoseWire")] pub struct Pose { pub enabled: bool, /// Degrees, -90..90 (W3C Pointer Events convention). @@ -417,7 +417,8 @@ pub struct Pose { pub rotation: f32, /// 0..1. pub pressure: f32, - pub override_tilt: bool, + pub override_tilt_x: bool, + pub override_tilt_y: bool, pub override_rotation: bool, pub override_pressure: bool, } @@ -430,13 +431,88 @@ impl Default for Pose { tilt_y: 0.0, rotation: 0.0, pressure: 1.0, - override_tilt: false, + override_tilt_x: false, + override_tilt_y: false, override_rotation: false, override_pressure: false, } } } +// Old presets used one combined flag. Keep that JSON input and readback while the +// model has only the two independent sources of truth. Explicit axis fields win. +#[derive(Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PoseWire { + enabled: bool, + tilt_x: f32, + tilt_y: f32, + rotation: f32, + pressure: f32, + override_tilt: bool, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_x: Option, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_y: Option, + override_rotation: bool, + override_pressure: bool, +} + +// Missing axis keys migrate from the old flag; a present null/non-boolean is an error. +fn pose_axis_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + bool::deserialize(d).map(Some) +} + +impl Default for PoseWire { + fn default() -> Self { + Self { + enabled: false, + tilt_x: 0.0, + tilt_y: 0.0, + rotation: 0.0, + pressure: 1.0, + override_tilt: false, + override_tilt_x: None, + override_tilt_y: None, + override_rotation: false, + override_pressure: false, + } + } +} + +impl From for Pose { + fn from(p: PoseWire) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt_x: p.override_tilt_x.unwrap_or(p.override_tilt), + override_tilt_y: p.override_tilt_y.unwrap_or(p.override_tilt), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + +impl From for PoseWire { + fn from(p: Pose) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt: p.override_tilt_x && p.override_tilt_y, + override_tilt_x: Some(p.override_tilt_x), + override_tilt_y: Some(p.override_tilt_y), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + /// Stroke smoothing (Photoshop's Smoothing % and its options). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 284b7ae3b9..77e7183a7f 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -852,6 +852,93 @@ fn smoothing_without_catch_up_stops_while_the_pointer_pauses() { assert_eq!(out.len(), n, "held still: the brush doesn't move"); } +#[test] +fn pose_axes_override_only_the_selected_stylus_components() { + let input = StrokePoint { x: 20.0, y: 30.0, pressure: 0.71, tilt_x: 13.0, tilt_y: -27.0, rotation: 67.0, wheel: 0.24, time: 73.0 }; + for enabled in [false, true] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, + "overrideTiltX": x, "overrideTiltY": y, + "pressure": 0.4, "rotation": 120 + }})) + .unwrap(); + let mut expected = input; + if enabled && x { + expected.tilt_x = 45.0; + } + if enabled && y { + expected.tilt_y = -18.0; + } + assert_eq!(crate::dynamics::apply_pose(&b, input), expected, "enabled={enabled}, X={x}, Y={y}"); + } + } +} + +#[test] +fn pose_axes_reach_dabs_and_pixels_at_all_rgba_depths() { + let points = [ + StrokePoint { tilt_x: 13.0, tilt_y: -27.0, ..StrokePoint::new(20.0, 30.0, 0.71) }, + StrokePoint { tilt_x: -31.0, tilt_y: 42.0, ..StrokePoint::new(75.0, 45.0, 0.83) }, + ]; + for (x, y) in [(true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({ + "size": 20, "roundness": 0.47, "angle": -23, + "pressureSize": false, "smoothing": {"enabled": false}, + "shapeDynamics": {"enabled": true, "brushProjection": true}, + "pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTiltX": x, "overrideTiltY": y} + })) + .unwrap(); + let expected_points: Vec<_> = + points.iter().map(|p| StrokePoint { tilt_x: if x { 45.0 } else { p.tilt_x }, tilt_y: if y { -18.0 } else { p.tilt_y }, ..*p }).collect(); + let mut expected_brush = b.clone(); + expected_brush.pose.enabled = false; + let actual_dabs = dabs_of(&b, &points); + let expected_dabs = dabs_of(&expected_brush, &expected_points); + assert_eq!(actual_dabs.len(), expected_dabs.len()); + for (index, (actual, expected)) in actual_dabs.iter().zip(&expected_dabs).enumerate() { + assert_eq!(actual, expected, "resolved tilt reaches dab {index}"); + } + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut actual = Surface::new(format); + let mut expected = Surface::new(format); + render_stroke(&mut actual, &b, &points, None, false, 1.0); + render_stroke(&mut expected, &expected_brush, &expected_points, None, false, 1.0); + assert!(actual.rgba(20, 30)[3] > 0.0, "the comparison includes painted pixels"); + for py in 0..80 { + for px in 0..100 { + assert_eq!(actual.rgba(px, py), expected.rgba(px, py), "{format:?}, X={x}, Y={y}, pixel ({px},{py})"); + } + } + } + } +} + +#[test] +fn pose_axes_legacy_combined_override_and_existing_rotation_pressure_are_preserved() { + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + for enabled in [false, true] { + for legacy in [false, true] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, "overrideTilt": legacy, + "pressure": 0.4, "overridePressure": true, "rotation": 120, "overrideRotation": true + }})) + .unwrap(); + let expected = StrokePoint { + tilt_x: if enabled && legacy { 45.0 } else { input.tilt_x }, + tilt_y: if enabled && legacy { -18.0 } else { input.tilt_y }, + pressure: if enabled { 0.4 } else { input.pressure }, + rotation: if enabled { 120.0 } else { input.rotation }, + ..input + }; + assert_eq!(crate::dynamics::apply_pose(&b, input), expected); + let stored = serde_json::to_vec(&b).unwrap(); + let restored: BrushSettings = serde_json::from_slice(&stored).unwrap(); + assert_eq!(crate::dynamics::apply_pose(&restored, input), expected, "legacy brush settings retain their painting behavior through persistence"); + } + } +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 25354dd6d5..b90a7c15b3 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -644,8 +644,10 @@ impl DualBuilder { pub fn apply_pose(b: &BrushSettings, mut p: StrokePoint) -> StrokePoint { let pose = &b.pose; if pose.enabled { - if pose.override_tilt { + if pose.override_tilt_x { p.tilt_x = pose.tilt_x; + } + if pose.override_tilt_y { p.tilt_y = pose.tilt_y; } if pose.override_rotation { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..c8f1d59977 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -104,7 +104,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) { p.enabled = true; p.tilt_x = 45.0; p.tilt_y = -27.0; - p.override_tilt = true; + p.override_tilt_x = true; + p.override_tilt_y = true; p.rotation = 120.0; p.override_rotation = true; p.pressure = 0.4; @@ -560,6 +561,55 @@ fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { brush.pointer(path).and_then(Value::as_bool).expect("the checkbox has a boolean brush setting") } +#[test] +fn pose_axes_window_routes_each_checkbox_through_a_minimal_command() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = harness(0, 7); + h.state_mut() + .session + .execute( + "tools.setBrush", + json!({"pose": { + "enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true, + "pressure": 0.71, "rotation": 67 + }}), + ) + .unwrap(); + h.run_steps(3); + let before = h.state().session.tools.brush.clone(); + let n = h.state().session.journal.len(); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt X").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Override Tilt X").accesskit_node().toggled(), Some(Toggled::False)); + assert_eq!(h.get_by_label("Override Tilt Y").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.state().session.journal.len(), n + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"pose": {"overrideTiltX": false}})); + let mut expected = serde_json::to_value(before).unwrap(); + expected["pose"]["overrideTiltX"] = json!(false); + expected["pose"]["overrideTilt"] = json!(false); + assert_eq!(serde_json::to_value(&h.state().session.tools.brush).unwrap(), expected); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"pose": {"overrideTiltY": false}})); + let before = h.state().session.tools.brush.clone(); + h.state_mut().session.execute("tools.setBrush", json!({"pose": {"enabled": false}})).unwrap(); + h.run_steps(3); + let n = h.state().session.journal.len(); + for label in ["Override Tilt X", "Override Tilt Y"] { + let node = h.get_by_role_and_label(Role::CheckBox, label); + assert!(node.accesskit_node().is_disabled()); + node.click(); + h.run_steps(2); + } + let mut expected = before; + expected.pose.enabled = false; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(h.state().session.journal.len(), n, "a disabled pose records no checkbox update"); +} + #[test] fn hue_jitter_amount_edits_keep_percentage_units_and_command_routing() { use egui::accesskit::Role; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..9dce174070 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -554,8 +554,10 @@ fn tilt(ui: &mut egui::Ui, label: &str, deg: &mut f32) { fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) { let p = &mut b.pose; tilt(ui, tl!("Tilt X"), &mut p.tilt_x); + widgets::checkbox(ui, &mut p.override_tilt_x, tl!("Override Tilt X")); + ui.add_space(8.0); tilt(ui, tl!("Tilt Y"), &mut p.tilt_y); - widgets::checkbox(ui, &mut p.override_tilt, tl!("Override Tilt")); + widgets::checkbox(ui, &mut p.override_tilt_y, tl!("Override Tilt Y")); ui.add_space(8.0); num(ui, tl!("Rotation"), &mut p.rotation, 0.0..=360.0, "°"); widgets::checkbox(ui, &mut p.override_rotation, tl!("Override Rotation")); @@ -645,6 +647,40 @@ mod tests { h } + #[test] + fn pose_axes_controls_are_independent_and_below_each_tilt_in_all_themes() { + use egui::accesskit::Toggled; + for kind in theme::ThemeKind::ALL { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": true, "tiltX": 45.0, "tiltY": -18.0, + "overrideTiltX": true, "overrideTiltY": false, + "pressure": 0.71, "rotation": 67.0 + }})) + .unwrap(); + let mut h = section_harness(7, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let x = h.get_by_role_and_label(Role::CheckBox, "Override Tilt X"); + let y = h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y"); + assert_eq!(x.accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(y.accesskit_node().toggled(), Some(Toggled::False)); + assert!(h.get_by_label("Tilt X").rect().bottom() < x.rect().top() && x.rect().bottom() < h.get_by_label("Tilt Y").rect().top()); + assert!(h.get_by_label("Tilt Y").rect().bottom() < y.rect().top() && y.rect().bottom() < h.get_by_label("Rotation").rect().top()); + assert_eq!(h.state(), &b, "drawing preserves pose degrees and flags"); + y.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + let wire = serde_json::to_value(h.state()).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], true); + assert_eq!(wire["pose"]["overrideTiltY"], true); + let mut expected = serde_json::to_value(b).unwrap(); + expected["pose"]["overrideTiltY"] = serde_json::json!(true); + expected["pose"]["overrideTilt"] = serde_json::json!(true); + assert_eq!(wire, expected, "Space changes only Y, plus the derived legacy readback"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805c20f76a..6335d6b3bc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1343,6 +1343,8 @@ Override Pressure Přepsat přítlak Override Rotation Přepsat natočení Override Tilt Přepsat náklon + Override Tilt X Přepsat náklon X + Override Tilt Y Přepsat náklon Y PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ PSD PSD Paint Overlay Překryv malby diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88ef957675..bc7b04f676 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1326,6 +1326,8 @@ Override Pressure Druck überschreiben Override Rotation Rotation überschreiben Override Tilt Neigung überschreiben + Override Tilt X Neigung X überschreiben + Override Tilt Y Neigung Y überschreiben PRESET DETAILS VORGABENDETAILS PSD PSD Paint Overlay Übermalung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8889313cb..0df2dc0c2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -797,6 +797,8 @@ Override Pressure Παράκαμψη πίεσης Override Rotation Παράκαμψη περιστροφής Override Tilt Παράκαμψη κλίσης + Override Tilt X Παράκαμψη κλίσης X + Override Tilt Y Παράκαμψη κλίσης Y PRESET DETAILS ΛΕΠΤΟΜΕΡΕΙΕΣ ΠΡΟΕΠΙΛΟΓΗΣ Pattern Size Μέγεθος μοτίβου Perspective Warp is not active Ο μετασχηματισμός προοπτικής δεν είναι ενεργός diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 37f88f4294..a19c7d9789 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1332,6 +1332,8 @@ Override Pressure Anular presión Override Rotation Anular rotación Override Tilt Anular inclinación + Override Tilt X Anular inclinación X + Override Tilt Y Anular inclinación Y PRESET DETAILS DETALLES DEL AJUSTE PREESTABLECIDO PSD PSD Paint Overlay Superposición de pintura diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7d508fb5c..b56208864c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1312,6 +1312,8 @@ Override Pressure Forcer la pression Override Rotation Forcer la rotation Override Tilt Forcer l'inclinaison + Override Tilt X Forcer l'inclinaison X + Override Tilt Y Forcer l'inclinaison Y PRESET DETAILS DÉTAILS DU PRÉRÉGLAGE PSD PSD Paint Overlay Superposition de peinture diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 70c2294205..474f664f2b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1214,6 +1214,8 @@ Override Pressure Nyomás felülbírálása Override Rotation Elforgatás felülbírálása Override Tilt Dőlés felülbírálása + Override Tilt X X dőlés felülbírálása + Override Tilt Y Y dőlés felülbírálása Paints from the document's opening state A dokumentum megnyitáskori állapotából fest Paper Papír Paragraph Bekezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dcc02ec185..a4b101ebc3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1306,6 +1306,8 @@ Override Pressure Timpa Tekanan Override Rotation Timpa Rotasi Override Tilt Timpa Kemiringan + Override Tilt X Timpa Kemiringan X + Override Tilt Y Timpa Kemiringan Y PRESET DETAILS DETAIL PRESET PSD PSD Paint Overlay Overlay Cat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c077d90aa1..87dc88d3cc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1328,6 +1328,8 @@ Override Pressure Sovrascrivi pressione Override Rotation Sovrascrivi rotazione Override Tilt Sovrascrivi inclinazione + Override Tilt X Sovrascrivi inclinazione X + Override Tilt Y Sovrascrivi inclinazione Y PRESET DETAILS DETTAGLI PREDEFINITO PSD PSD Paint Overlay Sovrapposizione pittura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f074e3e30..96d3b5939c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1331,6 +1331,8 @@ Override Pressure 筆圧を上書き Override Rotation 回転を上書き Override Tilt 傾きを上書き + Override Tilt X 傾き X を上書き + Override Tilt Y 傾き Y を上書き PRESET DETAILS プリセットの詳細 PSD PSD Paint Overlay ペイントオーバーレイ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eefa162a65..0b541ef7ff 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1303,6 +1303,8 @@ Override Pressure 필압 재정의 Override Rotation 회전 재정의 Override Tilt 기울기 재정의 + Override Tilt X X축 기울기 재정의 + Override Tilt Y Y축 기울기 재정의 PRESET DETAILS 사전 설정 세부 정보 PSD PSD Paint Overlay 오버레이 칠하기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 68e98a6fef..e7f1b38204 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1312,6 +1312,8 @@ Override Pressure Druk overschrijven Override Rotation Rotatie overschrijven Override Tilt Kanteling overschrijven + Override Tilt X Kanteling X overschrijven + Override Tilt Y Kanteling Y overschrijven PRESET DETAILS DETAILS VOORINSTELLING PSD PSD Paint Overlay Verfbedekking diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d76183794..21f4b6d7d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1327,6 +1327,8 @@ Override Pressure Zastąp nacisk Override Rotation Zastąp obrót Override Tilt Zastąp nachylenie + Override Tilt X Zastąp nachylenie X + Override Tilt Y Zastąp nachylenie Y PRESET DETAILS SZCZEGÓŁY USTAWIENIA WSTĘPNEGO PSD PSD Paint Overlay Nakładka malowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d48ba9b47..3ff8c1075e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1308,6 +1308,8 @@ Override Pressure Substituir pressão Override Rotation Substituir rotação Override Tilt Substituir inclinação + Override Tilt X Substituir inclinação X + Override Tilt Y Substituir inclinação Y PRESET DETAILS DETALHES DA PREDEFINIÇÃO PSD PSD Paint Overlay Sobreposição de pintura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a3cb6b2e..ad09c67ddf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1057,6 +1057,8 @@ No brush presets Нет наборов кистей Override Pressure Переопределить нажим Override Tilt Переопределить наклон + Override Tilt X Переопределить наклон X + Override Tilt Y Переопределить наклон Y Override Rotation Переопределить поворот Apply the pen's tilt and rotation to the tip shape Применять наклон и поворот пера к форме отпечатка diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f47fe20162..e05143881d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1291,6 +1291,8 @@ Override Pressure Nadjačaj pritisak Override Rotation Nadjačaj rotaciju Override Tilt Nadjačaj nagib + Override Tilt X Nadjačaj nagib X + Override Tilt Y Nadjačaj nagib Y PRESET DETAILS DETALJI ŠABLONA PSD PSD Paint Overlay Preklop za slikanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 948c967467..e0e09c55c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1288,6 +1288,8 @@ Override Pressure Basıncı Geçersiz Kıl Override Rotation Döndürmeyi Geçersiz Kıl Override Tilt Eğimi Geçersiz Kıl + Override Tilt X X Eğimini Geçersiz Kıl + Override Tilt Y Y Eğimini Geçersiz Kıl PRESET DETAILS ÖN AYAR DETAYLARI PSD PSD Paint Overlay Boya Kaplaması diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2a72fdaf19..3ca620436f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1035,6 +1035,8 @@ No brush presets Немає наборів пензлів Override Pressure Перевизначити натиск Override Tilt Перевизначити нахил + Override Tilt X Перевизначити нахил X + Override Tilt Y Перевизначити нахил Y Override Rotation Перевизначити обертання Apply the pen's tilt and rotation to the tip shape Застосувати нахил і обертання пера до форми кінчика diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88b855a707..75670e1602 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1181,6 +1181,8 @@ Tilt X Độ nghiêng X Tilt Y Độ nghiêng Y Override Tilt Ghi đè nghiêng + Override Tilt X Ghi đè độ nghiêng X + Override Tilt Y Ghi đè độ nghiêng Y Override Rotation Ghi đè xoay Override Pressure Ghi đè áp lực Pressure Áp lực diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2842771124..e20b8569c8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1341,6 +1341,8 @@ Override Pressure 覆盖压感 Override Rotation 覆盖旋转 Override Tilt 覆盖倾斜 + Override Tilt X 覆盖 X 倾斜 + Override Tilt Y 覆盖 Y 倾斜 PRESET DETAILS 预设详细信息 PSD PSD Paint Overlay 绘制叠加层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d02e06646e..7158be9012 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1338,6 +1338,8 @@ Override Pressure 覆寫壓力 Override Rotation 覆寫旋轉 Override Tilt 覆寫傾斜 + Override Tilt X 覆寫傾斜 X + Override Tilt Y 覆寫傾斜 Y PRESET DETAILS 預設集詳細資料 PSD PSD Paint Overlay 繪畫覆蓋 diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..a45e9a76ba 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1598,10 +1598,10 @@ fn checker_tile(ctx: &egui::Context) -> egui::TextureHandle { /// Spectrum-style checkbox (blue when checked). pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { let t = Tokens::get(ui.ctx()); - let mut resp = ui + let (mut resp, label_resp) = ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::CLICK); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); let l = ui .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) @@ -1609,13 +1609,16 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { // Pressing the label presses the box (#4476). let pressed = resp.is_pointer_button_down_on() || l.is_pointer_button_down_on(); check_box(ui.painter(), rect, *on, resp.hovered() || l.hovered(), pressed, &t); - resp.union(l) + (resp.union(l.clone()), l) }) .inner; if resp.clicked() { *on = !*on; resp.mark_changed(); } + // The label is the single named, focusable checkbox; the box is a mouse target. + // Override Label's metadata instead of creating a second node with the same name. + label_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, tl!(label))); resp } @@ -1707,7 +1710,10 @@ mod tests { #[test] fn checkbox_label_is_a_button_target_not_selectable_text() { use egui::CursorIcon; - use egui_kittest::{Harness, kittest::Queryable}; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; let mut h = Harness::builder().with_size(egui::vec2(300.0, 80.0)).build_ui_state( |ui, on: &mut bool| { @@ -1723,6 +1729,13 @@ mod tests { h.get_by_label("Show Transform Controls").click(); h.run_steps(2); assert!(*h.state()); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Show Transform Controls"); + assert_eq!(checkbox.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + checkbox.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert!(!*h.state(), "the same named checkbox supports Space"); } /// #3208: a rounded swatch's checker is one shared, repeating, unblurred two-by-two tile on diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..bbf8237373 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (independent Brush Pose tilt-axis overrides; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -28,6 +28,7 @@ tablets against Photoshop side by side. | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | | Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | +| Brush Pose: separate Override Tilt X/Y flags, preserved through ABR import, presets and commands | done | #3825 (axis overrides only); imported single-axis flags retain the other live stylus component; dab/pixel tests cover 8/16/32-bit RGBA; legacy combined inputs migrate and command deltas contain only the edited axis. | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | | Section locks | done | #174 | @@ -115,6 +116,7 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Brush Pose tilt overrides are independent end to end (#3825 partial); ABR flags, legacy JSON/commands, presets and three pixel depths covered; no readiness percentage or estimate changed | | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..812d0088d6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,6 +54,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. +- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. + - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..b7044c0498 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: independent Brush Pose tilt-axis overrides, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,11 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-14, #3825 partial):** Brush Pose has separate X/Y tilt override + controls. ABR import retains both flags independently instead of combining them; + only the selected component replaces live stylus input. Legacy combined JSON and + command inputs retain their two-axis behavior, and minimal command patches preserve + the other axis. Other Brush Settings and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -263,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G4: independent Brush Pose X/Y overrides across UI/import/painting/commands (#3825 partial); legacy combined input retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From e14bd18360f2728cc80d01a5fa4812d9a54dd825 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Tue, 13 Oct 2026 20:03:00 -0300 Subject: [PATCH 0090/1452] test: update Navigator Animated Zoom regression --- crates/ui-egui/src/panels.rs | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 53e59c78c5..72fd0906cb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -6078,6 +6078,7 @@ mod properties_target_regressions { #[cfg(test)] mod navigator_animated_zoom_tests { use super::*; + use egui_kittest::kittest::Queryable; #[test] fn animated_zoom_off_navigator_buttons_jump_without_a_transition() { @@ -6085,6 +6086,7 @@ mod navigator_animated_zoom_tests { app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); app.sync_views(); + app.ui.views[0].zoom = 1.0; app.ui.views[0].fit_pending = false; let mut h = egui_kittest::Harness::builder().with_size(vec2(430.0, 460.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { @@ -6096,32 +6098,17 @@ mod navigator_animated_zoom_tests { ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(4); - let width = |label: &str| h.ctx.fonts_mut(|f| f.layout_no_wrap(label.into(), crate::theme::medium(12.5), Color32::WHITE).size().x + 20.0); - let spacing = h.ctx.style_of(h.ctx.theme()).spacing.item_spacing.x; - let widths = [width("200%"), width("100%"), width("Fit")]; - let controls = last_navigator_controls(&h.ctx).unwrap(); - let mut right = controls.right(); - for (index, zoom) in [2.0, 1.0].into_iter().enumerate() { - let pointer = pos2(right - widths[index] / 2.0, controls.top() + 16.0); - h.hover_at(pointer); - for pressed in [true, false] { - h.event(egui::Event::PointerButton { pos: pointer, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - h.step(); - } - right -= widths[index] + spacing; + for (label, direction) in [("Zoom In", 1), ("Zoom Out", -1), ("Zoom Out", -1)] { + let before = &h.state().ui.views[0]; + let zoom = crate::zoom_levels::step(before.zoom, direction, before.doc_size); + h.get_by_label(label).click(); + h.run_steps(2); let view = &h.state().ui.views[0]; - assert_eq!(view.zoom, zoom); - assert_eq!(view.display_camera, Some((view.zoom, view.center))); - assert!(!view.zoom_animating && view.zoom_anchor.is_none() && view.zoom_transition.is_none()); + assert_eq!(view.zoom, zoom, "{label}"); + assert_eq!(view.display_camera, Some((view.zoom, view.center)), "{label}"); + assert!(!view.zoom_animating && view.zoom_anchor.is_none() && view.zoom_transition.is_none(), "{label}"); + assert!(!view.fit_pending, "{label}"); } - let pointer = pos2(right - widths[2] / 2.0, controls.top() + 16.0); - h.hover_at(pointer); - for pressed in [true, false] { - h.event(egui::Event::PointerButton { pos: pointer, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - h.step(); - } - assert!(h.state().ui.views[0].fit_pending); - assert!(!h.state().ui.views[0].zoom_animating); } } From 17abae97413def4f80cc84b7d41f819d6cff970e Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:52:47 +0300 Subject: [PATCH 0091/1452] Brush Settings: expose section lock names and state Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel.rs | 1 + crates/ui-egui/src/brush_panel_tests.rs | 69 +++++++++++++++++++++++++ docs/brush-parity.md | 5 +- docs/gaps.md | 7 ++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 515e6e5a21..7c44749565 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -324,6 +324,7 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); + lresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name)))); // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..7ad74fb1d8 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -275,6 +275,75 @@ fn section_locks_are_always_visible_and_toggle_on_click() { assert_eq!(h.state().ui.brush_section, 0); } +#[test] +fn section_lock_buttons_report_their_section_and_state_in_every_theme() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for theme in crate::theme::ThemeKind::ALL { + let mut h = harness(0, 3); + PhotocraftApp::setup_context(&h.ctx, theme); + let mut brush = h.state().session.tools.brush.clone(); + for i in 1..SECTIONS.len() { + *section_lock(&mut brush, i).unwrap() = i % 2 == 0; + } + h.state_mut().session.tools.brush = brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + h.run_steps(3); + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let label = format!("Lock: {name}"); + let node = h.get_by_role_and_label(Role::Button, &label); + assert!(!node.accesskit_node().is_disabled(), "locks remain available for disabled sections"); + assert_eq!(h.query_all_by_label(&label).count(), 1, "{theme:?}: {name} has one distinct control"); + assert_eq!(node.accesskit_node().toggled(), Some(if i % 2 == 0 { Toggled::True } else { Toggled::False }), "{theme:?}: {name}"); + let row = h.get_by_label(name).rect(); + assert!(row.contains_rect(node.rect()), "the named control is the lock's existing hit target"); + } + assert_eq!(h.state().session.tools.brush, brush, "describing a lock must not edit it"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 3); + } +} + +#[test] +fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let mut h = harness(0, 0); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let label = format!("Lock: {name}"); + let mut replay = photocraft_engine::Session::new(); + replay.tools.brush = before.clone(); + h.get_by_role_and_label(Role::Button, &label).focus(); + h.run_steps(2); + for locked in [true, false] { + h.key_press(egui::Key::Space); + h.run_steps(3); + let mut expected = before.clone(); + *section_lock(&mut expected, i).unwrap() = locked; + assert_eq!(h.state().session.tools.brush, expected, "{name}: only this section's lock changes"); + assert_eq!(h.state().ui.brush_section, 0); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.get_by_role_and_label(Role::Button, &label).accesskit_node().toggled(), Some(if locked { Toggled::True } else { Toggled::False })); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().values().next(), Some(&json!(locked))); + replay.execute(&id, params).unwrap(); + assert_eq!(replay.tools.brush, expected, "command replay preserves the lock-only edit"); + } + assert_eq!(h.state().session.journal.len(), journal + 2, "one command per activation"); + assert_eq!(replay.tools.brush, before); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..207301eb04 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (named, stateful Brush Settings section locks; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -30,7 +30,7 @@ tablets against Photoshop side by side. | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174 | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -121,3 +121,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..88ec068693 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: named, stateful section locks in Brush Settings; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-14, #3477 workflow):** section locks expose their section-specific + translated names and locked state to AccessKit. Keyboard activation preserves the selected + section and other brush settings, using the existing command path. Other panel and brush-feel + gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -263,6 +267,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state (#3477 workflow); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From 61b0f4699f6d358586db5d887a6817076dcc3b91 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:12:20 +0300 Subject: [PATCH 0092/1452] Brush Settings: show keyboard focus on section locks Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel.rs | 3 ++ crates/ui-egui/src/brush_panel_tests.rs | 47 +++++++++++++++++++++++++ docs/brush-parity.md | 6 ++-- docs/gaps.md | 7 ++-- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 7c44749565..c807f107ae 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -328,6 +328,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); + if lresp.has_focus() { + ui.painter().rect_stroke(lr, t.radius_sm, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 7ad74fb1d8..e260cecf91 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -344,6 +344,53 @@ fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() } } +#[test] +fn section_lock_buttons_show_focus_and_track_keyboard_navigation() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + fn has_ring(shape: &egui::Shape, target: egui::Rect, color: egui::Color32) -> bool { + match shape { + egui::Shape::Rect(r) => r.rect == target && r.stroke == Stroke::new(1.0, color) && r.stroke_kind == egui::StrokeKind::Inside, + egui::Shape::Vec(v) => v.iter().any(|s| has_ring(s, target, color)), + _ => false, + } + } + for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { + let mut h = harness(0, 0); + PhotocraftApp::setup_context(&h.ctx, theme); + h.state_mut().session.tools.brush.locks.scattering = index % 2 == 0; + h.state_mut().session.tools.brush.locks.texture = index % 2 != 0; + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let first = h.get_by_role_and_label(Role::Button, "Lock: Scattering").rect(); + let second = h.get_by_role_and_label(Role::Button, "Lock: Texture").rect(); + let accent = Tokens::get(&h.ctx).accent; + let ring = |h: &Harness<'_, PhotocraftApp>, rect| h.output().shapes.iter().any(|s| s.clip_rect.contains_rect(rect) && has_ring(&s.shape, rect, accent)); + assert!(!ring(&h, first) && !ring(&h, second), "{theme:?}: unfocused locks have no focus ring"); + h.get_by_role_and_label(Role::Button, "Lock: Scattering").focus(); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(ring(&h, first) && !ring(&h, second), "{theme:?}: the focused lock needs a visible indicator"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert!(!h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(!ring(&h, first), "Tab removes the old lock's indicator"); + h.get_by_role_and_label(Role::Button, "Lock: Texture").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && ring(&h, second), "the ring follows focus"); + h.get_by_label("Brush Tip Shape").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && !ring(&h, second)); + assert_eq!(h.state().session.tools.brush, before, "focus must not edit settings"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 0); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 207301eb04..8e02a4e272 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (named, stateful Brush Settings section locks; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (named, stateful Brush Settings locks and visible keyboard focus; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -30,7 +30,7 @@ tablets against Photoshop side by side. | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus Space activation, minimal command patches and replay without selecting or enabling a section. | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus visible themed focus, Tab blur, Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -121,4 +121,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | -| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | +| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 88ec068693..307b8c25b1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: named, stateful section locks in Brush Settings; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: named, stateful section locks and visible keyboard focus in Brush Settings; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -157,7 +157,8 @@ what an agent can't finish alone. ### G4. Brush feel - **Progress (2026-10-14, #3477 workflow):** section locks expose their section-specific - translated names and locked state to AccessKit. Keyboard activation preserves the selected + translated names and locked state to AccessKit. A themed outline follows keyboard focus; + focus changes do not edit settings. Keyboard activation preserves the selected section and other brush settings, using the existing command path. Other panel and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed @@ -267,7 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state (#3477 workflow); no readiness percentage or estimate changed | +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state and visible keyboard focus (#3477 workflow); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From ce383de7d8ee13955349be9740f8ee7720df036d Mon Sep 17 00:00:00 2001 From: Abdul Karim Mia Date: Wed, 14 Oct 2026 07:55:29 +0530 Subject: [PATCH 0093/1452] Map .atn duplicate-into-new-document (Mk) to layer.duplicate Photoshop records Layer > Duplicate Layer with Destination: New as a Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy name), verified against a ScriptListener/XTensions export. Import maps it to layer.duplicate {document: new}; export writes it back, so the steps round-trip instead of becoming kept Photoshop steps that fail at playback. A Dplc with a T document reference (another open doc) now imports as a kept step rather than misplaying as a same-doc copy. --- crates/engine/src/atn_cmds.rs | 45 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 17 +++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..ebeda48687 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..b5c3a9e545 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -448,6 +454,13 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }]))), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -457,8 +470,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From fb3997514fc214cfb1bb82bd548dc425748343f7 Mon Sep 17 00:00:00 2001 From: Abdul Karim Mia Date: Wed, 14 Oct 2026 07:59:57 +0530 Subject: [PATCH 0094/1452] Credit Abdul karim mia in contributors --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..eb1d57052e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # 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.abdul-karim-mia] +real_name = "Abdul karim mia" +sync_github_name = false + [people.jonathanwxh-cell] display_name = "jonathnwxh" sync_github_name = false From e2ed5cdcb7e53586fdf5ad9e44abc0dee64cd285 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:41:22 +0800 Subject: [PATCH 0095/1452] Limit blend mode hover refresh to the visible canvas --- crates/ui-egui/src/blend_preview.rs | 31 +++- crates/ui-egui/src/canvas.rs | 14 +- .../src/canvas/blend_viewport_tests.rs | 142 ++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/canvas/blend_viewport_tests.rs diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index e9f010613a..b73ddd553b 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -29,6 +29,8 @@ pub(crate) struct BlendPreview { area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, + /// GPU preview key, affected region (including effects), and the area last refreshed. + covered: Option<(u64, Rect, Rect)>, } /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the @@ -47,7 +49,7 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option Rect { + let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region }; + if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) { + p.covered = None; + return region; + } + let shown = region.intersect(&visible); + p.covered = Some((key, region, shown)); + shown +} + +/// A pan or zoom can expose pixels not yet rendered for the current preview key. +pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option { + let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?; + let (covered_key, region, covered) = p.covered?; + if covered_key != key { + return None; + } + let want = region.intersect(&visible); + if want.is_empty() || covered.contains_rect(&want) { + return None; + } + p.covered = Some((key, region, want)); + Some(want) +} + fn is_preview_key(key: u64) -> bool { (BASE..BASE << 1).contains(&key) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 676d068070..f2cc88f074 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1545,6 +1545,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); + if let Some(region) = damage { + damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, visible)); + } // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key)) @@ -1580,7 +1583,12 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); } - return true; + // Blend previews keep offscreen pixels untouched until the view reaches them. + // Use the normal refresh path so CPU fallback and pending redraws stay synchronized. + damage = crate::blend_preview::uncovered(app, id, revision, raw_key, visible); + if damage.is_none() { + return true; + } } // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas // draws it (checked before the memory budget, which walks the history, every frame). @@ -8003,3 +8011,7 @@ mod rotation_preview_isolation_tests { #[cfg(test)] #[path = "canvas_display_memo_tests.rs"] mod display_memo_tests; + +#[cfg(test)] +#[path = "canvas/blend_viewport_tests.rs"] +mod blend_viewport_regression; diff --git a/crates/ui-egui/src/canvas/blend_viewport_tests.rs b/crates/ui-egui/src/canvas/blend_viewport_tests.rs new file mode 100644 index 0000000000..fe06c0ba72 --- /dev/null +++ b/crates/ui-egui/src/canvas/blend_viewport_tests.rs @@ -0,0 +1,142 @@ +use super::*; +use photocraft_color::BlendMode; +use serde_json::json; + +fn render_state() -> Option { + match std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) { + Ok(rs) => Some(rs), + Err(error) => { + if std::env::var_os("PHOTOCRAFT_REQUIRE_GPU_TESTS").is_some() { + std::panic::resume_unwind(error); + } + eprintln!("skipping blend viewport regression: no GPU adapter"); + None + } + } +} + +#[test] +fn blend_hover_refresh_is_limited_to_visible_canvas() { + check_blend_viewport(false); +} + +#[test] +fn blend_hover_cpu_fallback_refresh_tracks_visible_canvas() { + check_blend_viewport(true); +} + +#[test] +fn blend_hover_during_background_redraw_restores_committed_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.background_jobs = true; + app.run("file.new", json!({"width":2600,"height":1700,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(!app.redraws.is_empty(), "fixture must start a deferred full redraw"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let panned = DRect::new(2048, 1024, 2304, 1280); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); +} + +fn settle_redraw(app: &mut PhotocraftApp) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let gpu = app.gpu.clone().unwrap(); + let id = app.session.active().unwrap().doc.id; + while !app.redraws.is_empty() { + crate::background_redraw::poll(app, &gpu, id); + assert!(std::time::Instant::now() < deadline, "background redraw did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +fn assert_visible_pixels(app: &mut PhotocraftApp, visible: DRect) { + let (doc, _) = display_doc(app, 0); + let gpu = app.gpu.clone().unwrap(); + let reference = doc.id.0 ^ (1u64 << 60); + let (display, _) = canvas_display(app, &doc, None); + gpu.upload_composite(reference, &doc, display.as_deref()); + let (_, size, shown) = gpu.read_texels(doc.id.0).unwrap(); + let (_, _, want) = gpu.read_texels(reference).unwrap(); + for y in visible.y0..visible.y1 { + for x in visible.x0..visible.x1 { + let index = y as usize * size[0] as usize + x as usize; + for channel in 0..4 { + assert!((shown[index][channel] - want[index][channel]).abs() <= 2.0 / 255.0, "preview differs at ({x},{y}) channel{channel}"); + } + } + } +} + +fn check_blend_viewport(cpu_fallback: bool) { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + if cpu_fallback { + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + } + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(app.redraws.is_empty(), "small fixture must finish synchronously"); + if cpu_fallback { + assert!(!app.perf.last_refresh.starts_with("gpu"), "fixture must exercise CPU composition fallback"); + } + let id = app.session.active().unwrap().doc.id; + let gpu = app.gpu.clone().unwrap(); + let original = gpu.read_texels(id.0).unwrap(); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + println!("BLEND_VIEWPORT kind={} pixels={} expected={} adapter={:?}", app.perf.last_refresh, app.perf.last_refresh_px, 256 * 256, app.perf.gpu_info); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "hover must not recomposite offscreen pixels of a full-canvas layer"); + assert_visible_pixels(&mut app, visible); + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged view and mode must reuse the texture"); + let zoomed_out = DRect::new(128, 128, 640, 640); + assert!(ensure_gpu(&mut app, &ctx, 0, zoomed_out)); + assert_eq!(app.perf.last_refresh_px, 512 * 512, "zooming out catches up the larger visible area"); + let panned = DRect::new(640, 256, 896, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "newly exposed pixels must catch up without a mode change"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Screen)); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "returning view must show the current mode"); + assert_visible_pixels(&mut app, visible); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(gpu.read_texels(id.0).unwrap(), original, "cancel must restore the whole texture, including previously previewed offscreen pixels"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + app.run("layer.setProps", json!({"layer":layer,"blend":"Multiply"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + app.run("file.new", json!({"width":1024,"height":768,"background":"white"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 1, visible)); + app.session.set_active(0); + app.sync_views(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_visible_pixels(&mut app, bounds); +} From 03b6af0c1220a56f93c21d31b00afbe80d8e0efc Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:12:24 +0530 Subject: [PATCH 0096/1452] Keep contributor credit PR focused on contributor entry --- crates/engine/src/atn_cmds.rs | 45 ----------------------------------- 1 file changed, 45 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..391c21abd8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,26 +983,6 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { - // Duplicate layer/group into a new document: `Mk` with a document class, a layer - // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` - // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another - // open document) stays a kept Photoshop step: document indices mean nothing at replay. - if let Some(r) = get_ref(d, "null") - && ref_class(r).as_deref() == Some("Dcmn") - && matches!(target(r), Target::Class) - && let Some(u) = get_ref(d, "Usng") - && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) - && let Some(mut p) = layer_target(u) - { - p.insert("document".into(), "new".into()); - if let Some(n) = get_text(d, "Nm ") { - p.insert("documentName".into(), n.into()); - } - if let Some(n) = get_text(d, "LyrN") { - p.insert("name".into(), n.into()); - } - return step("layer.duplicate", Value::Object(p)); - } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1137,21 +1117,6 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } - "layer.duplicate" => { - // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). - // Anything else falls through to the `Dplc` event below. - if jstr(p, "document") != Some("new") { - return None; - } - let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); - if let Some(n) = jstr(p, "documentName") { - d = d.with("Nm ", text(n)); - } - if let Some(n) = jstr(p, "name") { - d = d.with("LyrN", text(n)); - } - d - } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1911,11 +1876,6 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { - // A `T` document reference (duplicate into another open document) has no - // replayable meaning here: keep the step verbatim rather than misplay it. - if get_ref(d, "T ").is_some() { - return Mapped::Unsupported { harmless: false }; - } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1937,10 +1897,6 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { - // Duplicate into a new document is a `Mk` (handled above); let it fall through. - if id == "layer.duplicate" && jstr(p, "document") == Some("new") { - return None; - } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2740,7 +2696,6 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", - "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", From 007581b7a46ad117b03991c0d3ad80a202a00e34 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:12:26 +0530 Subject: [PATCH 0097/1452] Remove feature changes from contributor credit PR --- crates/engine/src/atn_cmds/tests.rs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b5c3a9e545..bdb839fa30 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,12 +212,6 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), - // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of - // a recorded Duplicate Layer with Destination: New, plus a named layer). - ("layer.duplicate", json!({"document": "new"})), - ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), - ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), - ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -454,13 +448,6 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), - // Duplicate into another open document: the `T` reference means nothing at - // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. - item( - "Dplc", - "Duplicate", - Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }]))), - ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -470,8 +457,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); - assert_eq!(imp.unsupported.len(), 5); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); + assert_eq!(imp.unsupported.len(), 4); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From fef5e042bd260d2afa0f7aeef7438857bcd0e239 Mon Sep 17 00:00:00 2001 From: Abdul Karim Mia Date: Wed, 14 Oct 2026 10:35:30 +0530 Subject: [PATCH 0098/1452] Support .act color tables end to end (swatches + Color Table dialog) Closes #1127. Photoshop's .act (256 RGB triplets, optional count/ transparent trailer) now loads everywhere swatches do: a new photocraft-psd act parser/writer (byte-stable round trip, hostile lengths are errors), Format::Act in swatches.import/export with length/extension sniffing, the Swatches panel menu, File > Open (desktop + web filters, drag-and-drop) and Load/Save buttons in Image > Mode > Color Table. Verified against a Photoshop-saved table (256 colors, byte-identical re-export). --- apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 6 +- crates/engine/src/swatch_cmds.rs | 74 +++++++++++-- crates/engine/src/swatch_cmds/tests.rs | 33 ++++++ crates/psd/src/act.rs | 143 +++++++++++++++++++++++++ crates/psd/src/lib.rs | 1 + crates/ui-egui/src/color_table_ui.rs | 141 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/preset_files_ui.rs | 8 +- crates/ui-egui/src/swatches_ui.rs | 10 +- 11 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 crates/psd/src/act.rs create mode 100644 crates/ui-egui/src/color_table_ui.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..55738ccd96 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -15,11 +15,11 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. +/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..d77358d61c 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -17,12 +17,12 @@ use std::sync::Arc; mod recovery_tests; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets +/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -71,7 +71,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog /// never swaps their extension for a document format's. -const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])]; +const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])]; /// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd. fn save_filters(suggested: &str) -> Vec<(String, Vec)> { diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs index 7844124bdb..fa561df671 100644 --- a/crates/engine/src/swatch_cmds.rs +++ b/crates/engine/src/swatch_cmds.rs @@ -362,6 +362,7 @@ fn reset(s: &mut Session, p: &Value) -> Result { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Aco, + Act, Ase, } @@ -369,6 +370,7 @@ impl Format { fn parse(s: &str) -> Option { match s.trim_start_matches('.').to_ascii_lowercase().as_str() { "aco" => Some(Format::Aco), + "act" => Some(Format::Act), "ase" => Some(Format::Ase), _ => None, } @@ -376,6 +378,7 @@ impl Format { fn id(self) -> &'static str { match self { Format::Aco => "aco", + Format::Act => "act", Format::Ase => "ase", } } @@ -462,6 +465,21 @@ fn kind_to_ase(k: SwatchKind) -> AseKind { } } +/// Colors in an `.act` color table (what the format holds). +pub const ACT_COLORS: usize = photocraft_psd::act::COLOR_COUNT; + +/// `.act` bytes as `#rrggbb` colors plus the transparent index, for the Color Table dialog. +pub fn act_table(bytes: &[u8]) -> std::result::Result<(Vec, Option), String> { + let f = photocraft_psd::act::parse(bytes).map_err(|e| e.to_string())?; + let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]); + Ok((f.used().iter().map(hex).collect(), f.transparent_index().map(|i| i as u64))) +} + +/// Encode colors as `.act` bytes (at most [`ACT_COLORS`]); the transparent index is kept. +pub fn act_bytes(colors: &[[u8; 3]], transparent: Option) -> std::result::Result, String> { + photocraft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string()) +} + /// Parse a swatch file into groups (`group` names the group for ungrouped colours). pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Result<(Vec>, Vec), String> { let mut warnings = Vec::new(); @@ -484,6 +502,19 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu .collect(); out.push(Group::new(group, items)); } + Format::Act => { + let f = photocraft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?; + if let Some(t) = f.transparent_index() { + warnings.push(format!("color {} is the table's transparent color; swatches have no transparency so it was kept opaque", t + 1)); + } + let items = f + .used() + .iter() + .enumerate() + .map(|(i, c)| Swatch { name: format!("Swatch {}", i + 1), color: SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0)), kind: SwatchKind::Process }) + .collect(); + out.push(Group::new(group, items)); + } Format::Ase => { let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?; let mut skipped = 0; @@ -541,6 +572,32 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?; Ok((bytes, n, warnings)) } + Format::Act => { + // `.act` holds RGB only: HSB rides along as RGB, anything else is left out. + let rgb8 = |v: [f32; 3]| v.map(|c| (unit(c) * 255.0).round() as u8); + let mut triplets = Vec::new(); + let mut skipped = 0; + for s in groups.iter().flat_map(|g| g.items.iter()) { + match s.color { + SwatchColor::Rgb(v) => triplets.push(rgb8(v)), + SwatchColor::Hsb(v) => triplets.push(rgb8(hsb_to_rgb(v))), + _ => skipped += 1, + } + } + if groups.len() > 1 { + warnings.push(".act files have no groups; the swatches were saved as one list".into()); + } + if skipped > 0 { + warnings.push(format!("{skipped} non-RGB swatches can't be stored in an .act file and were left out (save as .aco to keep them)")); + } + if triplets.len() > photocraft_psd::act::COLOR_COUNT { + warnings.push(format!("only the first {} swatches fit in an .act file", photocraft_psd::act::COLOR_COUNT)); + triplets.truncate(photocraft_psd::act::COLOR_COUNT); + } + let n = triplets.len(); + let bytes = photocraft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?; + Ok((bytes, n, warnings)) + } Format::Ase => { let mut books = 0; let mut entries = Vec::with_capacity(groups.len()); @@ -571,12 +628,17 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu fn import(s: &mut Session, p: &Value) -> Result { const CMD: &str = "swatches.import"; - let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco or .ase file")?; + let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco, .act or .ase file")?; let format = match str_param(p, "format") { - Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?, + None if ext_format(str_param(p, "path")) == Some(Format::Act) => Format::Act, None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase, None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) => Format::Aco, - None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco or .ase swatch file"))?, + // `.act` has no magic: an exact 768/772 bytes that is neither .ase nor .aco. + None if bytes.len() == photocraft_psd::act::PALETTE_LEN || bytes.len() == photocraft_psd::act::PALETTE_LEN + photocraft_psd::act::TRAILER_LEN => { + Format::Act + } + None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco, .act or .ase swatch file"))?, }; let mode = str_param(p, "mode").unwrap_or("append"); let replace = match mode { @@ -634,7 +696,7 @@ fn export(s: &mut Session, p: &Value) -> Result { const CMD: &str = "swatches.export"; let path = str_param(p, "path"); let format = match str_param(p, "format") { - Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?, None => ext_format(path).unwrap_or(Format::Aco), }; let mut groups: Vec> = match name_param(p, "group", CMD)? { @@ -790,7 +852,7 @@ pub fn specs() -> Vec { label: "Import Swatches…", menu: &[], shortcut: None, - params: r##"{"path":".aco or .ase file"?,"data":base64 bytes?,"format":"aco|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##, + params: r##"{"path":".aco, .act or .ase file"?,"data":base64 bytes?,"format":"aco|act|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##, enabled: always, run: import, journal: true, @@ -800,7 +862,7 @@ pub fn specs() -> Vec { label: "Export Swatches…", menu: &[], shortcut: None, - params: r##"{"format":"aco|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##, + params: r##"{"format":"aco|act|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##, enabled: has_swatches, run: export, journal: true, diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 2a3e8e7ddb..4c121359f2 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -146,6 +146,39 @@ fn aco_round_trip_keeps_16_bit_values_and_books() { std::fs::remove_dir_all(&dir).ok(); } +#[test] +fn act_round_trip_loads_a_photoshop_color_table() { + // A Photoshop-saved table: 256 RGB colors plus the count/transparent trailer. + let mut colors = [[0u8; 3]; 256]; + colors[0] = [255, 255, 255]; + for (i, c) in colors.iter_mut().enumerate().skip(1) { + let v = i as u8; + *c = [v, 255 - v, 128]; + } + let file = photocraft_psd::act::write(&colors, Some(256), Some(255)).unwrap(); + assert_eq!(file.len(), 772); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Table", "mode": "replace"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256))); + assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the transparent color is reported"); + assert_eq!(s.presets.swatches[0].items[0].color, SwatchColor::Rgb([1.0, 1.0, 1.0])); + assert_eq!(s.presets.swatches[0].items[1].name, "Swatch 2"); + // Sniffed by length without a format, and by extension from a path. + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); + assert_eq!(r["format"].as_str(), Some("act")); + // Export keeps the 256 RGB colors; the trailer comes back in canonical form + // (swatches have no transparency to preserve). + let canon = photocraft_psd::act::write(&colors, Some(256), None).unwrap(); + assert_eq!(export_bytes(&mut s, json!({"format": "act"})), canon); + // A bigger library is capped at 256 with a warning; groups collapse to one list. + for i in 0..300 { + s.execute("swatches.add", json!({"name": format!("Extra {i}"), "color": {"model": "rgb", "values": [1, 0, 0]}})).unwrap(); + } + let r = s.execute("swatches.export", json!({"format": "act"})).unwrap(); + assert_eq!(r["count"], 256); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("first 256")))); +} + #[test] fn ase_round_trip_keeps_groups_floats_and_kinds() { let src = AseFile { diff --git a/crates/psd/src/act.rs b/crates/psd/src/act.rs new file mode 100644 index 0000000000..ab5fe48625 --- /dev/null +++ b/crates/psd/src/act.rs @@ -0,0 +1,143 @@ +//! Photoshop Color Table files (`.act`): raw 8-bit RGB palettes. +//! +//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Table): 256 RGB +//! triplets (768 bytes), optionally followed by a 4-byte trailer: a big-endian `u16` color +//! count ("the number of colors to use") and a big-endian `u16` transparent color index. +//! There are no names, no groups and no other color spaces. The trailer is kept verbatim so a +//! read → write round trip never loses it; interpreting it is the caller's job. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// Colors in a `.act` palette. +pub const COLOR_COUNT: usize = 256; +/// Palette bytes: 256 RGB triplets. +pub const PALETTE_LEN: usize = COLOR_COUNT * 3; +/// Trailer bytes (color count + transparent index). +pub const TRAILER_LEN: usize = 4; + +/// A parsed `.act` file: the full 256-color palette plus the optional trailer, kept verbatim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActFile { + /// All 256 palette colors as RGB triplets. + pub colors: [[u8; 3]; COLOR_COUNT], + /// Trailer color count ("the number of colors to use"), if the file has one. + pub count: Option, + /// Trailer transparent color index, if the file has one. + pub transparent: Option, +} + +impl ActFile { + /// Colors to use: the trailer count clamped to the palette, else the whole palette. + pub fn used(&self) -> &[[u8; 3]] { + let n = self.count.map_or(COLOR_COUNT, |c| (c as usize).min(COLOR_COUNT)); + &self.colors[..n] + } + + /// The transparent color index, if the trailer names one inside the used colors. + pub fn transparent_index(&self) -> Option { + let t = usize::from(self.transparent?); + (t < self.used().len()).then_some(t) + } +} + +/// Parse an `.act` file. Anything but 768 bytes (palette only) or 772 bytes (plus the +/// trailer) is an error; never panics. +pub fn parse(data: &[u8]) -> Result { + if data.len() != PALETTE_LEN && data.len() != PALETTE_LEN + TRAILER_LEN { + return Err(PsdError::invalid("an .act file is 768 bytes, or 772 with the count trailer")); + } + let mut r = Reader::new(data); + let raw = r.bytes(PALETTE_LEN)?; + let mut colors = [[0u8; 3]; COLOR_COUNT]; + for (i, c) in colors.iter_mut().enumerate() { + let o = i * 3; + c.copy_from_slice(&raw[o..o + 3]); + } + let (count, transparent) = if r.remaining() >= TRAILER_LEN { (Some(r.u16()?), Some(r.u16()?)) } else { (None, None) }; + Ok(ActFile { colors, count, transparent }) +} + +/// Write an `.act` file: the 256-color palette plus the trailer when `file` has one. +/// Short palettes are padded with black; fails on a palette longer than 256 colors. +pub fn write(colors: &[[u8; 3]], count: Option, transparent: Option) -> Result> { + if colors.len() > COLOR_COUNT { + return Err(PsdError::LimitExceeded("an .act file holds at most 256 colors")); + } + let mut out = Vec::with_capacity(PALETTE_LEN + TRAILER_LEN); + for c in colors { + out.extend_from_slice(c); + } + out.resize(PALETTE_LEN, 0); + if count.is_some() || transparent.is_some() { + out.put_u16(count.unwrap_or(COLOR_COUNT as u16)); + out.put_u16(transparent.unwrap_or(0xFFFF)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> [[u8; 3]; COLOR_COUNT] { + let mut colors = [[0u8; 3]; COLOR_COUNT]; + for (i, c) in colors.iter_mut().enumerate() { + let v = i as u8; + *c = [v, v.wrapping_mul(7), v.wrapping_add(13)]; + } + colors[0] = [255, 255, 255]; + colors + } + + #[test] + fn round_trips_with_and_without_the_trailer() { + let colors = sample(); + let bare = write(&colors, None, None).unwrap(); + assert_eq!(bare.len(), PALETTE_LEN); + let f = parse(&bare).unwrap(); + assert_eq!((f.count, f.transparent), (None, None)); + assert_eq!(f.used(), &colors); + assert_eq!(f.transparent_index(), None); + assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), bare, "byte-stable"); + + let trailer = write(&colors, Some(40), Some(7)).unwrap(); + assert_eq!(trailer.len(), PALETTE_LEN + TRAILER_LEN); + let f = parse(&trailer).unwrap(); + assert_eq!((f.count, f.transparent), (Some(40), Some(7))); + assert_eq!(f.used().len(), 40); + assert_eq!(f.transparent_index(), Some(7)); + assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), trailer, "byte-stable"); + } + + #[test] + fn short_palettes_pad_and_long_ones_fail() { + let few = vec![[1u8, 2, 3]; 3]; + let bytes = write(&few, None, None).unwrap(); + assert_eq!(bytes.len(), PALETTE_LEN); + assert_eq!(&bytes[..9], &[1u8, 2, 3, 1, 2, 3, 1, 2, 3]); + assert_eq!(&bytes[9..12], &[0u8, 0, 0]); + let many = vec![[0u8; 3]; COLOR_COUNT + 1]; + assert!(write(&many, None, None).is_err()); + } + + #[test] + fn hostile_lengths_are_errors_not_panics() { + let good = write(&sample(), Some(256), Some(255)).unwrap(); + assert_eq!(good.len(), PALETTE_LEN + TRAILER_LEN); + for n in 0..good.len() { + // Only the two exact lengths parse (768 reads as a trailer-less palette). + if n == PALETTE_LEN { + assert!(parse(&good[..n]).is_ok(), "{n}"); + } else { + assert!(parse(&good[..n]).is_err(), "{n}"); + } + } + assert!(parse(&[]).is_err()); + assert!(parse(&vec![0u8; PALETTE_LEN + TRAILER_LEN + 1]).is_err(), "trailing byte"); + // A count past the palette clamps; a transparent index past it is none. + let f = parse(&write(&sample(), Some(1000), Some(999)).unwrap()).unwrap(); + assert_eq!(f.used().len(), COLOR_COUNT); + assert_eq!(f.transparent_index(), None); + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 00d73c4003..4fd526f076 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -42,6 +42,7 @@ pub mod abr; pub mod aco; +pub mod act; pub mod ase; pub mod atn; pub mod blend; diff --git a/crates/ui-egui/src/color_table_ui.rs b/crates/ui-egui/src/color_table_ui.rs new file mode 100644 index 0000000000..0e6000794e --- /dev/null +++ b/crates/ui-egui/src/color_table_ui.rs @@ -0,0 +1,141 @@ +//! Image › Mode › Color Table…: Load… and Save… for `.act` color tables, as in Photoshop. +//! +//! The dialog itself is the schema one (`filter_dialog`); this module only adds the file row. +//! Load fills the pending table from a picked `.act` (OK then applies it); Save writes the +//! pending table — the dialog's colors, else its preset, else the document's table — to a +//! picked `.act`. Parsing lives in the engine (`swatch_cmds`); this module only draws and +//! dispatches. + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +pub fn owns(fields: &Map) -> bool { + fields.get("__command").and_then(Value::as_str) == Some("image.mode.colorTable") +} + +fn unhex(s: &str) -> Option<[u8; 3]> { + let f = crate::color_picker_ui::parse_hex(s)?; + Some(f.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) +} + +/// The pending table: the dialog's colors, else its preset, else the document's table. +fn pending(app: &PhotocraftApp, fields: &Map) -> Option<(Vec<[u8; 3]>, Option)> { + if let Some(a) = fields.get("colors").and_then(Value::as_array) { + let colors: Option> = a.iter().filter_map(Value::as_str).map(unhex).collect(); + let colors = colors.filter(|c| !c.is_empty() && c.len() <= photocraft_engine::swatch_cmds::ACT_COLORS)?; + let transparent = fields.get("transparent").and_then(Value::as_u64).and_then(|i| u16::try_from(i).ok()); + return Some((colors, transparent)); + } + if let Some(name) = fields.get("table").and_then(Value::as_str) + && let Some(colors) = photocraft_engine::mode_cmds::preset_table(name) + { + return Some((colors, None)); + } + let t = app.session.active()?.doc.color_table.clone()?; + Some((t.colors, t.transparent.map(u16::from))) +} + +/// Load…/Save… row for the Color Table dialog (`id` finds its fields after the file picker +/// answers, as the picker is asynchronous). +pub fn buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: u64, fields: &Map) { + ui.horizontal(|ui| { + if ui.button(tl!("Load…")).clicked() + && let Err(e) = app.pick_file_bytes_filtered(&["act"], move |app, name, bytes| { + let (colors, transparent) = + photocraft_engine::swatch_cmds::act_table(&bytes).map_err(|e| format!("{}: {e}", crate::file_open::display_name(&name)))?; + // The dialog may close while the picker is open; then there is nothing to fill. + let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) else { return Ok(Value::Null) }; + d.fields.insert("table".into(), json!("custom")); + d.fields.insert("colors".into(), Value::Array(colors.into_iter().map(Value::String).collect())); + d.fields.insert("transparent".into(), transparent.map_or(Value::Null, |i| json!(i))); + Ok(Value::Null) + }) + && e != crate::file_dialog::CANCELLED + { + app.ui.status = e; + app.ui.status_error = true; + } + if ui.button(tl!("Save…")).clicked() { + let Some((colors, transparent)) = pending(app, fields) else { + app.ui.status = tl!("There is no color table to save.").into(); + app.ui.status_error = true; + return; + }; + let bytes = match photocraft_engine::swatch_cmds::act_bytes(&colors, transparent) { + Ok(bytes) => bytes, + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + return; + } + }; + let n = colors.len() as u64; + if let Err(e) = app.pick_save("Table.act", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt( + &crate::i18n::trn(crate::i18n::current(), n, "Saved {n} color to {file}", "Saved {n} colors to {file}"), + &[("file", &crate::file_open::display_name(&path))], + ); + Ok(json!({"path": path})) + }) { + app.ui.status = e; + app.ui.status_error = true; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) + } + + #[test] + fn owns_only_the_color_table_dialog() { + assert!(owns(&Map::from_iter([("__command".to_string(), json!("image.mode.colorTable"))]))); + assert!(!owns(&Map::from_iter([("__command".to_string(), json!("image.mode.indexedColor"))]))); + assert!(!owns(&Map::new())); + } + + #[test] + fn pending_prefers_dialog_colors_then_preset() { + let app = app(); + // The dialog's own colors win, with its transparent entry. + let fields = Map::from_iter([ + ("table".to_string(), json!("blackBody")), + ("colors".to_string(), json!(["#ff0000", "#00ff00"])), + ("transparent".to_string(), json!(1)), + ]); + assert_eq!(pending(&app, &fields), Some((vec![[255, 0, 0], [0, 255, 0]], Some(1)))); + // Else the preset (256 entries, no transparency). + let fields = Map::from_iter([("table".to_string(), json!("blackBody"))]); + let (colors, transparent) = pending(&app, &fields).unwrap(); + assert_eq!((colors.len(), transparent), (256, None)); + assert_eq!(colors[0], [0, 0, 0]); + // Nothing pending without colors, preset or document table. + assert_eq!(pending(&app, &Map::from_iter([("table".to_string(), json!("custom"))])), None); + } + + #[test] + fn loaded_act_bytes_fill_the_dialog_fields() { + let table: Vec<[u8; 3]> = (0..256).map(|i| [i as u8, 0, 255]).collect(); + let bytes = photocraft_engine::swatch_cmds::act_bytes(&table, Some(3)).unwrap(); + let (colors, transparent) = photocraft_engine::swatch_cmds::act_table(&bytes).unwrap(); + assert_eq!((colors.len(), transparent), (256, Some(3))); + // What OK would apply: the table's colors and transparent entry. + let fields = Map::from_iter([ + ("table".to_string(), json!("custom")), + ("colors".to_string(), Value::Array(colors.into_iter().map(Value::String).collect())), + ("transparent".to_string(), json!(3)), + ]); + let app = app(); + assert_eq!(pending(&app, &fields), Some((table, Some(3)))); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0530031f93..40318fa2af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -344,6 +344,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { outcome = Some(true); } } else { + // The Color Table dialog loads and saves `.act` files, as in Photoshop. + if matches!(d.kind, DialogKind::Command) && crate::color_table_ui::owns(&fields) { + crate::color_table_ui::buttons(app, ui, d.id, &fields); + } ui.add_space(8.0); // Align::Min, not Center: a centred row fills the height left over from last frame's // (larger) size, so a dialog whose body gets shorter would never shrink back. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38118955c3..9233633285 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,7 @@ pub mod cjk_fonts; pub mod clip_line_ui; pub mod color_picker_ui; pub mod color_range_ui; +pub mod color_table_ui; pub mod comps_ui; pub mod control; pub mod credits; diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 6138c08844..8d516e4b64 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -1,7 +1,7 @@ //! Preset files opened like documents (File › Open, drag-and-drop, the Brushes panel's //! Import Brushes…, the Preset Manager's Load…): Photoshop brushes (`.abr`) go to the brush //! library through `brush.presets.importAbr`, gradients (`.grd`) to the Gradients panel through -//! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through +//! `gradient.presets.importGrd`, swatches (`.aco`, `.act`, `.ase`) to the Swatches panel through //! `swatches.import`, patterns (`.pat`) to the Patterns panel through `pattern.import`, keyboard //! shortcut sets (`.kys`) to Edit › Keyboard Shortcuts through //! [`crate::kys_import`], instead of opening as documents. @@ -10,11 +10,11 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; -/// Extensions handled here rather than by the document importer (`.aco`/`.ase` swatches go to +/// Extensions handled here rather than by the document importer (`.aco`/`.act`/`.ase` swatches go to /// the Swatches panel through [`crate::swatches_ui::import_bytes`]). /// `.psp` is Photoshop's live `Keyboard Shortcuts.psp` (the same XML as a `.kys`); other `.psp` /// settings files are refused with a clear message. -pub const PRESET_EXTS: &[&str] = &["abr", "grd", "pat", "aco", "ase", "kys", "psp"]; +pub const PRESET_EXTS: &[&str] = &["abr", "grd", "pat", "aco", "act", "ase", "kys", "psp"]; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -58,7 +58,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("brush.presets.importAbr", "brushes"), "grd" => ("gradient.presets.importGrd", "gradients"), "pat" => ("pattern.import", "patterns"), - "aco" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), + "aco" | "act" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), "kys" | "psp" => return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, }; diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 749ab4a059..b6d75f8c23 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -5,7 +5,8 @@ //! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); //! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The //! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, -//! `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a +//! `.act`, `.ase`), Export (`.aco`), Export as Color Table (`.act`) and Export for Exchange +//! (`.ase`) and Reset. Every action is a //! `swatches.*` engine command; this module only draws and dispatches. Colours are shown in the //! RGB working space through `photocraft-cms` (cached per library revision and colour settings). @@ -149,7 +150,7 @@ fn source(name: &str, bytes: &[u8]) -> Value { json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) } -/// Import an `.aco`/`.ase` file's bytes (File › Open, drag and drop, the panel menu). +/// Import an `.aco`/`.act`/`.ase` file's bytes (File › Open, drag and drop, the panel menu). pub fn import_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8], replace: bool) -> Result { let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()); let mut p = source(name, bytes); @@ -220,6 +221,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled(!empty, egui::Button::new(tl!("Export Swatches for Exchange…"))).clicked() { act = Some("ase"); } + if ui.add_enabled(!empty, egui::Button::new(tl!("Export Swatches as Color Table…"))).clicked() { + act = Some("act"); + } ui.separator(); if ui.button(tl!("Reset Swatches")).clicked() { act = Some("reset"); @@ -237,7 +241,7 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } "import" => import_dialog(app, false), "replace" => import_dialog(app, true), - "aco" | "ase" => export_dialog(app, a), + "aco" | "ase" | "act" => export_dialog(app, a), _ => app.run("swatches.reset", json!({})), }; if let Err(e) = r From d658c13c4efcd3972272959886949c922799f482 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 06:12:49 +0100 Subject: [PATCH 0099/1452] fix(format): serialise directory bundle saves with a bundle-wide lock A directory save lists the existing objects, writes its own, publishes its manifest and removes what that manifest doesn't reference. Two writers (two stores, or two processes) saving the same bundle could interleave, and one writer's cleanup could remove an object the other's winning manifest needs. Each save now holds an OS advisory lock on save.lock for its whole transaction; a held lock is waited for (10 s by default, PcraftWriter::set_lock_wait), then the save fails without writing. A crash releases the lock; a file system without file locks saves unlocked as before. Fixes #4957. Co-Authored-By: Claude Opus 5.5 --- book/src/formats/pcraft.md | 3 ++ crates/format/src/lib.rs | 5 ++- crates/format/src/store.rs | 53 ++++++++++++++++++++++++++++++-- crates/format/tests/roundtrip.rs | 22 +++++++++++++ docs/development.md | 1 + 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 39385877a1..40ecbdd127 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -8,8 +8,11 @@ tiles/.zst blobs/.zst thumb.png composite/preview.png +save.lock ``` +`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. + The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly. ## Implemented load limits diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 0c87720212..992e925ae9 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -8,6 +8,7 @@ //! blobs/.zst content-addressed binary data (ICC, EXIF, PSD blocks, smart objects) //! thumb.png optional thumbnail (caller-rendered) //! composite/preview.png optional flattened preview (caller-rendered) +//! save.lock directory bundles only: held while a save runs (empty) //! ``` //! //! A bundle is either a ZIP archive (STORE entries) or a directory with the @@ -15,7 +16,9 @@ //! already compressed/wrote, so only new tiles are encoded. Directory saves //! verify existing content-addressed objects once per writer and folder, //! rechecking files whose size or modification time changes; they replace -//! damaged or missing objects and garbage-collect unreferenced ones. +//! damaged or missing objects and garbage-collect unreferenced ones. A directory +//! save holds an OS file lock on `save.lock` throughout, so two writers (or two +//! processes) saving one bundle take turns instead of removing each other's objects. //! //! This crate sits at L3 next to the compositor, so it does not render. //! Callers pass previews in [`SaveOptions`]; `photocraft-io` does that. diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..65b0b10265 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -18,6 +18,10 @@ use crate::{FormatError, LoadOptions, Result, SaveOptions}; pub(crate) const MANIFEST: &str = "manifest.json"; pub(crate) const THUMB: &str = "thumb.png"; pub(crate) const COMPOSITE: &str = "composite/preview.png"; +/// The file a directory save holds an exclusive lock on for its whole transaction (#4957). +pub(crate) const SAVE_LOCK: &str = "save.lock"; +/// How long a directory save waits for another save of the same bundle to finish. +const SAVE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10); fn tile_path(h: &str) -> String { format!("tiles/{h}.zst") @@ -366,11 +370,47 @@ pub struct PcraftWriter { compressed: HashMap>>, verified_directory: Option, reverify_budget: u64, + /// How long [`Self::save_dir`] waits for another save of the bundle ([`SAVE_LOCK_WAIT`]). + lock_wait: std::time::Duration, } impl Default for PcraftWriter { fn default() -> Self { - Self { hash_cache: HashMap::new(), compressed: HashMap::new(), verified_directory: None, reverify_budget: DEFAULT_REVERIFY_BUDGET } + Self { + hash_cache: HashMap::new(), + compressed: HashMap::new(), + verified_directory: None, + reverify_budget: DEFAULT_REVERIFY_BUDGET, + lock_wait: SAVE_LOCK_WAIT, + } + } +} + +/// Hold `dir`'s [`SAVE_LOCK`] for a directory save, so two writers (two stores, or two +/// processes) can't interleave: each lists the existing objects, writes its own, publishes its +/// manifest and removes what that manifest doesn't reference. Interleaved, one writer's cleanup +/// could remove an object the other's winning manifest needs (#4957). The lock is the OS's +/// advisory file lock, released when the returned file is dropped or the process ends, so a +/// crash leaves no stale lock. Another save holding it is waited for up to `wait`; after that the +/// save fails without writing anything. A file system without file locks (some network shares) +/// saves unlocked, as before. +fn lock_bundle(dir: &Path, wait: std::time::Duration) -> Result> { + let path = dir.join(SAVE_LOCK); + validate_bundle_file(dir, &path)?; + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path)?; + let start = std::time::Instant::now(); + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(file)), + Err(std::fs::TryLockError::WouldBlock) if start.elapsed() < wait => std::thread::sleep(std::time::Duration::from_millis(20)), + Err(std::fs::TryLockError::WouldBlock) => { + return Err(FormatError::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("another save of {} is in progress; nothing was written", dir.display()), + ))); + } + Err(std::fs::TryLockError::Error(_)) => return Ok(None), + } } } @@ -725,6 +765,12 @@ impl PcraftWriter { self.reverify_budget = bytes; } + /// How long a directory save waits for another save of the same bundle to finish before + /// failing without writing anything (10 s by default; see [`Self::save_dir`]). + pub fn set_lock_wait(&mut self, wait: std::time::Duration) { + self.lock_wait = wait; + } + fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); convert::check_nesting(&doc.layers)?; @@ -801,10 +847,13 @@ impl PcraftWriter { /// Save into a directory bundle: reuses only valid existing objects, /// rewrites missing or damaged objects, then writes the manifest - /// atomically and removes unreferenced objects. + /// atomically and removes unreferenced objects. The whole save holds the bundle's + /// `save.lock`, so saves of one bundle from other writers or processes wait their turn. pub fn save_dir(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions) -> Result { let canonical_dir = bundle_root(dir)?; let dir = canonical_dir.as_path(); + // Held until the end of the save: listing, writing, publishing and cleanup (#4957). + let _lock = lock_bundle(dir, self.lock_wait)?; let existing = list_objects(dir)?; // Reject a hostile existing entry before writing any object or manifest. This also // covers entries that this save would otherwise leave untouched or garbage-collect. diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 8b74a00c4b..b4c04d6db8 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } + +/// #4957: a directory save holds the bundle's lock for its whole transaction. While another +/// writer holds it, a save waits, then fails without writing anything; once it is released the +/// save goes ahead. +#[test] +fn directory_save_waits_for_the_bundle_lock() { + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("lock"); + let other = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("save.lock")).unwrap(); + other.lock().unwrap(); + let mut w = PcraftWriter::new(); + w.set_lock_wait(std::time::Duration::from_millis(100)); + let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(e.to_string().contains("in progress"), "{e}"); + assert!(!dir.join("manifest.json").exists(), "nothing written while locked"); + assert!(!dir.join("tiles").exists() || std::fs::read_dir(dir.join("tiles")).unwrap().count() == 0); + other.unlock().unwrap(); + w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + drop(other); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/development.md b/docs/development.md index f13690102e..d769719eb8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -191,6 +191,7 @@ cargo run -p photocraft-cli -- commands --filter blur # the c - `manifest.json`: the versioned document tree, with migrations in `format/src/migrate.rs`. - `tiles/.zst` and `blobs/.zst`: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes. - `thumb.png` and `composite/preview.png`: previews. +- `save.lock` (directory bundles only): an empty file a save holds an OS file lock on, so two writers or processes saving one bundle take turns (#4957). Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services). From c8ff8e3860fdb23ac58951df419f012c241612dc Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:58:39 +0530 Subject: [PATCH 0100/1452] Add Color Table dialog translations --- crates/ui-egui/src/i18n/cs.tsv | 5 +++++ crates/ui-egui/src/i18n/de.tsv | 5 +++++ crates/ui-egui/src/i18n/el.tsv | 5 +++++ crates/ui-egui/src/i18n/es.tsv | 5 +++++ crates/ui-egui/src/i18n/fr.tsv | 5 +++++ crates/ui-egui/src/i18n/hu.tsv | 5 +++++ crates/ui-egui/src/i18n/id.tsv | 5 +++++ crates/ui-egui/src/i18n/it.tsv | 5 +++++ crates/ui-egui/src/i18n/ja.tsv | 5 +++++ crates/ui-egui/src/i18n/ko.tsv | 5 +++++ crates/ui-egui/src/i18n/nl.tsv | 5 +++++ crates/ui-egui/src/i18n/pl.tsv | 5 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++++ crates/ui-egui/src/i18n/ru.tsv | 5 +++++ crates/ui-egui/src/i18n/sr.tsv | 5 +++++ crates/ui-egui/src/i18n/tr.tsv | 5 +++++ crates/ui-egui/src/i18n/uk.tsv | 5 +++++ crates/ui-egui/src/i18n/vi.tsv | 5 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++++ 20 files changed, 100 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805c20f76a..c2dbf9ba76 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3745,3 +3745,8 @@ navigator Zoom Zvětšení @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí Auto Add/Delete Automaticky přidat/odstranit ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + +# Color Table (.act) dialog + Export Swatches as Color Table… Exportovat vzorníky jako tabulku barev… + There is no color table to save. Neexistuje žádná tabulka barev k uložení. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Uložena {n} barva do {file}|Uloženy {n} barvy do {file}|Uloženo {n} barev do {file} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88ef957675..12d72bec89 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3135,3 +3135,8 @@ curves Smooth Glätten @plural {n} action|{n} actions {n} Aktion|{n} Aktionen Auto Add/Delete Autom. hinzuf./löschen ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + +# Color Table (.act) dialog + Export Swatches as Color Table… Farbfelder als Farbtabelle exportieren… + There is no color table to save. Es gibt keine Farbtabelle zum Speichern. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} Farbe in {file} gespeichert|{n} Farben in {file} gespeichert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8889313cb..ab51563048 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3835,3 +3835,8 @@ curves Smooth Εξομάλυνση @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Auto Add/Delete Αυτόματη προσθήκη/διαγραφή ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + +# Color Table (.act) dialog + Export Swatches as Color Table… Εξαγωγή δειγμάτων ως πίνακα χρωμάτων… + There is no color table to save. Δεν υπάρχει πίνακας χρωμάτων για αποθήκευση. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Αποθηκεύτηκε {n} χρώμα στο {file}|Αποθηκεύτηκαν {n} χρώματα στο {file} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 37f88f4294..8cd3950500 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2822,3 +2822,8 @@ curves Smooth Suavizar @plural {n} action|{n} actions {n} acción|{n} acciones Auto Add/Delete Añadir/eliminar automáticamente ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles + +# Color Table (.act) dialog + Export Swatches as Color Table… Exportar muestras como tabla de colores… + There is no color table to save. No hay ninguna tabla de colores para guardar. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Se guardó {n} color en {file}|Se guardaron {n} colores en {file} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7d508fb5c..3773ea4142 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3139,3 +3139,8 @@ curves Smooth Lisser @plural {n} action|{n} actions {n} action|{n} actions Auto Add/Delete Ajout/suppr. auto ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels + +# Color Table (.act) dialog + Export Swatches as Color Table… Exporter les nuances comme table de couleurs… + There is no color table to save. Il n’y a aucune table de couleurs à enregistrer. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} couleur enregistrée dans {file}|{n} couleurs enregistrées dans {file} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 70c2294205..9be6be0456 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4058,3 +4058,8 @@ cameraRaw Yellow Sárga Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás + +# Color Table (.act) dialog + Export Swatches as Color Table… Színtárak exportálása színtáblaként… + There is no color table to save. Nincs menthető színtábla. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} szín mentve ide: {file} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dcc02ec185..3181cace62 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3126,3 +3126,8 @@ curves Smooth Haluskan @plural {n} action|{n} actions {n} tindakan Auto Add/Delete Tambah/Hapus Otomatis ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel + +# Color Table (.act) dialog + Export Swatches as Color Table… Ekspor swatch sebagai Tabel Warna… + There is no color table to save. Tidak ada tabel warna untuk disimpan. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} warna disimpan ke {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c077d90aa1..b9dd5139a0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4100,3 +4100,8 @@ navigator Zoom Zoom @plural {n} action|{n} actions {n} azione|{n} azioni Auto Add/Delete Aggiungi/elimina automaticamente ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel + +# Color Table (.act) dialog + Export Swatches as Color Table… Esporta campioni come tabella colori… + There is no color table to save. Non c'è alcuna tabella colori da salvare. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Salvato {n} colore in {file}|Salvati {n} colori in {file} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f074e3e30..fcc93eb0f0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3815,3 +3815,8 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + +# Color Table (.act) dialog + Export Swatches as Color Table… スウォッチをカラーテーブルとして書き出し… + There is no color table to save. 保存するカラーテーブルがありません。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file} に {n} 色を保存しました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eefa162a65..bce2f1f375 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3312,3 +3312,8 @@ curves Smooth 매끄럽게 @plural {n} action|{n} actions 액션 {n}개 Auto Add/Delete 자동 추가/삭제 ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + +# Color Table (.act) dialog + Export Swatches as Color Table… 색상표로 견본 내보내기… + There is no color table to save. 저장할 색상표가 없습니다. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file}에 색상 {n}개 저장 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 68e98a6fef..2c4964a9e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3897,3 +3897,8 @@ curves Smooth Vloeiend maken @plural {n} action|{n} actions {n} handeling|{n} handelingen Auto Add/Delete Automatisch toevoegen/verwijderen ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels + +# Color Table (.act) dialog + Export Swatches as Color Table… Stalen exporteren als kleurtabel… + There is no color table to save. Er is geen kleurtabel om op te slaan. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} kleur opgeslagen in {file}|{n} kleuren opgeslagen in {file} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d76183794..e4e061e72c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3735,3 +3735,8 @@ curves Smooth Wygładź @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji Auto Add/Delete Automatyczne dodawanie/usuwanie ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli + +# Color Table (.act) dialog + Export Swatches as Color Table… Eksportuj próbki jako tabelę kolorów… + There is no color table to save. Brak tabeli kolorów do zapisania. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Zapisano {n} kolor do {file}|Zapisano {n} kolory do {file}|Zapisano {n} kolorów do {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d48ba9b47..bcd6be365a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3927,3 +3927,8 @@ layerLabel No Color Sem cor @plural {n} action|{n} actions {n} ação|{n} ações Auto Add/Delete Adicionar/excluir automaticamente ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels + +# Color Table (.act) dialog + Export Swatches as Color Table… Exportar amostras como tabela de cores… + There is no color table to save. Não há tabela de cores para salvar. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} cor salva em {file}|{n} cores salvas em {file} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a3cb6b2e..4c20e3c17b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3103,3 +3103,8 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина + +# Color Table (.act) dialog + Export Swatches as Color Table… Экспортировать образцы как таблицу цветов… + There is no color table to save. Нет таблицы цветов для сохранения. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Сохранён {n} цвет в {file}|Сохранены {n} цвета в {file}|Сохранено {n} цветов в {file} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f47fe20162..76bf7bf8b6 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4211,3 +4211,8 @@ shape Appearance Izgled Ink Characteristics Karakteristike mastila Solidity: Punoća: Redrawing Ponovno iscrtavanje + +# Color Table (.act) dialog + Export Swatches as Color Table… Извези узорке као табелу боја… + There is no color table to save. Нема табеле боја за чување. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Сачувана је {n} боја у {file}|Сачуване су {n} боје у {file}|Сачувано је {n} боја у {file} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 948c967467..e09d8a4e8c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3869,3 +3869,8 @@ cameraData Lens Lens Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: Redrawing Yeniden çiziliyor + +# Color Table (.act) dialog + Export Swatches as Color Table… Renk örneklerini Renk Tablosu olarak dışa aktar… + There is no color table to save. Kaydedilecek renk tablosu yok. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} renk {file} konumuna kaydedildi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2a72fdaf19..5d70174855 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3836,3 +3836,8 @@ curves Smooth Згладити @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Auto Add/Delete Автододавання/видалення ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + +# Color Table (.act) dialog + Export Swatches as Color Table… Експортувати зразки як таблицю кольорів… + There is no color table to save. Немає таблиці кольорів для збереження. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Збережено {n} колір у {file}|Збережено {n} кольори у {file}|Збережено {n} кольорів у {file} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88b855a707..6328fbb1e4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3624,3 +3624,8 @@ curves Smooth Làm mượt Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). Auto Add/Delete Tự động thêm/xóa ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel + +# Color Table (.act) dialog + Export Swatches as Color Table… Xuất mẫu màu thành Bảng màu… + There is no color table to save. Không có bảng màu nào để lưu. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Đã lưu {n} màu vào {file} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2842771124..b80e4d76a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2747,3 +2747,8 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 个动作 Auto Add/Delete 自动添加/删除 ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + +# Color Table (.act) dialog + Export Swatches as Color Table… 将色板导出为色表… + There is no color table to save. 没有可保存的色表。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} 已将 {n} 种颜色保存到 {file} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d02e06646e..73a9d0b6ba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2732,3 +2732,8 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 個動作 Auto Add/Delete 自動增加/刪除 ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + +# Color Table (.act) dialog + Export Swatches as Color Table… 將色票匯出為色表… + There is no color table to save. 沒有可儲存的色表。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} 已將 {n} 種顏色儲存至 {file} From 4121ff4dc1acbba7f30523f366965705d132e347 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 07:37:09 +0100 Subject: [PATCH 0101/1452] fix(tiff): keep a TIFF's alpha channel, and add a tiffCompression save option Photoshop writes a document's alpha channel as an unspecified extra sample (ExtraSamples 0). The decoder dropped it, so such a TIFF opened without its channel and a save never wrote one. With no extra sample marked as transparency, the first unspecified one is now decoded as the alpha and flagged as an alpha channel (Metadata::alpha_is_channel): flat TIFFs open it as a named alpha channel over an opaque Background, layered ones through their Photoshop channel names. Saving writes the first alpha channel as an unspecified extra sample when the document has no transparency (flat and layered), naming the channels it leaves out; extra samples beyond the alpha are dropped with a warning. Saves also take tiffCompression (none, lzw, zip) and the CLI --tiff-compression. Fixes #5301. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 28 ++++++-- crates/codecs/src/codecs/tiff.rs | 29 ++++++-- crates/codecs/src/image.rs | 11 +++ crates/codecs/tests/tiff_pages.rs | 30 +++++--- crates/codecs/tests/tiff_photoshop.rs | 22 ++++++ crates/engine/src/file_cmds.rs | 39 +++++++++-- crates/engine/src/file_cmds/tests.rs | 36 ++++++++++ crates/io/src/flat.rs | 94 ++++++++++++++++++++++---- crates/io/src/psd_import.rs | 2 +- crates/io/src/tiff_layers.rs | 10 ++- crates/io/tests/tiff_alpha_channels.rs | 80 ++++++++++++++++++++++ crates/io/tests/tiff_layers.rs | 38 ++++++----- 12 files changed, 357 insertions(+), 62 deletions(-) create mode 100644 crates/io/tests/tiff_alpha_channels.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a81cce61ce..9ea9f4f066 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,19 +15,20 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). + TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); + --tiff-compression compresses it (none, the default; lzw; zip). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -69,17 +70,22 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { + name: "convert", + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], + bare: &["--tiff-layers"], + run: convert, + }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -219,6 +225,14 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(c) = a.get("--tiff-compression") { + o.encode.tiff_compression = match c { + "none" => photocraft_codecs::TiffCompression::None, + "lzw" => photocraft_codecs::TiffCompression::Lzw, + "zip" => photocraft_codecs::TiffCompression::Deflate, + _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + }; + } Ok(o) } diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 5e09a68ece..9cae4e1c17 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; + let alpha_is_channel = img.meta.alpha_is_channel; img.meta = metadata(&file, &dir, &mut img.icc); + img.meta.alpha_is_channel = alpha_is_channel; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -541,8 +543,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 4, _ => 1, }; - let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0); + // With no extra sample marked as transparency, the first unspecified one (ExtraSamples 0) is + // decoded as the alpha and flagged as an alpha channel: Photoshop writes a document's alpha + // channel that way, and dropping it lost the channel (#5301). + let marked = extra.iter().position(|&e| e == 1 || e == 2); + let paletted = palette.is_some(); + let channel_alpha = marked.is_none() && !paletted && extra.first() == Some(&0); + let alpha_at = marked.or(channel_alpha.then_some(0)).filter(|&a| (color + a as u64) < spp); + // The `tiff` crate returns an RGB or CMYK alpha only when the first extra sample is marked as + // one, so an alpha channel (unspecified) is read through every sample too. + let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0 || channel_alpha); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -677,9 +687,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } + // Extra samples past the colour and the one decoded as the alpha are not imported. + let kept = out_layout.channels() as u64; + let skipped = if paletted { 0 } else { spp.saturating_sub(kept) }; + if skipped > 0 { + img.warnings.push(DecodeWarning::ExtraSamplesSkipped { count: u32::try_from(skipped).unwrap_or(u32::MAX) }); + } Ok(img) } @@ -1030,6 +1047,9 @@ struct TagSet<'a> { dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, alpha: bool, + /// The alpha is an alpha channel ([`Metadata::alpha_is_channel`]): written as an unspecified + /// extra sample (0) rather than unassociated alpha (2). + alpha_is_channel: bool, photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1060,8 +1080,8 @@ where } let d = im.encoder(); if tags.alpha { - // 2 = unassociated alpha - d.write_tag(Tag::ExtraSamples, &[2u16][..])?; + // 2 = unassociated alpha (transparency); 0 = unspecified, Photoshop's alpha channel. + d.write_tag(Tag::ExtraSamples, &[if tags.alpha_is_channel { 0u16 } else { 2 }][..])?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1169,6 +1189,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) } else { None }, text, alpha: img.layout().has_alpha(), + alpha_is_channel: img.layout().has_alpha() && img.meta.alpha_is_channel, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index db59d4fa6b..526681bf8b 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -127,6 +127,11 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, + /// The image's alpha is an alpha channel (a saved selection or texture mask), not + /// transparency: TIFF's "unspecified" extra sample (ExtraSamples 0), which Photoshop writes for + /// a document's alpha channel (#5301). Decoders set it; the TIFF encoder writes such an alpha as + /// an unspecified extra sample instead of as transparency. + pub alpha_is_channel: bool, } impl Metadata { @@ -137,6 +142,7 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() + && !self.alpha_is_channel } } @@ -163,6 +169,9 @@ pub enum DecodeWarning { /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, /// 255 when undefined). The code values are imported as stored, without a colour transform. DpxEncoding { transfer: u8, colorimetric: u8 }, + /// A TIFF's extra samples beyond the one decoded as the alpha (more alpha channels, or data + /// the file doesn't describe) were not imported. + ExtraSamplesSkipped { count: u32 }, } /// The SMPTE 268M name of a transfer characteristic or colorimetric code. @@ -213,6 +222,8 @@ impl fmt::Display for DecodeWarning { dpx_code_name(*transfer), dpx_code_name(*colorimetric) ), + DecodeWarning::ExtraSamplesSkipped { count: 1 } => write!(f, "one more extra channel in the TIFF was not imported"), + DecodeWarning::ExtraSamplesSkipped { count } => write!(f, "{count} more extra channels in the TIFF were not imported"), } } } diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index 60885e527d..b1f87a94cd 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() { } } +/// #5301: with no extra sample marked as transparency, the first unspecified one is decoded as +/// the alpha and flagged as an alpha channel (Photoshop's alpha channel). Extra samples beyond +/// the colour and the alpha are dropped, with a warning. #[test] -fn extra_samples_that_are_not_alpha_are_dropped() { +fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_dropped() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB. + // RGB + one unspecified extra sample: RGB and an alpha channel. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -142,9 +145,10 @@ fn extra_samples_that_are_not_alpha_are_dropped() { d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Rgb); - assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA. + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, true), "planar {planar}"); + assert_eq!(img.data(), &rgb_of(4, 4)[..], "planar {planar}"); + assert_eq!(img.warnings, [], "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA (transparency), the extra dropped. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -152,20 +156,23 @@ fn extra_samples_that_are_not_alpha_are_dropped() { d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(false, true, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, false), "planar {planar}"); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "planar {planar}"); } - // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. + // Gray + alpha + extra: gray and alpha, the extra dropped; gray + an unspecified extra: gray + // and an alpha channel. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::GrayA); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, false)); assert_eq!(img.data(), &rgb_of(3, 2)[..]); + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }]); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Gray); - assert_eq!(img.data(), &rgb_of(2, 1)[..]); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, true)); + assert_eq!(img.data(), &rgb_of(2, 2)[..]); } #[test] @@ -193,7 +200,8 @@ fn alpha_after_an_unspecified_extra_sample_is_kept() { let what = format!("photometric {photometric} little {little} big {big} bits {bits} planar {planar}"); assert_eq!((img.layout(), img.sample_type()), (layout, sample), "{what}"); assert_eq!(img.data(), &want[..], "{what}"); - assert_eq!(img.warnings, [], "{what}"); + // The unspecified sample before the alpha is the one left out. + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "{what}"); } } } diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 400b72eb0c..1daebcc9f4 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -172,3 +172,25 @@ fn malformed_tags_read_as_absent_without_panicking() { f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes()); assert_eq!(tiff_photoshop_tags(&f).1, None); } + +/// #5301: an alpha flagged as an alpha channel is written as an unspecified extra sample +/// (ExtraSamples 0, as Photoshop writes a document's alpha channel) and decodes back flagged, in +/// every layout with an alpha and both orders of decoding (chunky and the all-samples path); +/// transparency stays unassociated alpha (2). +#[test] +fn an_alpha_channel_round_trips_as_an_unspecified_extra_sample() { + for layout in [ChannelLayout::Rgba, ChannelLayout::GrayA, ChannelLayout::CmykA] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] { + for channel in [true, false] { + let mut img = synth(13, 11, layout, SampleType::U8, 7, 0.4); + img.meta.alpha_is_channel = channel; + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {compression:?} channel {channel}"); + assert_eq!(back.meta.alpha_is_channel, channel, "{layout:?} {compression:?}"); + assert_eq!(back.data(), img.data(), "{layout:?} {compression:?} channel {channel}"); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + } + } + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7689f39679..0e286f0fa3 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -262,6 +262,8 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. pub dpx_bits: Option, + /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. + pub tiff_compression: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -271,7 +273,15 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { + quality: None, + tiff_layers: false, + tga_bits: None, + dpx_bits: None, + tiff_compression: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -287,6 +297,7 @@ impl SaveOpts { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, + tiff_compression: tiff_compression_param(p, cmd)?, image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -318,6 +329,21 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + use photocraft_codecs::TiffCompression; + match p.get("tiffCompression") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_str() { + Some("none") => Ok(Some(TiffCompression::None)), + Some("lzw") => Ok(Some(TiffCompression::Lzw)), + Some("zip") => Ok(Some(TiffCompression::Deflate)), + _ => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`tiffCompression` must be \"none\", \"lzw\" or \"zip\", got {v}") }), + }, + } +} + /// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's /// depth). Anything else is a bad parameter of command `cmd`. pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result> { @@ -346,6 +372,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; + if let Some(c) = save.tiff_compression { + opts.encode.tiff_compression = c; + } if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -1486,7 +1515,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1549,7 +1578,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1558,7 +1587,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1586,7 +1615,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 99ddce15de..2e14eddd54 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1147,3 +1147,39 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { assert_eq!(std::fs::read(&first).unwrap(), before[0]); assert_eq!(std::fs::read(&second).unwrap(), before[1]); } + +/// The Compression tag (259) of a TIFF's first directory. +fn tiff_compression_tag(b: &[u8]) -> Option { + let le = b.get(..2)? == b"II"; + let u16_at = |i: usize| b.get(i..i + 2).map(|s| if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }); + let u32_at = + |i: usize| b.get(i..i + 4).map(|s| if le { u32::from_le_bytes([s[0], s[1], s[2], s[3]]) } else { u32::from_be_bytes([s[0], s[1], s[2], s[3]]) }); + let ifd = u32_at(4)? as usize; + (0..usize::from(u16_at(ifd)?)).map(|k| ifd + 2 + 12 * k).find(|e| u16_at(*e) == Some(259)).and_then(|e| u16_at(e + 8)) +} + +/// #5301: `tiffCompression` picks a TIFF's compression (TIFF Options: None, LZW, ZIP); unset, +/// none. Anything else is refused before anything is written. +#[test] +fn save_a_copy_writes_the_tiff_compression_asked_for() { + let dir = tmp("tiff-compression"); + for (params, tag) in + [(json!({}), 1), (json!({"tiffCompression": "none"}), 1), (json!({"tiffCompression": "lzw"}), 5), (json!({"tiffCompression": "zip"}), 8)] + { + let mut s = session(12, 6, 8); + let out = join(&dir, "a.tif"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(tiff_compression_tag(&bytes), Some(tag), "{params}"); + assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + } +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 5512a55c01..fe70cc7ffc 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -36,7 +36,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } r.document.file_encoding = file_encoding(bytes); if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { - tga_alpha_to_channel(&mut r.document)?; + alpha_to_channel(&mut r.document, "Alpha 1")?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear @@ -103,11 +103,13 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { } } -/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the -/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; -/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel -/// on the next save, where only an alpha channel becomes the Targa's alpha. -fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { +/// Moves a just-opened single layer's alpha into an alpha channel named `name`, leaving an +/// opaque Background. Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" +/// channel holding the fourth byte, even when the header declares no alpha bits, and keeps the +/// stored colours (#2225; measured on Photoshop 27.11). Layer transparency would hide those +/// colours and lose the channel on the next save, where only an alpha channel becomes the +/// Targa's alpha. A TIFF's unspecified extra sample is Photoshop's alpha channel too (#5301). +fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { let canvas = doc.bounds(); let depth = doc.depth; let Some(layer) = doc.layers.first_mut() else { return Ok(()) }; @@ -133,11 +135,11 @@ fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { layer.name = "Background".into(); layer.locks.transparency = true; layer.locks.position = true; - doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", alpha)); + doc.channels.push(photocraft_doc::AlphaChannel::new(name, alpha)); Ok(()) } -/// Undoes [`tga_alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel +/// Undoes [`alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel /// becomes the layer's transparency again. For image sequences, which treat a frame's alpha as /// transparency, as video footage does (Photoshop's Interpret Footage). Anything else is left /// alone. @@ -187,7 +189,27 @@ pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result } return Ok(r); } - image_to_document(name, &img.oriented(orientation)?) + // An unspecified extra sample is an alpha channel, not transparency (#5301): named as the + // TIFF's Photoshop resources name it (Unicode, else Pascal names), else "Alpha 1". + let channel_name = img.meta.alpha_is_channel.then(|| tiff_alpha_name(img.meta.photoshop_resources.as_deref())); + let mut r = image_to_document(name, &img.oriented(orientation)?)?; + if let Some(channel_name) = channel_name { + alpha_to_channel(&mut r.document, &channel_name)?; + } + Ok(r) +} + +/// The first alpha channel's name in a TIFF's Photoshop resources (1045 Unicode names, else +/// 1006 Pascal names), else "Alpha 1". +fn tiff_alpha_name(resources: Option<&[u8]>) -> String { + let resources = resources.and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()).map(|(r, _)| r).unwrap_or_default(); + let unicode = resources.iter().find(|r| r.id == 1045).and_then(|r| crate::psd_import::unicode_names(&r.data).into_iter().next()); + let pascal = || { + let data = &resources.iter().find(|r| r.id == 1006)?.data; + let n = usize::from(*data.first()?); + Some(String::from_utf8_lossy(data.get(1..1 + n)?).into_owned()) + }; + unicode.or_else(pascal).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Alpha 1".into()) } /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a @@ -560,9 +582,39 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti if format == Format::Tga { img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; } + if format == Format::Tiff { + img = tiff_image(doc, img, &mut warnings)?; + } encode_image(&img, format, opts, warnings) } +/// A flat TIFF's image with the document's alpha channel (#5301). Photoshop writes an alpha +/// channel as an unspecified extra sample; this writer holds one extra sample, so the first +/// alpha channel (spot channels don't count) is written when the document has no transparency, +/// named in the Photoshop resources so it opens with its name. With transparency, that is the +/// extra sample and the alpha channels are left out, as before; either way the ones left out are +/// named in a warning. +fn tiff_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { + let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); + let names = |cs: &[&AlphaChannel]| cs.iter().map(|c| format!("\"{}\"", c.name)).collect::>().join(", "); + let Some((first, rest)) = alphas.split_first() else { return Ok(img) }; + if img.layout().has_alpha() { + warnings.push(format!("the TIFF's one extra channel holds the transparency; alpha channels not saved: {}", names(&alphas))); + return Ok(img); + } + if !rest.is_empty() { + warnings.push(format!("the TIFF holds one alpha channel; not saved: {}", names(rest))); + } + let mut out = append_alpha_channel(doc, &img, first)?; + out.meta.alpha_is_channel = true; + let resources = [ + photocraft_psd::ImageResource::new(1006, crate::psd_export::pascal_names_resource(&[first.name.as_str()])), + photocraft_psd::ImageResource::new(1045, crate::psd_export::unicode_names_resource(&[first.name.as_str()])), + ]; + out.meta.photoshop_resources = photocraft_psd::tiff::resources_to_bytes(&resources).ok(); + Ok(out) +} + /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an /// alpha channel (spot channels don't count), else 24. pub fn tga_default_bits(doc: &Document) -> TgaBits { @@ -620,6 +672,18 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa } else { img }; + append_alpha_channel(doc, &img, channel) +} + +/// `img` (without alpha: RGB, gray or CMYK) with `channel`'s values added as its alpha. +fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> Result { + let out_layout = match img.layout() { + ChannelLayout::Rgb => ChannelLayout::Rgba, + ChannelLayout::Gray => ChannelLayout::GrayA, + ChannelLayout::Cmyk => ChannelLayout::CmykA, + other => return Err(IoError::Unsupported(format!("an alpha channel can't be added to a {other:?} image"))), + }; + let colors = img.layout().channels(); let (w, h) = img.dimensions(); let canvas = doc.bounds(); if canvas.width() != w || canvas.height() != h { @@ -629,22 +693,22 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa let row = img.data().len() / (h.max(1) as usize); let band = (BAND_BYTES / row.max(1)).max(1); let stride = channel.surface.channels().max(1); - let mut data = try_buffer(img.pixel_count(), ChannelLayout::Rgba.channels() * sample.bytes())?; + let mut data = try_buffer(img.pixel_count(), out_layout.channels() * sample.bytes())?; let mut y0 = canvas.y0; for rows in img.data().chunks(row.max(1) * band) { let n = (rows.len() / row.max(1)) as u32; let y1 = y0.saturating_add(i32::try_from(n).unwrap_or(i32::MAX)); let alpha = channel.surface.read_region(Rect::new(canvas.x0, y0, canvas.x1, y1)); - let rgb = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); - let mut vals = Vec::with_capacity(rgb.len() / 3 * 4); - for (px, a) in rgb.as_chunks::<3>().0.iter().zip(alpha.chunks_exact(stride)) { + let color = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); + let mut vals = Vec::with_capacity(color.len() / colors.max(1) * (colors + 1)); + for (px, a) in color.chunks_exact(colors.max(1)).zip(alpha.chunks_exact(stride)) { vals.extend_from_slice(px); vals.push(a.first().copied().unwrap_or(1.0)); } - data.extend_from_slice(Image::from_normalized(w, n, ChannelLayout::Rgba, sample, &vals)?.data()); + data.extend_from_slice(Image::from_normalized(w, n, out_layout, sample, &vals)?.data()); y0 = y1; } - Ok(Image::from_raw(w, h, ChannelLayout::Rgba, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) + Ok(Image::from_raw(w, h, out_layout, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) } /// Encodes a flat codec image as `format`: the fidelity warnings, then the codec. The end of diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 444c2d6e5b..09073a015b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -589,7 +589,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -fn unicode_names(data: &[u8]) -> Vec { +pub(crate) fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 8a5810e922..e12bff5126 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -221,10 +221,13 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); - let channels = cc + usize::from(has_alpha); + // Without transparency, the first alpha channel is the TIFF's extra sample, written as an + // unspecified one (Photoshop's alpha channel) and named by the resources above (#5301). + let channel_alpha = !has_alpha && usize::from(file.header.channels) > cc; + let channels = cc + usize::from(has_alpha || channel_alpha); let extra = usize::from(file.header.channels).saturating_sub(channels); if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); + warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one extra channel only)")); } let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); @@ -232,7 +235,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha); + let layout = layout_for(fmt.mode, has_alpha || channel_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -245,6 +248,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), + alpha_is_channel: channel_alpha, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) diff --git a/crates/io/tests/tiff_alpha_channels.rs b/crates/io/tests/tiff_alpha_channels.rs new file mode 100644 index 0000000000..d44efc17c6 --- /dev/null +++ b/crates/io/tests/tiff_alpha_channels.rs @@ -0,0 +1,80 @@ +//! #5301: a TIFF's alpha channel. Photoshop writes a document's alpha channel as an +//! "unspecified" extra sample (ExtraSamples 0); it opens as an alpha channel over an opaque +//! image, not as transparency, and a document's alpha channel is written back that way. + +mod common; +use common::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; + +/// An opaque one-layer document of `mode` with the alpha channels `names`. +fn doc_with_channels(mode: ColorMode, names: &[&str]) -> Document { + let (w, h) = (40, 30); + let mut d = Document::new("t", Size::new(w, h), mode, SampleType::U8); + let canvas = Rect::new(0, 0, w as i32, h as i32); + d.layers.push(raster("Background", PixelFormat::new(mode, SampleType::U8, true), canvas, 3, false)); + for (i, name) in names.iter().enumerate() { + d.channels.push(AlphaChannel::new(*name, mask_surface(SampleType::U8, canvas, 0.0, 10 + i as u32))); + } + d +} + +fn channel_values(d: &Document, i: usize) -> Vec { + d.channels[i].surface.read_region(d.bounds()) +} + +fn tiff(doc: &Document, layers: bool) -> ExportResult { + export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") +} + +#[test] +fn a_flat_tiff_keeps_one_alpha_channel_with_its_name() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + let d = doc_with_channels(mode, &["Letters"]); + let r = tiff(&d, false); + assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{mode:?}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.meta.alpha_is_channel, "{mode:?}: written as an unspecified extra sample"); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{mode:?}"); + assert_eq!(back.channels[0].name, "Letters"); + assert_eq!(channel_values(&back, 0), channel_values(&d, 0), "{mode:?}: the channel's values"); + assert_eq!(back.layers.len(), 1); + assert_eq!(back.layers[0].name, "Background", "{mode:?}: opaque, not transparency"); + } +} + +#[test] +fn a_flat_tiff_names_the_alpha_channels_it_leaves_out() { + let d = doc_with_channels(ColorMode::Rgb, &["Kept", "Second", "Third"]); + let r = tiff(&d, false); + assert!(r.warnings.iter().any(|w| w.contains("one alpha channel") && w.contains("\"Second\", \"Third\"")), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Kept"]); +} + +#[test] +fn transparency_stays_the_extra_sample_and_the_channels_are_named_as_left_out() { + let mut d = doc_with_channels(ColorMode::Rgb, &["Letters"]); + d.layers[0] = raster("Layer 0", PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), Rect::new(5, 5, 20, 20), 3, true); + let r = tiff(&d, false); + assert!(r.warnings.iter().any(|w| w.contains("holds the transparency") && w.contains("\"Letters\"")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha() && !img.meta.alpha_is_channel); + let back = import("x.tif", &r.bytes).unwrap().document; + assert!(back.channels.is_empty()); +} + +#[test] +fn a_layered_tiff_keeps_one_alpha_channel_with_its_name() { + let d = doc_with_channels(ColorMode::Rgb, &["Letters"]); + let r = tiff(&d, true); + assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Letters"]); + assert_eq!(channel_values(&back, 0), channel_values(&d, 0)); + // Another reader sees an opaque image with an alpha channel, as Photoshop wrote it. + assert!(photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel); +} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 56e51bf9f9..d1dd39b8c4 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -20,7 +20,9 @@ fn tags(bytes: &[u8]) -> (Option>, Option>) { (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) } -fn roundtrip(doc: &Document) -> Document { +/// `doc` through a layered TIFF, and whether the TIFF kept its first alpha channel (it does +/// when the document has no transparency for the one extra sample, #5301). +fn roundtrip(doc: &Document) -> (Document, bool) { let r = tiff(doc, true); let (resources, layers) = tags(&r.bytes); let layers = layers.expect("layer data"); @@ -34,17 +36,19 @@ fn roundtrip(doc: &Document) -> Document { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not - // kept, and the export says so. + // A TIFF holds one extra sample: the transparency, or else the first alpha channel. The + // other alpha channels (and a Quick Mask) are not kept, and the export says so. + let kept = flat.meta.alpha_is_channel; let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); - import("x.tif", &r.bytes).expect("import").document + assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > usize::from(kept), "{:?}", r.warnings); + (import("x.tif", &r.bytes).expect("import").document, kept) } -/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. -fn expected(doc: &Document) -> Document { +/// `doc` as a layered TIFF round trip returns it: with its first alpha channel when the TIFF +/// kept it (`kept`), without the others or a Quick Mask. +fn expected(doc: &Document, kept: bool) -> Document { let mut d = doc.clone(); - d.channels.clear(); + d.channels.truncate(usize::from(kept)); d.quick_mask = None; d } @@ -54,8 +58,8 @@ macro_rules! rt_case { #[test] fn $name() { let d = gen_doc($mode, $depth, $f); - let back = roundtrip(&d); - assert_docs_eq(&expected(&d), &back); + let (back, kept) = roundtrip(&d); + assert_docs_eq(&expected(&d, kept), &back); } }; } @@ -78,8 +82,8 @@ fn tiff_and_psd_open_as_the_same_document() { let d = gen_doc(mode, depth, Features::ALL); let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); let from_psd = import("x.psd", &psd.bytes).unwrap().document; - let from_tif = roundtrip(&d); - assert_docs_eq(&expected(&from_psd), &from_tif); + let (from_tif, kept) = roundtrip(&d); + assert_docs_eq(&expected(&from_psd, kept), &from_tif); // The composite the TIFF carries is what the document looks like. let tif = tiff(&d, true); let flat = import( @@ -261,12 +265,14 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps - // everything else; the file is then in the encoder's byte order. + // PSD rules it is an alpha channel, which a re-save keeps when the document has no + // transparency for the one extra sample (#5301). Re-saving keeps everything else; the + // file is then in the encoder's byte order. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); + let kept = photocraft_codecs::decode(&out.bytes).unwrap().meta.alpha_is_channel; let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d), &back); + assert_docs_eq(&expected(d, kept), &back); } } @@ -313,7 +319,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); - assert_docs_eq(&expected(&d), &back); + assert_docs_eq(&expected(&d, photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel), &back); } } } From 4c4138d6eb2b0b878c385812ea5906bc62a08bc8 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 07:38:36 +0100 Subject: [PATCH 0102/1452] docs: TIFF alpha channels and compression (#5301) Co-Authored-By: Claude Opus 5.5 --- book/src/formats/raster-formats.md | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 2e65d51550..7afb2b9bbf 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. A TIFF holds one extra sample: the transparency, or, in a document without transparency, the first alpha channel. Photoshop writes an alpha channel as an *unspecified* extra sample (ExtraSamples 0); PhotoCraft opens one as a named alpha channel over an opaque image, flat or layered, and writes the first alpha channel back that way. Further alpha channels are not written, and the save names them. Lab documents are saved flat. TIFFs are written uncompressed unless a script asks: `"tiffCompression": "lzw"` or `"zip"` in the save, batch and export commands (`--tiff-compression` on the CLI). Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..87e6c99585 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (TIFF alpha channels open and save, LZW/ZIP for scripted saves, #5301; earlier: palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; one alpha channel opens and saves (an unspecified extra sample, #5301), LZW/ZIP by `tiffCompression` (FILE-215-7: alpha channels beyond the first, or with transparency, not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A TIFF's alpha channel (Photoshop's unspecified extra sample) opens as a named alpha channel and the first alpha channel saves back; scripted saves take `tiffCompression` (#5301) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..db722d5ed4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G10: a TIFF's first alpha channel opens and saves, #5301; earlier: G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -221,7 +221,7 @@ what an agent can't finish alone. - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, + Raw; TIFF alpha channels beyond the first (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -263,6 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G10: TIFF alpha channels narrowed to those beyond the first (#5301) | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..4112aa8648 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 40b242ea53..1a28e7b210 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -52,7 +52,7 @@ id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" status = "partial" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them" [[item]] id = "FILE-215-8" From 478c986972792531ccc6669bc4a57dde80a1956f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 18:04:46 +1100 Subject: [PATCH 0103/1452] IO: open .psd/.psb/.psdt through the PSD reader, not codecs (#5258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open sent Photoshop extensions that lacked an 8BPS prefix (or reached import by name) to photocraft-codecs, which reports "unrecognized image format". Route them like other native formats, and offer a Photoshop filter in the open dialog. --- apps/photocraft-web/src/web.rs | 5 ++++- apps/photocraft/src/services.rs | 8 ++++++++ crates/io/src/lib.rs | 27 +++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..14105cc6c1 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -362,7 +362,10 @@ fn services(inbox: Inbox) -> Services { let dialog = if let Some(exts) = extensions { rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts) } else { - rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS) + rfd::AsyncFileDialog::new() + .add_filter("All Formats", OPEN_EXTS) + .add_filter("Photoshop", &["psd", "psb", "psdt"]) + .add_filter("SVG", SVG_EXTS) }; let answer = if multiple { match dialog.pick_files().await { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..e2df191020 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -42,6 +42,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { vec![ ("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), + ("Photoshop", open_filter_extensions(&["psd", "psb", "psdt"])), ("OpenRaster", open_filter_extensions(&["ora"])), ("Krita", open_filter_extensions(&["kra", "krz"])), ("SVG", open_filter_extensions(SVG_EXTS)), @@ -628,6 +629,9 @@ mod tests { assert!(!globs.iter().any(|glob| matches(glob, "txt"))); let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default(); assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS)); + let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default(); + assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"])); + assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD"))); assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz"))); } @@ -637,6 +641,10 @@ mod tests { assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS); assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]); assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()])); + assert_eq!( + open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts), + Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()]) + ); } #[test] diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..c2c91a10b4 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -230,11 +230,20 @@ impl TgaBits { } } +/// Photoshop document extensions File › Open routes to the PSD reader. +const PSD_EXTS: &[&str] = &["psd", "psb", "psdt"]; + /// `true` if `bytes` start with the PSD/PSB signature. pub fn is_psd(bytes: &[u8]) -> bool { bytes.starts_with(b"8BPS") } +/// PSD/PSB/PSDT by magic or by a declared Photoshop extension so File › Open does not +/// send `.psd` through the generic image codecs (`codec: unrecognized image format`, #5258). +fn is_psd_input(name: &str, bytes: &[u8]) -> bool { + is_psd(bytes) || PSD_EXTS.iter().any(|e| has_extension(name, e)) +} + /// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything /// else is decoded with `photocraft-codecs`. pub fn import(name: &str, bytes: &[u8]) -> Result { @@ -272,7 +281,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); } - if is_psd(bytes) { + if is_psd_input(name, bytes) { let file = PsdFile::from_bytes(bytes)?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. @@ -516,7 +525,7 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { #[cfg(test)] mod tests { - use super::is_svg_input; + use super::{import, is_psd_input, is_svg_input}; #[test] fn svg_extension_does_not_override_another_format_signature() { @@ -526,4 +535,18 @@ mod tests { assert!(is_svg_input("drawing.svgz", &[0x1f, 0x8b, 0, 0])); assert!(!is_svg_input("drawing.svgz", webp_header)); } + + #[test] + fn photoshop_extension_reaches_the_psd_loader() { + // A declared .psd/.PSB/.psdt must not fall through to codecs (#5258). + for name in ["photo.psd", "dir/Photo.PSD", r"C:\w\a.PSB", "card.psdt"] { + assert!(is_psd_input(name, b"not a signature"), "{name}"); + } + assert!(!is_psd_input("photo.png", b"not a signature")); + assert!(is_psd_input("renamed.png", b"8BPS")); + let err = import("photo.psd", b"not a photoshop file").expect_err("named .psd is a PSD parse, not a codec miss"); + let msg = err.to_string(); + assert!(msg.starts_with("PSD:"), "{msg}"); + assert!(!msg.contains("unrecognized image format"), "{msg}"); + } } From 2c5ed1c91d4dd3c430502ec6d3c3ebef7c0e1ed7 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 09:22:00 +0200 Subject: [PATCH 0104/1452] Layered TIFF corpus test: a flat Duotone is expected with its inks rendered With the ink block read, an opened duotone has its real inks. A single-layer one is saved flat, which writes the inks rendered as RGB (flat.rs); the test compared that with the grey plate. Refs #4814 --- crates/io/tests/tiff_corpus.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/io/tests/tiff_corpus.rs b/crates/io/tests/tiff_corpus.rs index 143e75bea8..697eab6d32 100644 --- a/crates/io/tests/tiff_corpus.rs +++ b/crates/io/tests/tiff_corpus.rs @@ -114,6 +114,14 @@ fn corpus_round_trips_through_layered_tiff() { let expected: Document = { let mut d = oracle.clone(); d.channels.clear(); + // A flat Duotone is written as its inks rendered in RGB (as for any flat format). + if flat + && d.mode == photocraft_color::ColorMode::Duotone + && let Some(duo) = d.duotone.take() + { + d.layers.push(Layer::new("Duotone", photocraft_doc::LayerContent::Adjustment(duo.display_adjustment()))); + d.mode = photocraft_color::ColorMode::Rgb; + } d }; let x = photocraft_compose::flatten(&expected).px; From a042bd373d12e0b7e97e472a92fdecb9eb90f2e9 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 18:23:51 +1100 Subject: [PATCH 0105/1452] Show Load Actions and the rest of the Actions panel menu. The docked Actions panel menu already had Load/Save, but it omitted New Action, Play, Record, and Delete, so it looked like only docking commands. Match the Actions panel id in both casings and list the Photoshop-parity items. --- crates/ui-egui/src/actions.rs | 78 +++++++++++++++++++++-- crates/ui-egui/src/panel_docking.rs | 2 +- crates/ui-egui/src/panel_docking_tests.rs | 19 ++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index e0f3713f2e..68882e62ee 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -436,21 +436,58 @@ pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) /// The Actions entries of the panel group's menu ("…"). pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut act: Option<&str> = None; + let recording = app.session.actions.recording.is_some(); + let any = !app.session.actions.list.is_empty(); + let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); + let can_delete = selected && (!recording || app.ui.actions.selected_step.is_some()); + if ui.add_enabled(!recording, egui::Button::new(tl!("New Action…"))).clicked() { + act = Some("new"); + } + if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { + act = Some("play"); + } + if recording { + if ui.button(tl!("Stop Recording")).clicked() { + act = Some("stopRec"); + } + } else if ui.button(tl!("Start Recording")).clicked() { + act = Some("record"); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } + ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { + act = Some("stop"); + } + ui.separator(); if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } - let any = !app.session.actions.list.is_empty(); if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } - ui.separator(); - let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); - if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { - act = Some("stop"); - } let Some(a) = act else { return }; ui.close(); let r = match a { + "new" => { + begin_recording(app, false); + Ok(Value::Null) + } + "record" => { + begin_recording(app, true); + Ok(Value::Null) + } + "stopRec" => app.run("actions.stop", json!({})), + "play" => playback_selection(app).map_or(Ok(Value::Null), |i| { + let v = app.run("actions.play", json!({"action": i}))?; + report_play(app, &v); + Ok(v) + }), + "delete" => { + delete_selection(app); + Ok(Value::Null) + } "load" => load_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), @@ -911,6 +948,35 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn panel_menu_lists_load_save_and_existing_action_commands() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut empty_app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty_app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut empty = Harness::builder().with_size(vec2(240.0, 280.0)).build_ui_state(|ui, app| panel_menu(app, ui), empty_app); + empty.run_steps(2); + assert!(empty.query_all_by_label("Load Actions…").count() > 0); + assert!(empty.query_all_by_label("New Action…").count() > 0); + assert!(empty.query_all_by_label("Start Recording").count() > 0); + empty.get_by_label("New Action…").click(); + empty.run_steps(2); + assert_eq!(empty.state().session.actions.list.len(), 1); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.session.actions.list.push(Action::new("Make Layer", vec![("layer.new.layer".into(), json!({}))])); + app.ui.actions.selected = Some(0); + let mut h = Harness::builder().with_size(vec2(240.0, 320.0)).build_ui_state(|ui, app| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Load Actions…", "Save Actions…", "Insert Stop…", "Play", "Delete", "Start Recording"] { + assert!(h.query_all_by_label(label).count() > 0, "{label}"); + } + let layers = h.state().session.active().unwrap().doc.layers.len(); + h.get_by_label("Play").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); + } + #[test] fn actions_list_grows_with_panel_and_keeps_footer_visible() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index de56d6714c..c02844662d 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -849,7 +849,7 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::brush_panel::panel_menu(app, ui); ui.separator(); } - "actions" => { + "actions" | "Actions" => { crate::actions::panel_menu(app, ui); ui.separator(); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 735274eb85..ffbb7acff4 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -606,6 +606,25 @@ fn customized_panel_menus_and_rail_remain_reachable_after_float_and_restore() { assert!(menu.query_all_by_label("New Swatch…").count() > 0); } +#[test] +fn actions_panel_menu_includes_load_and_save_actions() { + let mut ready = false; + let mut menu = Harness::builder().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "actions"); + }, + self::app(), + ); + menu.run_steps(2); + assert!(menu.query_all_by_label("Load Actions…").count() > 0); + assert!(menu.query_all_by_label("Save Actions…").count() > 0); + assert!(menu.query_all_by_label("New Action…").count() > 0); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); From f7842751803cf39432946c463ba0cf4b718f477b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 18:34:48 +1100 Subject: [PATCH 0106/1452] Open Affinity files renamed .psd with the Affinity loader. Magic 8BPS still goes to the PSD reader. A .psd suffix without that signature is PSD only after Affinity (and other native) magic is ruled out, so a renamed Affinity preview does not fail as an invalid PSD. --- crates/io/src/lib.rs | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c2c91a10b4..7c67ab4970 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -276,26 +276,35 @@ pub fn import_with_svg_group_depth_and_interrupt( Ok(r) } +fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { + let file = PsdFile::from_bytes(bytes)?; + // Nesting past the document model's cap could never be saved; reject the file with the + // actionable limit. + psd_import::check_group_depth(&file)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(0.05); + let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; + document.name = name.to_string(); + text_import::prepare(&mut document); + Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) +} + fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); } - if is_psd_input(name, bytes) { - let file = PsdFile::from_bytes(bytes)?; - // Nesting past the document model's cap could never be saved; reject the file with the - // actionable limit. - psd_import::check_group_depth(&file)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; - document.name = name.to_string(); - text_import::prepare(&mut document); - return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); + // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus + // preview_never_saves_back_to_its_native_or_renamed_source). + if is_psd(bytes) { + return import_psd(name, bytes, ctl); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); } + if PSD_EXTS.iter().any(|e| has_extension(name, e)) { + return import_psd(name, bytes, ctl); + } if ora::is_ora(bytes) || has_extension(name, "ora") { return ora::import(name, bytes, ctl); } @@ -548,5 +557,7 @@ mod tests { let msg = err.to_string(); assert!(msg.starts_with("PSD:"), "{msg}"); assert!(!msg.contains("unrecognized image format"), "{msg}"); + // A renamed Affinity file keeps the Affinity loader (magic before the .psd suffix). + assert!(!super::is_psd(b"\0\xffKA")); } } From 10220c97fcb0d73c312ccb451746209346bdd774 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 19:14:56 +1100 Subject: [PATCH 0107/1452] Translate New Action, Start Recording, and Stop Recording. The Actions panel menu added those labels; complete-menu locales must cover every tl! literal. --- crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/el.tsv | 3 +++ crates/ui-egui/src/i18n/es.tsv | 3 +++ crates/ui-egui/src/i18n/fr.tsv | 3 +++ crates/ui-egui/src/i18n/hu.tsv | 3 +++ crates/ui-egui/src/i18n/id.tsv | 3 +++ crates/ui-egui/src/i18n/it.tsv | 3 +++ crates/ui-egui/src/i18n/ja.tsv | 3 +++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nl.tsv | 3 +++ crates/ui-egui/src/i18n/pl.tsv | 3 +++ crates/ui-egui/src/i18n/pt-br.tsv | 3 +++ crates/ui-egui/src/i18n/ru.tsv | 3 +++ crates/ui-egui/src/i18n/sr.tsv | 3 +++ crates/ui-egui/src/i18n/tr.tsv | 3 +++ crates/ui-egui/src/i18n/uk.tsv | 3 +++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ 20 files changed, 60 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 01a8b9e4d9..8489dfb4bb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3724,6 +3724,9 @@ navigator Zoom Zvětšení # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… Save Actions… Uložit akce… + New Action… Nová akce… + Start Recording Zahájit nahrávání + Stop Recording Zastavit nahrávání {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Načtena {n} akce ze souboru {file}|Načteny {n} akce ze souboru {file}|Načteno {n} akcí ze souboru {file} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 489647ed21..80f9e07c25 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3114,6 +3114,9 @@ curves Smooth Glätten # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… Save Actions… Aktionen speichern… + New Action… Neue Aktion… + Start Recording Aufnahme starten + Stop Recording Aufnahme stoppen {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} Aktion aus {file} geladen|{n} Aktionen aus {file} geladen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 410da1a188..cd56ccd4e3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3814,6 +3814,9 @@ curves Smooth Εξομάλυνση # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… Save Actions… Αποθήκευση ενεργειών… + New Action… Νέα ενέργεια… + Start Recording Έναρξη εγγραφής + Stop Recording Διακοπή εγγραφής {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Φορτώθηκε {n} ενέργεια από το {file}|Φορτώθηκαν {n} ενέργειες από το {file} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b5db4e9925..c1f7f4f27d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2801,6 +2801,9 @@ curves Smooth Suavizar # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… Save Actions… Guardar acciones… + New Action… Nueva acción… + Start Recording Iniciar grabación + Stop Recording Detener grabación {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Se cargó {n} acción de {file}|Se cargaron {n} acciones de {file} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ab0cca6f40..3b4f08409c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3118,6 +3118,9 @@ curves Smooth Lisser # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… Save Actions… Enregistrer les actions… + New Action… Nouvelle action… + Start Recording Démarrer l'enregistrement + Stop Recording Arrêter l'enregistrement {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} action chargée depuis {file}|{n} actions chargées depuis {file} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9a7283a5a1..58090bd84e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4019,6 +4019,9 @@ cameraRaw Yellow Sárga # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… Save Actions… Műveletek mentése… + New Action… Új művelet… + Start Recording Felvétel indítása + Stop Recording Felvétel leállítása {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} művelet betöltve. Forrás: {file} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6924c45609..6c70dc17d7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3105,6 +3105,9 @@ curves Smooth Haluskan # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… Save Actions… Simpan Tindakan… + New Action… Tindakan Baru… + Start Recording Mulai Rekaman + Stop Recording Hentikan Rekaman {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} tindakan dimuat dari {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d42c550cc..63e78a6b73 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4079,6 +4079,9 @@ navigator Zoom Zoom # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… Save Actions… Salva azioni… + New Action… Nuova azione… + Start Recording Avvia registrazione + Stop Recording Interrompi registrazione {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Caricata {n} azione da {file}|Caricate {n} azioni da {file} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e05f27d718..d3fa9ac3c1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3723,6 +3723,9 @@ curves Smooth スムーズ # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… Save Actions… アクションを保存… + New Action… 新規アクション… + Start Recording 記録を開始 + Stop Recording 記録を停止 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} からアクション {n} 個を読み込みました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7de9f61f10..fae01350cb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3291,6 +3291,9 @@ curves Smooth 매끄럽게 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… Save Actions… 액션 저장… + New Action… 새 액션… + Start Recording 기록 시작 + Stop Recording 기록 중지 {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}에서 액션 {n}개를 불러왔습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4ed7d8a744..cfceb01703 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3876,6 +3876,9 @@ curves Smooth Vloeiend maken # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… Save Actions… Handelingen opslaan… + New Action… Nieuwe handeling… + Start Recording Opname starten + Stop Recording Opname stoppen {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} handeling geladen uit {file}|{n} handelingen geladen uit {file} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a27cb4af8d..a0d97e9694 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3714,6 +3714,9 @@ curves Smooth Wygładź # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… Save Actions… Zapisz operacje… + New Action… Nowa operacja… + Start Recording Rozpocznij nagrywanie + Stop Recording Zatrzymaj nagrywanie {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Wczytano {n} operację z {file}|Wczytano {n} operacje z {file}|Wczytano {n} operacji z {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0a64497704..48c81ef9f0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3906,6 +3906,9 @@ layerLabel No Color Sem cor # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carregar ações… Save Actions… Salvar ações… + New Action… Nova ação… + Start Recording Iniciar gravação + Stop Recording Parar gravação {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} ação carregada de {file}|{n} ações carregadas de {file} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2dbbf4003c..50f9eecb7a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3062,6 +3062,9 @@ curves Smooth Сгладить # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… Save Actions… Сохранить операции… + New Action… Новая операция… + Start Recording Начать запись + Stop Recording Остановить запись {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружены {n} операции из {file}|Загружено {n} операций из {file} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4a5f7459df..da90dbf573 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4180,6 +4180,9 @@ shape Appearance Izgled Copy Texture to Other Tools Kopiraj teksturu u druge alatke Load Actions… Učitaj radnje… Save Actions… Sačuvaj radnje… + New Action… Nova radnja… + Start Recording Započni snimanje + Stop Recording Zaustavi snimanje {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana je {n} radnja iz {file}|Učitane su {n} radnje iz {file}|Učitano je {n} radnji iz {file} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 01c37a18b2..136a5680e7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3830,6 +3830,9 @@ cameraData Lens Lens # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… Save Actions… Eylemleri Kaydet… + New Action… Yeni Eylem… + Start Recording Kaydı Başlat + Stop Recording Kaydı Durdur {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} dosyasından {n} eylem yüklendi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 79ebc8df36..42b0bf25df 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3815,6 +3815,9 @@ curves Smooth Згладити # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… Save Actions… Зберегти операції… + New Action… Нова операція… + Start Recording Почати запис + Stop Recording Зупинити запис {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Завантажено {n} операцію з {file}|Завантажено {n} операції з {file}|Завантажено {n} операцій з {file} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fea5e6b38..98a9d781bb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3599,6 +3599,9 @@ curves Smooth Làm mượt # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Nạp hành động… Save Actions… Lưu hành động… + New Action… Hành động mới… + Start Recording Bắt đầu ghi + Stop Recording Dừng ghi {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Đã nạp {n} hành động từ {file} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 375b61c40b..92f061ef1a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2726,6 +2726,9 @@ curves Smooth 平滑 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… Save Actions… 存储动作… + New Action… 新建动作… + Start Recording 开始录制 + Stop Recording 停止录制 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已从 {file} 载入 {n} 个动作 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index de931038db..f4e605b28b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2711,6 +2711,9 @@ curves Smooth 平滑 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… Save Actions… 儲存動作… + New Action… 新增動作… + Start Recording 開始錄製 + Stop Recording 停止錄製 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已從 {file} 載入 {n} 個動作 From 78d06781a03d0bfd12735c45b80a897f64b05a28 Mon Sep 17 00:00:00 2001 From: Juan Antonio Redondo Date: Wed, 14 Oct 2026 12:12:54 +0200 Subject: [PATCH 0108/1452] perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426) The Blur and Sharpen tools ran each dab's separable Gaussian (local_blur) and the mix back onto the working copy on one thread. A large brush is a lot of work per dab: a 2500 px dab is about 10^9 multiply-adds, so strokes on big images stalled the app for seconds. Both passes of local_blur and the per-dab mix now run their rows in parallel with rayon, as blur.rs already does. Every row does exactly the same arithmetic in the same order, so the output is bit-identical: the layer pixels after the strokes below hash the same as on main. paint.blur, 200-point stroke on a 6000x4000 noise layer, release, 10-core M-series: brush commit (main -> this) live stroke (main -> this) 300 px 323 ms -> 124 ms 497 ms -> 314 ms 1000 px 1.11 s -> 312 ms 1.70 s -> 996 ms 2500 px 2.77 s -> 843 ms 4.26 s -> 2.44 s The live stroke keeps a larger serial share (region copies and mix_back per push); that is left for a follow-up. Refs #5426 Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/retouch.rs | 65 ++++++++++++++++--------------- crates/engine/src/retouch_cmds.rs | 14 +++++-- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index f7c1c18557..e7d33453d4 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -7,6 +7,7 @@ //! "Protect Tones" works on luminance (scaling RGB so hue and saturation are kept, with gamut //! compression instead of per-channel clipping). +use rayon::prelude::*; use serde::{Deserialize, Serialize}; /// Tonal range targeted by Dodge/Burn. @@ -124,56 +125,56 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect(); let ks: f32 = k.iter().sum(); let k: Vec = k.into_iter().map(|v| v / ks).collect(); - // Horizontal pass over the window rows extended vertically by r. + // Horizontal pass over the window rows extended vertically by r. Rows are independent, so + // both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426). let th = oh + 2 * r as usize; let stride = ow * ch; let mut tmp = vec![0.0f32; stride * th]; - let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch]; - for ty in 0..th { - let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; - // Clamp and premultiply each source pixel once, rather than once for every kernel tap. - for (rx, px) in row.chunks_exact_mut(ch).enumerate() { - let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize; - let i = (y * w + x) * ch; - px.copy_from_slice(&data[i..i + ch]); - if let Some(a) = alpha { - let av = px[a]; - for (c, v) in px.iter_mut().enumerate() { - if c != a { - *v *= av; + tmp.par_chunks_mut(stride).enumerate().for_each_init( + || vec![0.0f32; (ow + 2 * r as usize) * ch], + |row, (ty, dst)| { + let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; + // Clamp and premultiply each source pixel once, rather than once for every kernel tap. + for (rx, px) in row.chunks_exact_mut(ch).enumerate() { + let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize; + let i = (y * w + x) * ch; + px.copy_from_slice(&data[i..i + ch]); + if let Some(a) = alpha { + let av = px[a]; + for (c, v) in px.iter_mut().enumerate() { + if c != a { + *v *= av; + } } } } - } - let dst = &mut tmp[ty * stride..(ty + 1) * stride]; - // Contiguous accumulation can be vectorized for any channel layout. Each output still - // sums the same products in the same kernel order as the scalar convolution. - for (i, kv) in k.iter().enumerate() { - for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) { - *d += s * kv; + // Contiguous accumulation can be vectorized for any channel layout. Each output still + // sums the same products in the same kernel order as the scalar convolution. + for (i, kv) in k.iter().enumerate() { + for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) { + *d += s * kv; + } } - } - } + }, + ); let mut out = vec![0.0f32; ow * oh * ch]; - for oy in 0..oh { - let dst = &mut out[oy * stride..(oy + 1) * stride]; + out.par_chunks_mut(stride).enumerate().for_each(|(oy, dst)| { for (i, kv) in k.iter().enumerate() { for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) { *d += s * kv; } } - for ox in 0..ow { - let o = (oy * ow + ox) * ch; - if let Some(a) = alpha { - let av = out[o + a]; - for c in 0..ch { + if let Some(a) = alpha { + for (ox, px) in dst.chunks_exact_mut(ch).enumerate() { + let av = px[a]; + for (c, v) in px.iter_mut().enumerate() { if c != a { - out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; + *v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; } } } } - } + }); out } diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..f41dff7e8c 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -18,6 +18,7 @@ use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use rayon::prelude::*; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -977,19 +978,26 @@ impl DabTool { let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize); let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) }; let rw = r.width() as usize; - for y in r.y0..r.y1 { + // Row-parallel, like the blur itself (#5426). + let (wx0, wy0) = (work.rect.x0, work.rect.y0); + work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| { + let y = wy0 + row as i32; + if y < r.y0 || y >= r.y1 { + return; + } for x in r.x0..r.x1 { let k = per_dab(fp.at(x, y) * strength, sp); if k <= 0.0 { continue; } let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n; - let px = work.px_mut(x, y); + let i = (x - wx0) as usize * n; + let px = &mut line[i..i + n]; for c in 0..n { px[c] += (out[o + c] - px[c]) * k; } } - } + }); }) } Self::Smudge { strength, finger, max_size } => { From 23da649296a0c70ad751010f24f368827599a498 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 0109/1452] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From ed76a4035b0ad1bafca041b1133519362cd02e91 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 0110/1452] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From b32563501ddb557f33bf311cf4a2a34822330cb0 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 0111/1452] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 20735ab52129bc4fe33fadc78d6769442fe13d4f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 14 Oct 2026 17:06:45 +0530 Subject: [PATCH 0112/1452] Value fields: the mouse wheel steps them, as in Photoshop (#5473) A field that has been clicked into takes the wheel wherever the pointer is: field_wheel::begin_frame takes plain and Shift wheel events out of the frame's input before anything is laid out, so the canvas and panels neither scroll nor pan. Alt and Ctrl/Cmd + wheel stay the view's. Over a field that hasn't been clicked, the wheel steps it too (as in Photopea, #4061), but only for a gesture that starts over the field. A panel being scrolled keeps scrolling when a field passes under the pointer. One step a notch, as the Up/Down keys: 1, or 0.01 for small ranges, x10 with Shift. A typed sum is worked out first. Hovering never takes focus, and a non-finite step is dropped. The slider wheel and the fields share widgets::count_notches. Closes #5473 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 257 ++++++++++++++++++++ crates/ui-egui/src/field_wheel_app_tests.rs | 73 ++++++ crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/widgets.rs | 50 +++- docs/ui-parity.md | 4 +- 5 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/field_wheel.rs create mode 100644 crates/ui-egui/src/field_wheel_app_tests.rs diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs new file mode 100644 index 0000000000..d8924b0d8c --- /dev/null +++ b/crates/ui-egui/src/field_wheel.rs @@ -0,0 +1,257 @@ +//! The mouse wheel on value fields (#5473, #4061): one step a notch, as the Up/Down keys (×10 +//! with Shift). +//! +//! - While a field has keyboard focus, the wheel steps it wherever the pointer is. [`begin_frame`] +//! takes the wheel out of the frame's input before anything is laid out, so neither the canvas +//! nor a panel scrolls or zooms with it. ⌥/Alt and ⌘/Ctrl + wheel stay the view's. +//! - Over a field without focus, the wheel steps it too, but only a gesture that starts over it: +//! a panel being scrolled keeps scrolling when a field passes under the pointer. + +use egui::{Context, Event, Id, MouseWheelUnit, Rect, Ui, Vec2}; + +/// Wheel events this close together (seconds) are one gesture. +const GESTURE_GAP: f64 = 0.3; + +/// Who a wheel gesture belongs to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Owner { + /// It started this frame and no field has taken it yet. + New, + Field(Id), + /// It started where no field took it (a panel, the canvas). + Elsewhere, +} + +#[derive(Clone, Copy, Debug)] +struct Gesture { + last: f64, + owner: Owner, +} + +fn gesture_id() -> Id { + Id::new("pc-field-wheel-gesture") +} + +fn focused_id() -> Id { + Id::new("pc-field-wheel-focused") +} + +fn taken_id() -> Id { + Id::new("pc-field-wheel-taken") +} + +/// A wheel event the focused field takes: plain or Shift, not the view's Alt or Ctrl/Cmd zoom. +fn for_field(e: &Event) -> bool { + matches!(e, Event::MouseWheel { modifiers, .. } if !modifiers.alt && !modifiers.ctrl && !modifiers.command && !modifiers.mac_cmd) +} + +/// Before this frame's UI is laid out: hand the wheel to the focused value field, if any, and +/// follow which gesture the wheel is in. +pub fn begin_frame(ctx: &Context) { + ctx.data_mut(|d| d.remove::>(taken_id())); + // (the field's widget id, its slot id): focus is the widget's, a gesture the slot's. + let focused = ctx.data(|d| d.get_temp::<(Id, Id)>(focused_id())).filter(|(id, _)| ctx.memory(|m| m.has_focus(*id))); + let (now, wheel) = ctx.input(|i| (i.time, i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })))); + if let Some((_, field)) = focused { + let taken: Vec = ctx.input_mut(|i| { + let (taken, rest) = std::mem::take(&mut i.events).into_iter().partition(for_field); + i.events = rest; + // egui spreads each notch over a few frames: none of it scrolls anything meanwhile. + if !i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })) { + i.smooth_scroll_delta = Vec2::ZERO; + } + taken + }); + if !taken.is_empty() { + ctx.data_mut(|d| { + d.insert_temp(taken_id(), taken); + d.insert_temp(gesture_id(), Gesture { last: now, owner: Owner::Field(field) }); + }); + } + return; + } + if !wheel { + return; + } + let gesture = ctx.data(|d| d.get_temp::(gesture_id())); + let owner = match gesture { + Some(g) if now - g.last <= GESTURE_GAP => match g.owner { + // No field took it when it started. + Owner::New => Owner::Elsewhere, + owner => owner, + }, + _ => Owner::New, + }; + ctx.data_mut(|d| d.insert_temp(gesture_id(), Gesture { last: now, owner })); +} + +/// Record that value field `id` (whose slot is `field`, as given to [`notches`]) has keyboard +/// focus this frame, so the wheel is its next frame. +pub fn set_focused(ctx: &Context, id: Id, field: Id) { + ctx.data_mut(|d| d.insert_temp(focused_id(), (id, field))); +} + +/// Wheel notches for the value field `field` in `rect` this frame (up positive, Shift ×10): +/// all of the wheel while it is `focused`, else a gesture that started with the pointer over it. +pub fn notches(ui: &Ui, field: Id, focused: bool, rect: Rect) -> f32 { + let events: Vec = if focused { + ui.data_mut(|d| d.remove_temp::>(taken_id())).unwrap_or_default() + } else { + if !ui.is_enabled() || !ui.rect_contains_pointer(rect) { + return 0.0; + } + let Some(mut g) = ui.data(|d| d.get_temp::(gesture_id())) else { return 0.0 }; + let events: Vec = ui.input(|i| i.events.iter().filter(|e| for_field(e)).cloned().collect()); + match g.owner { + Owner::New if !events.is_empty() => { + g.owner = Owner::Field(field); + ui.data_mut(|d| d.insert_temp(gesture_id(), g)); + } + Owner::Field(id) if id == field => {} + _ => return 0.0, + } + // Nothing under the field scrolls with the wheel that steps it, smoothed tail included. + ui.ctx().input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + events + }; + let wheel: Vec<(MouseWheelUnit, Vec2, bool)> = events + .iter() + .filter_map(|e| match e { + Event::MouseWheel { unit, delta, modifiers, .. } if for_field(e) => Some((*unit, *delta, modifiers.shift)), + _ => None, + }) + .collect(); + crate::widgets::count_notches(ui, field.with("wheel-acc"), &wheel, 10.0) +} + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui::{Modifiers, Pos2, vec2}; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + /// The value, the scroll area's offset, and whether the field is enabled. + type State = (f32, f32, bool); + + /// A value field (0–1000) at the top of a scroll area 100 pt tall, above a tall filler. + fn harness(value: f32) -> Harness<'static, State> { + let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state( + |ui, s: &mut State| { + super::begin_frame(ui.ctx()); + let out = egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| { + ui.add_enabled_ui(s.2, |ui| crate::widgets::value_field(ui, &mut s.0, 0.0..=1000.0, "px", 90.0)); + ui.allocate_space(vec2(200.0, 1000.0)); + }); + s.1 = out.state.offset.y; + }, + (value, 0.0, true), + ); + h.run(); + h + } + + fn field(h: &Harness<'static, State>) -> egui::Rect { + h.get_by_role(Role::SpinButton).rect() + } + + /// `lines` wheel lines (up positive) at `at`, then let the gesture end. + fn wheel(h: &mut Harness<'static, State>, at: Pos2, lines: f32, modifiers: Modifiers) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: egui::TouchPhase::Move, modifiers }); + h.run_steps(4); + } + + #[test] + fn the_wheel_over_a_field_steps_it_and_scrolls_nothing() { + let mut h = harness(100.0); + let at = field(&h).center(); + wheel(&mut h, at, 1.0, Modifiers::NONE); + assert_eq!(h.state().0, 101.0); + wheel(&mut h, at, -3.0, Modifiers::NONE); + assert_eq!(h.state().0, 98.0); + wheel(&mut h, at, 1.0, Modifiers::SHIFT); + assert_eq!(h.state().0, 108.0, "Shift: ten a notch"); + assert_eq!(h.state().1, 0.0, "the panel under the field didn't scroll"); + } + + #[test] + fn a_focused_field_takes_the_wheel_wherever_the_pointer_is() { + let mut h = harness(100.0); + h.get_by_role(Role::SpinButton).click(); + h.run(); + let away = field(&h).center() + vec2(0.0, 60.0); + wheel(&mut h, away, -2.0, Modifiers::NONE); + assert_eq!(h.state().0, 98.0); + assert_eq!(h.state().1, 0.0, "the panel under the pointer didn't scroll"); + assert!(h.get_by_role(Role::SpinButton).is_focused(), "the field keeps focus"); + // Alt + wheel stays the view's (zoom), not the field's. + wheel(&mut h, away, 1.0, Modifiers::ALT); + assert_eq!(h.state().0, 98.0); + } + + #[test] + fn a_gesture_that_starts_over_the_panel_keeps_scrolling_it_over_a_field() { + let mut h = harness(100.0); + let below = field(&h).center() + vec2(0.0, 40.0); + h.hover_at(below); + h.run_steps(1); + let down = + || egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }; + h.event(down()); + h.run_steps(1); + // Still the same gesture as the pointer reaches a field. + h.hover_at(field(&h).center()); + h.event(down()); + h.run_steps(1); + assert_eq!(h.state().0, 100.0, "the field passing under the pointer is not stepped"); + assert!(h.state().1 > 0.0, "the panel scrolled"); + } + + #[test] + fn a_disabled_field_ignores_the_wheel() { + let mut h = harness(100.0); + h.state_mut().2 = false; + h.run(); + let at = field(&h).center(); + wheel(&mut h, at, 1.0, Modifiers::NONE); + assert_eq!(h.state().0, 100.0); + } + + #[test] + fn wild_wheel_deltas_leave_a_finite_value_in_range() { + // egui itself asserts on a non-finite wheel event in debug builds: `count_notches` is checked + // with those below. + for lines in [1e30, -1e30, 1e6, -1e6] { + for focus in [false, true] { + let mut h = harness(100.0); + if focus { + h.get_by_role(Role::SpinButton).click(); + h.run(); + } + let at = field(&h).center(); + wheel(&mut h, at, lines, Modifiers::SHIFT); + let v = h.state().0; + assert!(v.is_finite() && (0.0..=1000.0).contains(&v), "{lines} (focus {focus}): {v}"); + } + } + } + + #[test] + fn notches_of_nan_or_infinite_deltas_are_none() { + use egui::MouseWheelUnit::{Line, Point}; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let id = egui::Id::new("acc"); + for d in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for unit in [Line, Point] { + assert_eq!(crate::widgets::count_notches(ui, id, &[(unit, vec2(0.0, d), false)], 10.0), 0.0, "{d} {unit:?}"); + // Nothing poisoned is carried over to the next notch. + assert_eq!(crate::widgets::count_notches(ui, id, &[(Line, vec2(0.0, 1.0), true)], 10.0), 10.0); + } + } + }); + out.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/field_wheel_app_tests.rs b/crates/ui-egui/src/field_wheel_app_tests.rs new file mode 100644 index 0000000000..ba902da508 --- /dev/null +++ b/crates/ui-egui/src/field_wheel_app_tests.rs @@ -0,0 +1,73 @@ +//! #5473 in the app: a focused value field takes the wheel over the canvas, which neither pans +//! nor zooms with it. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.ui.tool = Tool::Brush; + app + }); + h.run_steps(8); + h +} + +/// The value field showing `value`: the options bar's Smoothing is the brush's only one at 10. +fn field<'a>(h: &'a Harness<'_, PhotocraftApp>, value: &str) -> egui_kittest::Node<'a> { + h.query_all_by_role(Role::SpinButton).find(|n| n.value().as_deref() == Some(value)).unwrap() +} + +fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2, lines: f32, modifiers: egui::Modifiers) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: egui::TouchPhase::Move, modifiers }); + h.run_steps(6); +} + +/// (zoom, centre) of the document's view. +fn view(h: &Harness<'_, PhotocraftApp>) -> (f32, [f32; 2]) { + let v = &h.state().ui.views[0]; + (v.zoom, v.center) +} + +#[test] +fn a_focused_field_takes_the_wheel_over_the_canvas() { + let mut h = harness(); + let canvas = egui::pos2(640.0, 500.0); + let before = view(&h); + wheel(&mut h, canvas, -1.0, egui::Modifiers::NONE); + assert_ne!(view(&h), before, "without a focused field the wheel pans the canvas"); + let before = view(&h); + field(&h, "10").click(); + h.run(); + wheel(&mut h, canvas, -2.0, egui::Modifiers::NONE); + assert_eq!(view(&h), before, "the canvas neither pans nor zooms"); + assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("8")), "Smoothing stepped from 10 to 8"); + // ⌥/Alt + wheel is still the view's zoom. + wheel(&mut h, canvas, 1.0, egui::Modifiers::ALT); + assert_ne!(view(&h).0, before.0, "Alt + wheel zooms"); + assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("8"))); +} + +#[test] +fn the_wheel_over_a_field_steps_it_without_a_click() { + let mut h = harness(); + let before = view(&h); + let at = field(&h, "10").rect().center(); + wheel(&mut h, at, 1.0, egui::Modifiers::NONE); + assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("11")), "Smoothing stepped from 10 to 11"); + wheel(&mut h, at, 1.0, egui::Modifiers::SHIFT); + assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("21")), "Shift: ten a notch"); + assert!(!field(&h, "21").is_focused(), "hovering doesn't put the cursor in the field"); + assert_eq!(view(&h), before, "the canvas didn't move"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38118955c3..2f686c57f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,7 @@ pub mod eraser_ui; pub mod export_dialog; pub mod eyedropper_ui; pub mod field_tab; +pub mod field_wheel; pub mod file_dialog; pub mod file_open; pub mod file_ui; @@ -1806,6 +1807,8 @@ impl eframe::App for PhotocraftApp { self.stroke_trace.cpu_prev(frame.info().cpu_usage); // A text field's right-click menu, before any field is drawn (text_field_menu.rs). text_field_menu::show(&ctx); + // The wheel goes to a focused value field before the canvas or a panel sees it (#5473). + field_wheel::begin_frame(&ctx); // Each painting tool keeps its own brush (#218), so switch the active tool's brush in // before anything this frame reads it (the cursor, the options bar, a stroke). paint_mouse::sync_tool_brush(self); @@ -2566,6 +2569,9 @@ mod blend_dropdown_wheel_tests; #[cfg(test)] mod options_bar_mode_wheel_tests; +#[cfg(test)] +mod field_wheel_app_tests; + #[cfg(test)] mod dropdown_press_tests; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..a1cae67ef5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -529,7 +529,20 @@ fn value_field_box( // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; let (lo, hi) = (*range.start(), *range.end()); - let (step, grid) = arrow_step(ui, slot.id, if fine { 0.01 } else { 1.0 }); + // The field's id is only known once it's drawn: last frame's, saved under `slot` below. + let focused = ui.data(|d| d.get_temp::(slot.id)).filter(|id| ui.memory(|m| m.has_focus(*id))); + let unit = if fine { 0.01 } else { 1.0 }; + let (step, grid) = arrow_step(ui, slot.id, unit); + // The wheel steps it as the arrow keys do (#5473), focused or under the pointer. + let notches = crate::field_wheel::notches(ui, slot.id, focused.is_some(), rect); + if notches != 0.0 + && let Some(id) = focused + && ui.data(|d| d.get_temp(id.with("arithmetic"))).unwrap_or(false) + { + // A typed sum works itself out first, as before an arrow step. + ui.memory_mut(|m| m.surrender_focus(id)); + } + let step = step + notches * unit; // new_child (not scope_builder): a scope would move the parent cursor back to the child rect. let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field)); let mut resp = { @@ -585,12 +598,22 @@ fn value_field_box( // Round to 4 decimal places, so repeated 0.1 steps don't leave float errors. let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. - *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - ui.memory_mut(|m| m.request_focus(resp.id)); - // The text stays selected, as in Photoshop: typing after a step replaces the value. - crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); + let v = if lo <= hi { v.clamp(lo, hi) } else { v }; + // A wild wheel delta must not leave infinity in the field. + if v.is_finite() { + *value = v; + } + // A wheel step over a field without focus leaves the focus where it is. + if focused.is_some() { + ui.memory_mut(|m| m.request_focus(resp.id)); + // The text stays selected, as in Photoshop: typing after a step replaces the value. + crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); + } resp.mark_changed(); } + if ui.memory(|m| m.has_focus(resp.id)) { + crate::field_wheel::set_focused(ui.ctx(), resp.id, slot.id); + } (resp, rect) } @@ -860,11 +883,6 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch if !hovered { return 0.0; } - let acc_id = id.with("wheel-acc"); - let mut acc: f32 = ui.data(|d| d.get_temp(acc_id)).unwrap_or(0.0); - let per_line = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let per_line = if per_line.is_finite() && per_line > 0.0 { per_line } else { 40.0 }; - let mut notches = 0.0; let wheel: Vec<(egui::MouseWheelUnit, Vec2, bool)> = ui.input(|i| { i.events .iter() @@ -874,7 +892,17 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch }) .collect() }); - for (unit, delta, shift) in wheel { + count_notches(ui, id.with("wheel-acc"), &wheel, shift_notches) +} + +/// Whole notches in `wheel` (unit, delta, Shift held), a notch with Shift counting +/// `shift_notches`; fractions carry over in egui memory under `acc_id`. +pub(crate) fn count_notches(ui: &Ui, acc_id: egui::Id, wheel: &[(egui::MouseWheelUnit, Vec2, bool)], shift_notches: f32) -> f32 { + let mut acc: f32 = ui.data(|d| d.get_temp(acc_id)).unwrap_or(0.0); + let per_line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let per_line = if per_line.is_finite() && per_line > 0.0 { per_line } else { 40.0 }; + let mut notches = 0.0; + for &(unit, delta, shift) in wheel { // Some systems turn Shift+wheel into a horizontal scroll. let d = if delta.y != 0.0 { delta.y } else { delta.x }; acc += match unit { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5abc8727a4..ae74ffcb84 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-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the wheel steps value fields, #5473; 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) · **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, @@ -156,6 +156,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Guides stored as f64 | missing | `Vec` (UI-217-20) | | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | +| Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately | ## Navigation @@ -217,6 +218,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | From 5c9d2ce3ac2b5d4d3cd75e4c50b538bd66f721e8 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:12 +0800 Subject: [PATCH 0113/1452] Apply canvas display profiles to Free Transform previews --- crates/ui-egui/src/transform_fx.rs | 15 +- crates/ui-egui/src/transform_fx_tests.rs | 27 +++ crates/ui-egui/src/transform_tex.rs | 114 ++++++++++- crates/ui-egui/src/transform_tool.rs | 230 ++++++++++++++++++++++- 4 files changed, 371 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/transform_fx.rs b/crates/ui-egui/src/transform_fx.rs index 3b56225efa..db38827da9 100644 --- a/crates/ui-egui/src/transform_fx.rs +++ b/crates/ui-egui/src/transform_fx.rs @@ -348,6 +348,7 @@ impl Frame<'_> { #[derive(Clone, PartialEq)] struct Key { revision: u64, + display_key: u64, quad: [u64; 8], plan: Plan, level: u32, @@ -389,12 +390,18 @@ impl StyledPreview { /// The composite for `frame` and the document rectangle it covers (x0, y0, x1, y1), /// compositing it again if anything it depends on changed. `None` when there is nothing to /// draw this way: the caller falls back to the plain texels. - pub fn texture(&self, ctx: &egui::Context, frame: &Frame) -> Option<(TextureId, [f32; 4])> { + pub fn texture( + &self, + ctx: &egui::Context, + frame: &Frame, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + ) -> Option<(TextureId, [f32; 4])> { let (plan, work, level) = frame.plan()?; let crisp = plan.scale >= 1.0 && frame.zoom >= CRISP_ZOOM; let q = frame.quad.map(|q| q.map(f64::to_bits)); let key = Key { revision: frame.revision, + display_key: display.map_or(0, |d| d.key), quad: [q[0][0], q[0][1], q[1][0], q[1][1], q[2][0], q[2][1], q[3][0], q[3][1]], plan, level, @@ -407,7 +414,11 @@ impl StyledPreview { } let out = frame.compose(&plan, &work, level)?; let options = if crisp { TextureOptions::NEAREST } else { TextureOptions::LINEAR }; - let tex = ctx.load_texture(self.name.clone(), image(&out), options); + let pixels = match display { + Some(d) if !d.is_identity() => crate::canvas::display_image(Some(d), &out), + _ => image(&out), + }; + let tex = ctx.load_texture(self.name.clone(), pixels, options); let (id, rect) = (tex.id(), plan.doc_rect()); state.shown = Some(Shown { key, tex, rect }); state.builds += 1; diff --git a/crates/ui-egui/src/transform_fx_tests.rs b/crates/ui-egui/src/transform_fx_tests.rs index 0eb860dadd..23e9d471a6 100644 --- a/crates/ui-egui/src/transform_fx_tests.rs +++ b/crates/ui-egui/src/transform_fx_tests.rs @@ -27,6 +27,33 @@ fn begin(app: &mut PhotocraftApp) { crate::transform_tool::begin(app, &egui::Context::default()).unwrap(); } +#[test] +fn styled_texture_tracks_monitor_changes_without_recoloring_effect_inputs() { + let mut app = app("rgb", 16); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let raw = composite(&app, 1.0).unwrap().0; + let mut previous = None; + for monitor in ["srgb", "display-p3", "srgb"] { + app.session.color.settings.monitor_profile = monitor.into(); + let display = app.session.color.canvas_display(&app.session.active().unwrap().doc).unwrap(); + let expected = crate::canvas::display_image(Some(&display), &raw); + let (styled, frame) = crate::transform_tool::styled_frame(&app, 1.0, false).unwrap(); + let (id, _) = styled.texture(&ctx, &frame, Some(&display)).unwrap(); + assert_ne!(Some(id), previous, "monitor change invalidates the texture"); + let builds = styled.builds(); + assert_eq!(styled.texture(&ctx, &frame, Some(&display)).unwrap().0, id); + assert_eq!(styled.builds(), builds, "unchanged display reuses the composite"); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(shown) = &delta.set.get(&id).unwrap().last().unwrap().image; + assert_eq!(shown.pixels, expected.pixels, "monitor={monitor}"); + delta.clear(); + assert_eq!(composite(&app, 1.0).unwrap().0.px, raw.px, "effects still use document colors"); + previous = Some(id); + } +} + /// What the box draws for the styled layer at `zoom`: `None` when it draws the bare pixels. fn composite(app: &PhotocraftApp, zoom: f32) -> Option<(Buffer, Plan)> { let (_, frame) = crate::transform_tool::styled_frame(app, zoom, false)?; diff --git a/crates/ui-egui/src/transform_tex.rs b/crates/ui-egui/src/transform_tex.rs index bd84704542..ae67b2e2c5 100644 --- a/crates/ui-egui/src/transform_tex.rs +++ b/crates/ui-egui/src/transform_tex.rs @@ -16,10 +16,12 @@ use std::sync::{Arc, Mutex, PoisonError}; use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions}; +use photocraft_engine::display_color::CanvasDisplay; /// One uploaded texture: its mip level, filter and the uv extent covering the source. struct Level { level: u32, + display_key: u64, nearest: bool, tex: TextureHandle, uv: [f32; 2], @@ -43,7 +45,12 @@ impl PreviewTextures { let image = Arc::new(image); let tex = ctx.load_texture(format!("{name}-0-linear"), Arc::clone(&image), TextureOptions::LINEAR); let base_uv = [base_uv[0].clamp(0.0, 1.0), base_uv[1].clamp(0.0, 1.0)]; - Self { name, images: Mutex::new(vec![image]), base_uv, uploaded: Mutex::new(vec![Level { level: 0, nearest: false, tex, uv: base_uv }]) } + Self { + name, + images: Mutex::new(vec![image]), + base_uv, + uploaded: Mutex::new(vec![Level { level: 0, display_key: 0, nearest: false, tex, uv: base_uv }]), + } } /// Size of the full-resolution level in texels. @@ -72,20 +79,38 @@ impl PreviewTextures { } /// Texture and uv extent to draw with (uploading a level / filter on first use). + #[cfg(test)] pub fn pick(&self, ctx: &egui::Context, zoom: f32, scale: f32, nearest_interpolation: bool) -> (TextureId, [f32; 2]) { + self.pick_display(ctx, zoom, scale, nearest_interpolation, None) + } + + /// Convert only the displayed upload: effects still read the raw mip levels. + pub fn pick_display( + &self, + ctx: &egui::Context, + zoom: f32, + scale: f32, + nearest_interpolation: bool, + display: Option<&CanvasDisplay>, + ) -> (TextureId, [f32; 2]) { let (level, nearest) = Self::choose(zoom, scale, nearest_interpolation); + let display = display.filter(|d| !d.is_identity()); + let display_key = display.map_or(0, |d| d.key); let mut up = self.uploaded.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest) { + if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest && l.display_key == display_key) { return (l.tex.id(), l.uv); } let Some((img, uv)) = self.level_image(level) else { // Can't build it (shouldn't happen): fall back to the first upload. return up.first().map_or((TextureId::default(), [1.0, 1.0]), |l| (l.tex.id(), l.uv)); }; + let img = display.map_or_else(|| Arc::clone(&img), |d| Arc::new(display_image(&img, d))); let opts = if nearest { TextureOptions::NEAREST } else { TextureOptions::LINEAR }; let tex = ctx.load_texture(format!("{}-{level}-{}", self.name, if nearest { "nearest" } else { "linear" }), img, opts); let id = tex.id(); - up.push(Level { level, nearest, tex, uv }); + // Profile changes must not accumulate another full set of GPU textures. + up.retain(|l| l.display_key == display_key); + up.push(Level { level, display_key, nearest, tex, uv }); (id, uv) } @@ -110,6 +135,28 @@ impl PreviewTextures { } } +/// Premultiplied preview texels -> straight document values -> monitor values. +/// Bound the temporary float buffer independently of the texture size. +fn display_image(image: &ColorImage, display: &CanvasDisplay) -> ColorImage { + let mut out = image.clone(); + let convert = |pixels: &mut [Color32]| { + let px = pixels.iter().map(|p| p.to_srgba_unmultiplied().map(|c| f32::from(c) / 255.0)).collect(); + let buffer = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(0, 0, pixels.len() as i32, 1), px }; + let converted = display.to_rgba8(&buffer); + for (p, rgba) in pixels.iter_mut().zip(converted.pixels.as_chunks::<4>().0) { + *p = Color32::from_rgba_unmultiplied(rgba[0], rgba[1], rgba[2], rgba[3]); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.pixels.par_chunks_mut(4096).for_each(convert); + } + #[cfg(target_arch = "wasm32")] + out.pixels.chunks_mut(4096).for_each(convert); + out +} + /// 2×2 box filter of a premultiplied image (odd edges repeat the last row/column). fn halve(img: &ColorImage) -> ColorImage { let [w, h] = img.size; @@ -198,6 +245,67 @@ pub fn read_surface(surf: &photocraft_raster::Surface, mask: Option<&PreviewMask #[cfg(test)] mod tests { + #[test] + #[ignore = "reports CPU preparation cost; run explicitly for profile-preview changes"] + fn transform_profile_upload_cost() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", serde_json::json!({"width": 6000, "height": 4000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + for monitor in ["raw", "srgb", "display-p3"] { + session.color.settings.monitor_profile = if monitor == "raw" { "srgb" } else { monitor }.into(); + let display = session.color.canvas_display(&doc).unwrap(); + let ctx = egui::Context::default(); + let image = ColorImage::new([6000, 4000], vec![Color32::from_rgb(140, 89, 51); 6000 * 4000]); + let display = (monitor != "raw").then_some(display.as_ref()); + let started = std::time::Instant::now(); + let textures = PreviewTextures::new(&ctx, "profile-cost".into(), image, [1.0, 1.0]); + let texture = textures.pick_display(&ctx, 1.0, 1.0, false, display).0; + let prepare = started.elapsed(); + ctx.tex_manager().write().take_delta().clear(); + let started = std::time::Instant::now(); + for _ in 0..1000 { + assert_eq!(std::hint::black_box(textures.pick_display(&ctx, 1.0, 1.0, false, display)).0, texture); + } + let cached = started.elapsed(); + let mut delta = ctx.tex_manager().write().take_delta(); + assert!(delta.set.is_empty(), "cached dragging must not upload or reconvert pixels"); + delta.clear(); + eprintln!( + "profile={monitor}, pixels=24000000, first_prepare_ms={:.3}, cached_1000_us={:.3}; CPU preparation only, not GPU completion", + prepare.as_secs_f64() * 1000.0, + cached.as_secs_f64() * 1_000_000.0 + ); + } + } + + #[test] + fn display_upload_changes_with_monitor_but_keeps_raw_alpha_and_mips() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", serde_json::json!({"width": 2, "height": 1})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + session.color.settings.monitor_profile = "srgb".into(); + let srgb = session.color.canvas_display(&doc).unwrap(); + session.color.settings.monitor_profile = "display-p3".into(); + let p3 = session.color.canvas_display(&doc).unwrap(); + assert!(srgb.is_identity()); + assert!(!p3.is_identity()); + let raw = ColorImage::new([2, 1], vec![Color32::from_rgba_unmultiplied(140, 89, 51, 128), Color32::TRANSPARENT]); + let converted = display_image(&raw, &p3); + assert_eq!(converted.pixels[0].a(), 128); + assert_eq!(converted.pixels[1], Color32::TRANSPARENT); + assert_ne!(converted.pixels[0], raw.pixels[0]); + let ctx = egui::Context::default(); + let textures = PreviewTextures::new(&ctx, "profile-test".into(), raw.clone(), [1.0, 1.0]); + let first = textures.pick_display(&ctx, 1.0, 1.0, false, Some(&srgb)).0; + let second = textures.pick_display(&ctx, 1.0, 1.0, false, Some(&p3)).0; + assert_ne!(first, second); + assert_eq!(textures.pick_display(&ctx, 1.0, 1.0, false, Some(&p3)).0, second); + assert_eq!(textures.level_image(0).unwrap().0.pixels, raw.pixels); + assert_eq!(textures.level_image(1).unwrap().0.pixels, halve(&raw).pixels); + assert_eq!(textures.uploaded.lock().unwrap().len(), 1, "old profile uploads are released"); + ctx.tex_manager().write().take_delta().clear(); + } + use super::*; #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 81fc0fadcf..a8167adb03 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -867,9 +867,9 @@ impl Above { } /// Draw the composite over the moving pixels. - fn draw(&self, painter: &egui::Painter, xf: &ViewXform) { + fn draw(&self, painter: &egui::Painter, xf: &ViewXform, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { let Some((texture, r)) = &self.shown else { return }; - let (tex, uv) = texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false); + let (tex, uv) = texture.pick_display(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false, display); let mut mesh = egui::Mesh::with_texture(tex); let corners = [(r.x0, r.y0, 0.0, 0.0), (r.x1, r.y0, uv[0], 0.0), (r.x1, r.y1, uv[0], uv[1]), (r.x0, r.y1, 0.0, uv[1])]; for (x, y, u, v) in corners { @@ -2048,6 +2048,10 @@ fn front_vertices(h: &Homography, n: usize) -> Vec { /// Warped preview of the moving pixels plus the box, handles and reference point. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; + let output = crate::monitor_status::view_display(app, painter.ctx()); + let (display, _) = crate::canvas::canvas_display(app, &pv.doc, output); + // Selection tint and lone mask/channel values are UI diagnostics, not document colors. + let display = display.as_deref().filter(|_| !t.selection && t.target.is_none() && t.path.is_none()); let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); if let Some(w) = &t.warp { let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); @@ -2057,11 +2061,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let tool = pv.split_tool.or_else(|| (alt || pv.split_quick).then(|| quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); tool.map(|tool| (tool, p)) }); - draw_warp(painter, xf, t, w, pv, guide); + draw_warp(painter, xf, t, w, pv, guide, display); return; } let ctx = painter.ctx(); - let styled = styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame)); + let styled = + styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame, display)); if let Some((tex, r)) = styled { let mut mesh = egui::Mesh::with_texture(tex); for (x, y, u, v) in [(r[0], r[1], 0.0, 0.0), (r[2], r[1], 1.0, 0.0), (r[2], r[3], 1.0, 1.0), (r[0], r[3], 0.0, 1.0)] { @@ -2076,8 +2081,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let zoom = xf.zoom * painter.ctx().pixels_per_point(); // Content-Aware Scale shows its latest live result once there is one. let (tex, uv) = match pv.content_aware.as_ref().and_then(|l| l.shown(t)) { - Some((texture, texel)) => texture.pick(painter.ctx(), zoom, texel, false), - None => pv.texture.pick(painter.ctx(), zoom, quad_scale(t), t.interpolation == "nearest"), + Some((texture, texel)) => texture.pick_display(painter.ctx(), zoom, texel, false, display), + None => pv.texture.pick_display(painter.ctx(), zoom, quad_scale(t), t.interpolation == "nearest", display), }; let mut mesh = egui::Mesh::with_texture(tex); let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8); @@ -2103,7 +2108,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); } if let Some(a) = &pv.above { - a.draw(painter, xf); + a.draw(painter, xf, display); } if pv.committing.is_some() { return; @@ -2335,12 +2340,20 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; } } -fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { +fn draw_warp( + painter: &egui::Painter, + xf: &ViewXform, + t: &TransformSession, + w: &Warp, + pv: &TransformPreview, + guide: Option<(SplitTool, [f64; 2])>, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, +) { let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let r = t.rect; let m = w.to_mesh(1, 1); let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64); - let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest"); + let (tex, uv) = pv.texture.pick_display(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest", display); let mut mesh = egui::Mesh::with_texture(tex); let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8); for v in &vs { @@ -2354,7 +2367,7 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: & mesh.indices = crate::warp_preview::triangle_order(&pos, &rest, us.len()); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); if let Some(a) = &pv.above { - a.draw(painter, xf); + a.draw(painter, xf, display); } let accent = crate::theme::Tokens::get(painter.ctx()).accent; // Transform Path › Warp: the path drawn through the warp. @@ -4741,6 +4754,59 @@ mod tests { img.pixels[y * img.size[0] + x].a() } + #[test] + fn transform_preview_preserves_displayed_profile_colors() { + let bounds = photocraft_geom::Rect::new(0, 0, 8, 8); + for (wide_gamut, monitor, warp) in [(false, "srgb", false), (true, "srgb", false), (false, "display-p3", false), (true, "srgb", true)] { + let mut app = app_with_square(8, bounds); + app.session + .edit("original color sample", |doc, active| { + if wide_gamut { + doc.icc_profile = Some(photocraft_cms::Builtin::AdobeRgbCompat.profile().to_bytes()); + } + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.55, 0.35, 0.2, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = monitor.into(); + let st = app.session.active().unwrap(); + let display = app.session.color.canvas_display(&st.doc).unwrap(); + assert_eq!(!display.is_identity(), wide_gamut || monitor != "srgb"); + let composite = photocraft_compose::render(&st.doc, bounds); + let expected = crate::canvas::display_image(Some(&display), &composite); + let ctx = gpu_ctx(); + if warp { + begin_warp(&mut app, &ctx).unwrap(); + } else { + begin(&mut app, &ctx).unwrap(); + } + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| { + draw_overlay(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let texture = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id != egui::TextureId::default() => Some(mesh.texture_id), + _ => None, + }) + .expect("transform draws a textured mesh"); + let upload = out.textures_delta.set.get(&texture).and_then(|updates| updates.last()).expect("drawn texture was uploaded"); + let egui::ImageData::Color(image) = &upload.image; + let shown = image.pixels[0]; + out.textures_delta.clear(); + assert_eq!(shown, expected.pixels[0], "transform changed displayed color; wide_gamut={wide_gamut}, monitor={monitor}, warp={warp}"); + } + } + #[test] fn preview_applies_the_linked_mask() { for linked in [true, false] { @@ -4768,6 +4834,150 @@ mod tests { assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed"); } + #[test] + fn transform_selection_tint_is_not_document_color() { + let mut app = app_with_square(8, photocraft_geom::Rect::new(0, 0, 8, 8)); + app.session.color.settings.monitor_profile = "display-p3".into(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let ctx = gpu_ctx(); + begin_selection(&mut app, &ctx).unwrap(); + let raw = app.transform_preview.as_ref().unwrap().texture.level_image(0).unwrap().0; + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_overlay(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let id = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.texture_id), + _ => None, + }) + .unwrap(); + let egui::ImageData::Color(shown) = &out.textures_delta.set.get(&id).unwrap().last().unwrap().image; + assert_eq!(shown.pixels, raw.pixels); + out.textures_delta.clear(); + } + + #[test] + fn transform_covering_layer_uses_the_same_monitor_profile() { + let bounds = photocraft_geom::Rect::new(0, 0, 8, 8); + let mut app = app_with_square(8, bounds); + let lower = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let upper = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("original color samples", |doc, _| { + doc.layer_mut(lower).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.55, 0.35, 0.2, 1.0]); + doc.layer_mut(upper).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.2, 0.5, 0.7, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = "display-p3".into(); + let doc = app.session.active().unwrap().doc.clone(); + let display = app.session.color.canvas_display(&doc).unwrap(); + let expected = [lower, upper].map(|id| { + let mut solo = (*doc).clone(); + for layer in &mut solo.layers { + layer.visible = layer.id == id; + } + crate::canvas::display_image(Some(&display), &photocraft_compose::render(&solo, bounds)).pixels[0] + }); + app.session.execute("layer.select", json!({"layer": lower.0})).unwrap(); + let ctx = gpu_ctx(); + begin(&mut app, &ctx).unwrap(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_overlay(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let shown: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => { + let egui::ImageData::Color(image) = &out.textures_delta.set.get(&m.texture_id).unwrap().last().unwrap().image; + Some(image.pixels[0]) + } + _ => None, + }) + .collect(); + out.textures_delta.clear(); + assert_eq!(shown, expected, "moving pixels and covering pixels use the same display conversion"); + } + + #[test] + #[ignore = "requires a GPU adapter; renders profile-managed transform evidence"] + fn transform_profile_visual_evidence() { + let _guard = photocraft_testkit::gpu_lock(); + let destination = std::env::var("PHOTOCRAFT_TRANSFORM_EVIDENCE").unwrap_or_else(|_| "transform-profile-evidence".into()); + std::fs::create_dir_all(&destination).unwrap(); + for monitor in ["srgb", "display-p3"] { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.set_wgpu(cc.wgpu_render_state.as_ref().unwrap().clone()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "transparent"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("original color swatches", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for (x, color) in [(60, [0.55, 0.35, 0.2, 1.0]), (160, [0.2, 0.6, 0.35, 1.0]), (260, [0.3, 0.4, 0.7, 1.0])] { + surface.fill_rect(photocraft_geom::Rect::new(x, 60, x + 80, 220), &color); + } + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = monitor.into(); + app.ui.extras.rulers = false; + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].center = [200.0, 150.0]; + app.ui.views[0].fit_pending = false; + app + }); + h.run_steps(8); + let app = h.state(); + let view = &app.ui.views[0]; + let xf = ViewXform { rect: app.last_canvas_rect, zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 }; + let points = [100.0, 200.0, 300.0].map(|x| xf.to_screen(x, 100.0) * h.ctx.pixels_per_point()); + let before = h.render().unwrap(); + before.save(std::path::Path::new(&destination).join(format!("{monitor}-before.png"))).unwrap(); + let ctx = h.ctx.clone(); + begin(h.state_mut(), &ctx).unwrap(); + h.run_steps(8); + assert!(h.state().ui.transform.is_some()); + let during = h.render().unwrap(); + during.save(std::path::Path::new(&destination).join(format!("{monitor}-during.png"))).unwrap(); + commit(h.state_mut()); + h.run_steps(8); + assert!(h.state().ui.transform.is_none()); + let after = h.render().unwrap(); + after.save(std::path::Path::new(&destination).join(format!("{monitor}-after.png"))).unwrap(); + for point in points { + let (x, y) = (point.x as u32, point.y as u32); + let expected = before.get_pixel(x, y).0; + assert_ne!(expected[0], expected[1], "sample must hit a color swatch, not the checkerboard"); + for actual in [during.get_pixel(x, y).0, after.get_pixel(x, y).0] { + assert!(actual.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 2), "{monitor}: before={expected:?}, shown={actual:?}"); + } + } + } + } + #[test] fn preview_feathers_the_linked_mask() { // A feathered pixel mask without a vector mask: the compositor softens the edge at x 16, From 32d965cf503f8093df6e92c6dd3c1fd099bd9936 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 15:29:17 +0200 Subject: [PATCH 0114/1452] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 56a16654bd..d30519331a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,13 +8,13 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | -| [Tools](#tools) | 29 | 13 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | -| [Type](#type) | 4 | 3 | 2 | | +| [Tools](#tools) | 30 | 12 | 1 | | +| [Workspace and UI](#workspace-and-ui) | 13 | 7 | 3 | settings that do nothing: 26 of 157 (target 0) | +| [Type](#type) | 5 | 3 | 1 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | -| [Open bugs](#open-bugs) | 29 | 2 | 1 | | +| [Open bugs](#open-bugs) | 30 | 2 | 0 | | ## Performance @@ -157,7 +157,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | -| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | +| TOOL-214-13 | Mask Properties: Density and Feather controls | done | [#214](https://github.com/storytold/photocraft/issues/214) | Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs; mask_props_ui_tests.rs pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, mask_slider_drags_preview_live_and_undo_as_separate_gestures, vector_mask_properties_edit_density_and_feather) | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | @@ -199,7 +199,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | | UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | -| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | +| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs; input_tests.rs alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked | | UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | @@ -216,7 +216,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | -| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 | +| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs warp_defaults; warp_text_dialog_tests.rs warp_text_opens_with_the_type_layers_current_warp, warp_text_opens_at_none_for_a_layer_without_warp, remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (warp_text_dialog_previews_the_warp_without_editing_the_document, cancelling_warp_text_leaves_the_document_and_history_untouched, confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | | TYPE-218-9 | Distort/Perspective/Warp on live type as in Photoshop (was: offer 'Rasterize and continue?'; Photoshop doesn't prompt) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701) | @@ -298,9 +298,9 @@ Never-crash attribute on 28 of 28 crate roots. | BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) | | BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) | | BUG-207-4 | edit.transform transforms a targeted alpha channel | done | [#207](https://github.com/storytold/photocraft/issues/207) | edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel | -| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) | +| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | done | [#207](https://github.com/storytold/photocraft/issues/207) | Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask, only a targeted alpha channel, and to follow the Channels panel target (engine retouch_cmds/tests.rs retouch_tools_paint_only_the_targeted_mask, retouch_tools_paint_only_the_targeted_alpha_channel, retouch_tools_follow_the_channels_panel_target, retouch_tools_paint_the_quick_mask, retouch_targets_fail_gracefully). Sample All Layers changes the result over two layers (sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, sample_all_layers_smudge_drags_colour_from_below, sample_all_layers_spot_healing_heals_onto_an_empty_layer, clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs sample_all_layers_reaches_the_command); no smudge fringe (smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: tests.rs painting_can_target_the_layer_mask, channel_cmds/tests.rs painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel | | BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners applies Shift via Drag::marquee_mods; marquee_tests.rs::shift_during_the_drag_draws_a_square_and_the_readout_follows covers the drag | | BUG-208-2 | Alt while dragging a marquee draws from the centre | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners centres Alt drags; marquee_tests.rs::alt_draws_from_the_centre_and_shift_alt_a_centred_circle verifies rectangles and ellipses | | BUG-208-3 | W x H px readout beside the cursor while dragging a marquee | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_readout draws the readout; marquee_tests.rs asserts dimensions while dragging and its removal after release | -| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195) | +| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (marquee_tests.rs modifiers_held_before_the_drag_pick_the_mode_not_the_shape). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift/Alt at the first click keep the selection and combine (polygon_lasso_tests.rs modifiers_at_the_first_click_keep_and_combine_the_selection). Missing: a test that Shift+Alt held before a marquee drag intersects | | BUG-208-5 | Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size | done | [#208](https://github.com/storytold/photocraft/issues/208) | crates/ui-egui/src/marquee_tests.rs covers Shift, Alt, selection bounds, W/H readout and drag completion | From 5f661d3ee35803544dafc0819e0ff5df8d825887 Mon Sep 17 00:00:00 2001 From: Florian Meyer <33363574+alloutflo@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:39:02 +0200 Subject: [PATCH 0115/1452] fix(ui): keep type panels editable without a text layer Persist Character and Paragraph formatting as Type tool defaults and apply it through type.create. Preserve selected-text scope, foreground color, undo, reset, and nonblocking font loading. Fixes #5556 Co-Authored-By: Codex --- crates/ui-egui/src/props_layout_tests.rs | 12 +- crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/tool_reset.rs | 4 +- crates/ui-egui/src/type_tool.rs | 168 +++++++++++++--- crates/ui-egui/src/type_tool_tests.rs | 233 +++++++++++++++++++++++ docs/gaps.md | 6 +- docs/ui-parity.md | 4 +- 7 files changed, 404 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 74fd29121a..62e9423f44 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -231,10 +231,16 @@ fn character_and_paragraph_dock_panels_share_the_headers_without_type_options() let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, [want], "tab {tab}"); } - // No type layer: the empty-state message, no sections. + // No type layer: the same dedicated sections edit Type tool defaults (#5556). select(&mut h, px); - assert!(headers(&h).is_empty()); - assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some()); + for (tab, want) in [(0, "Character"), (1, "Paragraph")] { + set(&mut h, json!({"dockTabs": {"character": tab}})); + let hs = headers(&h); + assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}"); + let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, [want], "default tab {tab}"); + } + assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_none()); } /// #2085: like Photoshop, Opacity and Fill stack in one right-aligned column: both fields end at diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 42997fe62c..13b260b5a9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -535,6 +535,11 @@ pub struct ToolOptions { pub type_size: f32, pub type_aa: String, pub type_align: String, + /// Character/Paragraph panel defaults beyond the options bar, in `type.setStyle`'s + /// parameter format. Kept as overrides so saved engine defaults still supply unset fields. + /// OpenType features and variable-font axes are merged per tag/axis. + #[serde(default)] + pub type_style_defaults: serde_json::Map, /// Clone Stamp / Healing Brush. pub clone_aligned: bool, /// Pattern Stamp: lock the tile origin in document space across strokes. @@ -772,6 +777,7 @@ impl Default for ToolOptions { type_size: 48.0, type_aa: "sharp".into(), type_align: "left".into(), + type_style_defaults: serde_json::Map::new(), clone_aligned: true, pattern_stamp_aligned: true, pattern_stamp_impressionist: false, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 8de3fe4ec7..049a4ad5d7 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -149,7 +149,9 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), - Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => { + reset!(type_font, type_style, type_size, type_aa, type_align, type_style_defaults) + } Tool::Zoom => reset!(zoom_scrubby), Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::Healing => reset!(clone_aligned, clone_sample), diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..27a6d495e1 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -304,15 +304,9 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { // Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). The Type // Mask tools start empty, as Photoshop's do. let text = if !mask && app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; - let mut p = json!({ - "text": text, - "orientation": if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" }, - "font": o.type_font, - "fontStyle": o.type_style, - "size": o.type_size, - "align": o.type_align, - "color": hex(app.session.tools.foreground), - }); + let mut p = default_style_params(app); + p["text"] = json!(text); + p["orientation"] = json!(if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" }); if w >= min && h >= min { p["box"] = json!([start[0].min(end[0]).round(), start[1].min(end[1]).round(), w.round(), h.round()]); } else { @@ -1637,9 +1631,133 @@ fn drag_key(ctx: &egui::Context) -> Option { Some(format!("type-drag-{}-{}", id.value(), t.to_bits())) } -/// Apply only the supplied properties to the text-edit scope or selected type layers. +/// The styles a new Type-tool layer will receive. Use the same command parameters for the +/// panels and creation so defaults also survive action recording/replay. The older options-bar +/// fields remain authoritative, and text colour continues to follow the foreground colour. +fn default_style_params(app: &PhotocraftApp) -> serde_json::Value { + let o = &app.ui.tool_options; + let mut p = json!({}); + // Settings are editable JSON: only recognized formatting keys may reach type.create. + // In particular a stray `text`, `box`, or `coalesce` key must not become a create parameter. + let (mut c, mut para) = (photocraft_doc::text::CharStyle::default(), photocraft_doc::text::ParagraphStyle::default()); + for (key, value) in &o.type_style_defaults { + // These belong to the existing options-bar fields. Apart from being redundant, + // parsing a saved font key here would wait for the background font scan. + if matches!(key.as_str(), "font" | "family" | "fontStyle" | "weight" | "italic" | "postscriptName") { + continue; + } + let one = json!({ (key): value }); + if !finite_style_value(value) + || photocraft_engine::type_cmds::check_size_tracking(&one).is_err() + || photocraft_engine::type_cmds::check_kerning(&one).is_err() + { + continue; + } + if photocraft_engine::type_cmds::apply_char_props(&mut c, &one) | photocraft_engine::type_cmds::apply_para_props(&mut para, &one) { + p[key] = value.clone(); + } + } + p["font"] = json!(o.type_font); + p["fontStyle"] = json!(o.type_style); + p["size"] = json!(o.type_size); + p["align"] = json!(o.type_align); + p["color"] = json!(hex(app.session.tools.foreground)); + p +} + +/// Style numbers become f32 in the engine. JSON itself permits finite f64 values too large +/// for that conversion; a hand-edited setting must not inject infinity into a new layer. +/// Style collections (features, axes, colour) contain only scalar values, so no recursion. +fn finite_style_value(value: &serde_json::Value) -> bool { + let scalar = |v: &serde_json::Value| match v { + serde_json::Value::Number(n) => n.as_f64().is_some_and(|n| (n as f32).is_finite()), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => false, + _ => true, + }; + match value { + serde_json::Value::Array(v) => v.iter().all(scalar), + serde_json::Value::Object(v) => v.values().all(scalar), + v => scalar(v), + } +} + +fn default_styles(app: &PhotocraftApp) -> (photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle) { + let (mut c, mut para) = app.session.type_defaults.clone().unwrap_or_default(); + let mut p = default_style_params(app); + // The saved panels can appear on the very first frame. Style parsing resolves the named + // font through shared(), which waits for the font scan; display the saved names meanwhile, + // and let fonts_ready/axis_sliders expose the font controls when try_shared is ready. + if photocraft_text::try_shared().is_none() { + c.font_family = app.ui.tool_options.type_font.clone(); + c.font_style = app.ui.tool_options.type_style.clone(); + c.postscript_name = None; + if let Some(p) = p.as_object_mut() { + p.remove("font"); + p.remove("fontStyle"); + } + } + photocraft_engine::type_cmds::apply_char_props(&mut c, &p); + photocraft_engine::type_cmds::apply_para_props(&mut para, &p); + (c, para) +} + +/// Change tool defaults without creating a document edit. The engine owns the style schema; +/// keep its property names rather than maintaining another style parser in the shell. +fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) { + let Some(props) = props.as_object() else { return }; + let o = &mut app.ui.tool_options; + for (key, value) in props { + match key.as_str() { + "font" => { + if let Some(v) = value.as_str() { + o.type_font = v.into(); + } + } + "fontStyle" => { + if let Some(v) = value.as_str() { + o.type_style = v.into(); + // A newly chosen named style replaces a weight-axis setting, as it does + // in apply_char_props. Other axes retain their independently chosen values. + if let Some(axes) = o.type_style_defaults.get_mut("variations").and_then(serde_json::Value::as_object_mut) { + axes.remove("wght"); + } + } + } + "size" => { + if let Some(v) = value.as_f64() { + o.type_size = v as f32; + } + } + "align" => { + if let Some(v) = value.as_str() { + o.type_align = v.into(); + } + } + "metricsAsShown" => {} // Defaults have no layer transform. + "features" | "variations" => { + if let Some(patch) = value.as_object() { + let old = o.type_style_defaults.entry(key.clone()).or_insert_with(|| json!({})); + if let Some(old) = old.as_object_mut() { + old.extend(patch.clone()); + } else { + *old = value.clone(); + } + } + } + _ => { + o.type_style_defaults.insert(key.clone(), value.clone()); + } + } + } +} + +/// Apply only the supplied properties to the text-edit scope or selected type layers; without +/// a type target, prepare the defaults for the next layer instead. fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) { - let Some(mut p) = formatting_params(app) else { return }; + let Some(mut p) = formatting_params(app) else { + apply_defaults(app, &props); + return; + }; if let Some(props) = props.as_object() { for (key, value) in props { p[key] = value.clone(); @@ -2026,7 +2144,17 @@ fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc:: /// characters (or its whole text) while typing, else every selected type layer. The new state /// is explicit, so it follows the button the user saw rather than the layer's first run. pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) { - let Some(mut p) = formatting_params(app) else { return }; + let Some(mut p) = formatting_params(app) else { + if let Some((tag, _, _)) = OPENTYPE_BUTTONS.iter().find(|(_, command, _)| *command == id) { + let props = match *tag { + "liga" => json!({"ligatures": !shown_on}), + "dlig" => json!({"discretionaryLigatures": !shown_on}), + _ => json!({"features": { (*tag): !shown_on }}), + }; + apply_defaults(app, &props); + } + return; + }; p["on"] = json!(!shown_on); let _ = app.run(id, p); } @@ -2093,28 +2221,22 @@ fn cell_width(avail: f32, n: usize) -> f32 { /// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155). pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - type_sections(app, ui, true, true); + type_sections(app, ui, true, true, false); type_options(app, ui); } /// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock -/// group. Without a type layer (or a type edit) there is nothing to show. +/// group. Without a type target they edit the defaults for the next Type-tool layer. pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) { - if styles_at(app).is_none() { - let t = crate::theme::Tokens::get(ui.ctx()); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Select a type layer to edit its character and paragraph settings.")).color(t.text_dim)); - return; - } - type_sections(app, ui, !paragraph, paragraph); + type_sections(app, ui, !paragraph, paragraph, true); } /// The Character and/or Paragraph sections, with the shared collapsible headers and fields that /// fill the panel width (#155). -fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) { +fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool, show_defaults: bool) { use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section}; use crate::theme::ROW_GAP; - let Some((c, para)) = styles_at(app) else { return }; + let Some((c, para)) = styles_at(app).or_else(|| show_defaults.then(|| default_styles(app))) else { return }; let t = crate::theme::Tokens::get(ui.ctx()); // Row helper: `ui.horizontal` with the column gap, then the shared row gap. let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..6246257fc2 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -73,6 +73,239 @@ fn numeric_type_fields_have_localized_accessible_names() { assert_eq!(*h.state(), 36.0); } +/// #5556: the dedicated panels remain usable before there is any type layer, including +/// before opening a document. Properties still belongs to the selected layer. +#[test] +fn type_panels_show_default_controls_without_a_type_target() { + for app in [PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()), new_app()] { + let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + ui.columns(2, |columns| { + super::character_panel(app, &mut columns[0], false); + super::character_panel(app, &mut columns[1], true); + }); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_label("Faux Bold"); + h.get_by_label("Fractions"); + h.get_by_label("Center text"); + h.get_by_label("Hyphenate"); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Font size"); + } +} + +mod panel_defaults { + use super::*; + use crate::state::{Tool, ToolOptions}; + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::NodeT; + use photocraft_doc::text::{Caps, Kerning, Orientation, TextAlign, TextShape}; + + fn panels(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.columns(2, |columns| { + super::super::character_panel(app, &mut columns[0], false); + super::super::character_panel(app, &mut columns[1], true); + }); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h + } + + fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) { + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(value.into())); + h.key_press(egui::Key::Enter); + h.run(); + } + + #[test] + fn panel_defaults_are_editable_and_reach_every_type_tool() { + let mut h = panels(new_app()); + let revision = h.state().session.active().unwrap().revision; + let layers = h.state().session.active().unwrap().doc.walk().len(); + number(&mut h, "Font size", "36"); + number(&mut h, "Tracking (1/1000 em)", "80"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "44"); + number(&mut h, "Indent left margin", "9"); + for label in ["Faux Bold", "Fractions", "Standard Ligatures", "Discretionary Ligatures", "Center text", "Hyphenate"] { + h.get_by_label(label).click(); + h.run(); + } + assert_eq!(h.get_by_label("Fractions").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.get_by_label("Standard Ligatures").accesskit_node().toggled(), Some(Toggled::False)); + assert_eq!(h.state().session.active().unwrap().revision, revision, "pre-styling does not create document history"); + assert_eq!(h.state().session.active().unwrap().doc.walk().len(), layers); + let options = h.state().ui.tool_options.clone(); + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for paragraph in [false, true] { + let mut app = new_app(); + app.ui.tool_options = options.clone(); + app.ui.tool = tool; + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + let revision = app.session.active().unwrap().revision; + super::super::pointer_up(&mut app, [100.0, 100.0], if paragraph { [500.0, 400.0] } else { [100.0, 100.0] }); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let t = text(&app, id); + let c = &t.char_runs()[0].style; + let p = &t.paragraph_runs()[0].style; + assert_eq!(c.size_pt, 36.0); + assert_eq!(c.tracking, 80.0); + assert_eq!(c.leading_pt, Some(44.0)); + assert!(c.faux_bold && c.discretionary_ligatures && !c.ligatures); + assert!(photocraft_engine::type_extra_cmds::feature_on(c, "frac")); + assert_eq!(c.color.to_rgba8(), [51, 102, 153, 255]); + assert_eq!(p.align, TextAlign::Center); + assert_eq!(p.start_indent_pt, 9.0); + assert!(p.hyphenate); + assert_eq!(t.orientation, if tool.is_vertical_type() { Orientation::Vertical } else { Orientation::Horizontal }); + assert_eq!(matches!(t.shape, TextShape::Box { .. }), paragraph); + assert_eq!(app.ui.text_edit.as_ref().unwrap().mask, tool.is_type_mask()); + assert!(app.session.active().unwrap().revision > revision); + super::super::cancel(&mut app); + assert!(app.session.active().unwrap().doc.layer(id).is_none(), "creating and formatting is one undoable edit"); + assert_eq!(app.ui.tool_options, options, "cancelling text does not discard the chosen defaults"); + } + } + } + + #[test] + fn extended_defaults_merge_serialize_and_reset_without_replacing_legacy_options() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + super::super::apply( + &mut app, + &ctx, + json!({ + "fontStyle": "Bold", "size": 32, "kerning": 25, "caps": "smallCaps", + "baselineShift": 3, "horizontalScale": 90, "verticalScale": 110, + "underline": true, "strikethrough": true, "fauxItalic": true, + "firstLineIndent": 5, "endIndent": 7, "spaceBefore": 11, "spaceAfter": 13, + "features": {"swsh": 1}, "variations": {"wght": 650, "wdth": 90} + }), + ); + super::super::apply(&mut app, &ctx, json!({"features": {"frac": 1}, "variations": {"slnt": -8}})); + let (c, p) = super::super::default_styles(&app); + assert_eq!((c.size_pt, c.kern, c.kerning, c.caps), (32.0, 25.0, Kerning::Off, Caps::SmallCaps)); + assert_eq!((c.baseline_shift_pt, c.horizontal_scale, c.vertical_scale), (3.0, 0.9, 1.1)); + assert!(c.underline && c.strikethrough && c.faux_italic); + assert_eq!((p.first_line_indent_pt, p.end_indent_pt, p.space_before_pt, p.space_after_pt), (5.0, 7.0, 11.0, 13.0)); + assert_eq!(c.features.iter().map(|f| f.tag.as_str()).collect::>(), ["frac", "swsh"]); + assert_eq!(c.variations.iter().map(|v| (v.axis.as_str(), v.value)).collect::>(), [("slnt", -8.0), ("wdth", 90.0), ("wght", 650.0)]); + // A named style replaces wght; it must not come back when a later layer is created. + super::super::apply(&mut app, &ctx, json!({"fontStyle": "Regular"})); + let (c, _) = super::super::default_styles(&app); + assert!(!c.variations.iter().any(|v| v.axis == "wght")); + assert_eq!(c.variations.len(), 2); + let saved = serde_json::to_value(&app.ui.tool_options).unwrap(); + let restored: ToolOptions = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(restored, app.ui.tool_options); + crate::tool_memory::persist(&mut app, &ctx); + let remembered = app.session.prefs().tool_options.clone(); + let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restarted.session.prefs.edit(|p| p.tool_options = remembered); + crate::tool_memory::restore(&mut restarted); + assert_eq!(restarted.ui.tool_options, restored, "the real preference restore path retains panel defaults"); + let mut legacy = saved; + legacy.as_object_mut().unwrap().remove("type_style_defaults"); + let legacy: ToolOptions = serde_json::from_value(legacy).unwrap(); + assert_eq!((legacy.type_font.as_str(), legacy.type_style.as_str(), legacy.type_size), ("Inter", "Regular", 32.0)); + assert!(legacy.type_style_defaults.is_empty()); + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + let mut reset = restored.clone(); + crate::tool_reset::reset_options(&mut reset, tool); + assert!(reset.type_style_defaults.is_empty()); + assert_eq!(reset.type_size, ToolOptions::default().type_size); + } + } + + #[test] + fn selected_text_edits_leave_panel_defaults_and_other_characters_untouched() { + let mut app = new_app(); + let ctx = egui::Context::default(); + super::super::apply(&mut app, &ctx, json!({"tracking": 80, "fauxBold": true})); + let defaults = app.ui.tool_options.type_style_defaults.clone(); + let id = LayerId(app.run("type.create", json!({"text": "AéBC", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 2, + anchor: 1, + session: "panel-default-selection".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + super::super::apply(&mut app, &ctx, json!({"tracking": 15})); + super::super::toggle_opentype(&mut app, "type.openType.fractions", false); + let runs = text(&app, id).char_runs(); + assert_eq!(runs.iter().map(|r| (r.len, r.style.tracking)).collect::>(), [(1, 0.0), (2, 15.0), (2, 0.0)]); + assert!(photocraft_engine::type_extra_cmds::feature_on(&runs[1].style, "frac")); + assert!(!photocraft_engine::type_extra_cmds::feature_on(&runs[0].style, "frac")); + assert_eq!(app.ui.tool_options.type_style_defaults, defaults); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&app, id).char_runs().len(), 1, "selection edits keep their typing-session coalescing"); + assert_eq!(text(&app, id).char_runs()[0].style.tracking, 0.0); + } + + #[test] + fn default_text_color_uses_foreground_and_cancel_preserves_it() { + let mut app = new_app(); + let before = app.session.tools.foreground; + let revision = app.session.active().unwrap().revision; + let dialog = super::super::open_color_picker(&mut app, [0.0; 3]); + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::cancel(&mut app, dialog).unwrap(); + assert_eq!(app.session.tools.foreground, before); + let dialog = super::super::open_color_picker(&mut app, [0.0; 3]); + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#336699")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(super::super::default_styles(&app).0.color.to_rgba8(), [51, 102, 153, 255]); + assert_eq!(app.session.active().unwrap().revision, revision); + super::super::pointer_up(&mut app, [100.0, 100.0], [100.0, 100.0]); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert_eq!(text(&app, id).char_runs()[0].style.color.to_rgba8(), [51, 102, 153, 255]); + } + + #[test] + fn unknown_saved_defaults_cannot_become_creation_parameters() { + let mut app = new_app(); + app.ui.tool_options.type_style_defaults = + json!({"tracking": 20, "text": "Injected", "box": [0, 0, 2, 2], "coalesce": "unrelated"}).as_object().unwrap().clone(); + let p = super::super::default_style_params(&app); + assert_eq!(p["tracking"], 20); + assert!(p.get("text").is_none() && p.get("box").is_none() && p.get("coalesce").is_none()); + app.ui.tool_options.type_style_defaults = json!({ + "tracking": 1e300, "leading": 1e300, "spaceAfter": 1e300, + "variations": {"wght": 1e300}, "features": {"frac": {"bad": 1}}, "fauxBold": true + }) + .as_object() + .unwrap() + .clone(); + let p = super::super::default_style_params(&app); + for key in ["tracking", "leading", "spaceAfter", "variations", "features"] { + assert!(p.get(key).is_none(), "hostile {key} is ignored"); + } + let (c, para) = super::super::default_styles(&app); + assert!(c.faux_bold && c.leading_pt.is_none() && c.tracking == 0.0 && para.space_after_pt == 0.0); + } +} + fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { make_harness(ppp, app, false) } diff --git a/docs/gaps.md b/docs/gaps.md index c952bac81b..bf793af8eb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Character/Paragraph defaults before creating text, #5556; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -190,6 +190,9 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without + a type target, editing persisted Type tool defaults inherited by new text. Formatting existing + selected text keeps its current command and undo scope. Other panel gaps remain open. - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't @@ -262,6 +265,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G9: Character/Paragraph defaults before creating text (#5556); no percentage or estimate changed | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5abc8727a4..c460b08933 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-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Character/Paragraph defaults before creating text, #5556; 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) · **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, @@ -198,6 +198,7 @@ Paragraph; the rest open as in-app floating windows. | Behaviour | Status | Evidence | |---|---|---| +| Character and Paragraph controls without a type target | done | #5556; persistent Type tool defaults feed new point, box and mask text; existing text still edits in its selected scope (`type_tool.rs`, `type_tool_tests.rs`) | | Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 | | Drag panels to reorder and regroup | partial | #2272, #2305 | | Docked resize pushes the neighbour | partial | #2573 | @@ -217,6 +218,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Character and Paragraph panels edit persistent defaults without a type target (#5556); no percentage or estimate changed | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | From 70886c0d88d9ecac957993c839d087cde3e2691f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 14 Oct 2026 19:57:02 +0530 Subject: [PATCH 0116/1452] Layers: Group Layers creates the group collapsed (#5571) Photoshop's Group Layers (Cmd/Ctrl+G) and Group from Layers create the new group collapsed, so the Layers panel shows one row in place of the grouped layers. layer.groupLayers and layer.new.groupFromLayers now do the same. Layer::group still defaults to expanded, so New Group, PSD import and artboards are unchanged. Tests that walk group contents now open every group first with layer.setExpanded {"all":true,"expanded":true}. Closes #5571 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNoCkwuu6CgnxJAii7QExa --- crates/engine/src/group_view_cmds.rs | 3 +++ crates/engine/src/layer_multi_cmds.rs | 15 ++++++++++++--- crates/engine/src/layer_nav_cmds.rs | 1 + crates/ui-egui/src/layer_row_ui/tests.rs | 2 ++ crates/ui-egui/src/layer_tree_ui/tests.rs | 2 ++ crates/ui-egui/src/shortcut_dispatch/tests.rs | 2 ++ 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/group_view_cmds.rs b/crates/engine/src/group_view_cmds.rs index b95acff6a7..52eadb782c 100644 --- a/crates/engine/src/group_view_cmds.rs +++ b/crates/engine/src/group_view_cmds.rs @@ -102,6 +102,9 @@ mod tests { s.execute("layer.new.layer", json!({"name": "deep"})).unwrap(); let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap(); let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap(); + // Group Layers creates collapsed groups; the tests start from both open. + assert!(!expanded(&s, inner) && !expanded(&s, outer)); + s.execute(ID, json!({"all": true, "expanded": true})).unwrap(); (s, inner, outer) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 80da3bdd81..f887f46cc6 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1218,7 +1218,8 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> { } /// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves -/// into a new group placed where the top-most of them was. Bottom-to-top order is preserved. +/// into a new, collapsed group placed where the top-most of them was. Bottom-to-top order is +/// preserved. pub fn group_layers(s: &mut Session, p: &Value) -> Result { let ids = match crate::commands::explicit_layer(s, p)? { Some(id) => vec![id], @@ -1232,7 +1233,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { let ids = top_level(doc, &ids); let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?; let name = name.unwrap_or_else(|| doc.next_layer_name("Group")); - let gid = doc.insert_above(Some(top), Layer::group(name, vec![])); + // Photoshop creates the group collapsed, so the panel shows one row in place of the layers. + let mut group = Layer::group(name, vec![]); + if let LayerContent::Group(g) = &mut group.content { + g.expanded = false; + } + let gid = doc.insert_above(Some(top), group); let mut children = Vec::with_capacity(ids.len()); for id in &ids { children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?); @@ -2193,6 +2199,7 @@ mod tests { let docu = doc(&s); let gl = docu.layer(g).unwrap(); assert_eq!(gl.name, "Pair"); + assert!(matches!(&gl.content, LayerContent::Group(gr) if !gr.expanded), "created collapsed"); assert_eq!(gl.children().unwrap().iter().map(|l| l.id).collect::>(), vec![a, c]); // Root (bottom-to-top): Background, b, Pair (where c was), d. assert_eq!(docu.layers.iter().map(|l| l.id).skip(1).collect::>(), vec![b, g, d]); @@ -2201,8 +2208,10 @@ mod tests { assert_eq!(doc(&s).layers.len(), 5); // ⌘G with several selected groups them too. select_all(&mut s, &[b, d]); - s.execute("layer.groupLayers", json!({})).unwrap(); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); assert_eq!(doc(&s).layers.len(), 4); + // Both create the group collapsed, as Photoshop does (#5571). + assert!(matches!(&doc(&s).layer(g).unwrap().content, LayerContent::Group(gr) if !gr.expanded)); } #[test] diff --git a/crates/engine/src/layer_nav_cmds.rs b/crates/engine/src/layer_nav_cmds.rs index bd1ecb661a..b41a18b590 100644 --- a/crates/engine/src/layer_nav_cmds.rs +++ b/crates/engine/src/layer_nav_cmds.rs @@ -118,6 +118,7 @@ mod tests { s.execute("layer.select", json!({"layer": c.0, "mode": "add"})).unwrap(); s.execute("layer.groupLayers", json!({})).unwrap(); let group = s.active().unwrap().doc.layers.iter().find(|l| l.is_group()).unwrap().id; + s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})).unwrap(); let d = id(&mut s, "layer.new.layer", json!({"name": "D"})); (s, [bg, a, group, b, c, d]) } diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 6ec1241b46..cca7d4e839 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -37,6 +37,8 @@ fn busy() -> photocraft_engine::Session { s.execute("layer.layerStyle.dropShadow", json!({"layer": g})).unwrap(); prev = Some(g); } + // Group Layers creates collapsed groups (#5571); these tests start with every group open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s } diff --git a/crates/ui-egui/src/layer_tree_ui/tests.rs b/crates/ui-egui/src/layer_tree_ui/tests.rs index 13e73353b3..0b3d4c2724 100644 --- a/crates/ui-egui/src/layer_tree_ui/tests.rs +++ b/crates/ui-egui/src/layer_tree_ui/tests.rs @@ -21,6 +21,8 @@ fn layered() -> photocraft_engine::Session { s.execute("layer.new.layer", json!({"name": "in-outer"})).unwrap(); s.execute("layer.select", json!({"layer": outer})).unwrap(); s.execute("layer.new.layer", json!({"name": "top"})).unwrap(); + // Group Layers creates collapsed groups (#5571); these tests start with every group open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 458f22c050..c573991e2e 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -21,6 +21,8 @@ fn realistic() -> photocraft_engine::Session { s.execute("file.new", json!({"width": 240, "height": 160})).unwrap(); s.execute("layer.new.layer", json!({"name": "in-group"})).unwrap(); s.execute("layer.groupLayers", json!({"name": "group"})).unwrap(); + // Group Layers creates collapsed groups (#5571); the walk below expects it open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello"})).unwrap(); let paint = s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap(); s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 60})).unwrap(); From b51255f87905b41496e8089c2ea96a08aa5c0fff Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:54:59 +0700 Subject: [PATCH 0117/1452] fix(i18n): keep upstream translations for duplicate catalog keys --- crates/ui-egui/src/i18n/hu.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6a155516b5..5c2037c2ad 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4065,7 +4065,6 @@ cameraRaw Yellow Sárga Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás - Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra # Bevel & Emboss shading controls. Highlight Mode Csúcsfény módja Highlight Color Csúcsfény színe diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c18776efbe..61eeba4c31 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3821,7 +3821,6 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです - Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) # Bevel & Emboss shading controls. Highlight Mode ハイライトモード Highlight Color ハイライトカラー From ff92cb3ffde57df60d06bb67a0192ff8f11ed7bd Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:57:13 +0800 Subject: [PATCH 0118/1452] Preserve editable clipboard contents when native reads fail --- apps/photocraft/src/services.rs | 83 +++++++++++++++--- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 150 +++++++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 2 +- 4 files changed, 204 insertions(+), 33 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index e6c882d198..8b4c7c045e 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -475,23 +475,35 @@ pub fn native(automation: Option) -> clipboard_get_image: Some({ let clip = clip.clone(); Box::new(move || { - let mut slot = clip.try_borrow_mut().ok()?; + let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?), }; // Copied files first: Finder also puts the file's icon on the clipboard as an // image, which would otherwise paste instead of the file. - if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) { - return Some(img); + match cb.get().file_list() { + Ok(paths) => { + if let Some(img) = image_from_files(&paths) { + return Ok(Some(img)); + } + } + Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()), + Err(_) => {} } - if let Ok(img) = cb.get_image() { - return Some((img.width as u32, img.height as u32, img.bytes.into_owned())); + match cb.get_image() { + Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))), + Err(error) => { + #[cfg(target_os = "linux")] + if let Some(image) = wayland_clipboard_image() { + return Ok(Some(image)); + } + match error { + arboard::Error::ContentNotAvailable => Ok(None), + _ => Err(error.to_string()), + } + } } - #[cfg(target_os = "linux")] - return wayland_clipboard_image(); - #[cfg(not(target_os = "linux"))] - None }) }), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), @@ -597,6 +609,57 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// Regression using the real clipboard on an isolated Windows CI runner. + #[cfg(target_os = "windows")] + #[test] + #[ignore = "requires exclusive use of the Windows clipboard"] + fn busy_native_clipboard_preserves_copied_shape() { + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] { + let mut native = native(None); + let services = Services { + clipboard_set_image: native.clipboard_set_image.take(), + clipboard_get_image: native.clipboard_get_image.take(), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded"); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + + // Another thread holds the OS clipboard open beyond arboard's bounded retry window. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + // A non-null window distinguishes this owner from arboard's null-window opens. + let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + drop(guard); + }); + ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + let contention = arboard::Clipboard::new().unwrap().get_image().err(); + let first = app.run(id, json!({})); + release_tx.send(()).unwrap(); + holder.join().unwrap(); + assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}"); + eprintln!("paste while clipboard occupied: {first:?}"); + assert!(first.is_err(), "report contention rather than a successful no-op"); + assert!( + app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()), + "a temporary OS read failure must not discard the copied editable shape" + ); + app.run(id, json!({})).unwrap(); + let state = app.session.active().unwrap(); + assert!( + matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)), + "retry keeps the shape editable" + ); + } + } + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn open_filters_cover_uppercase_and_mixed_case_extensions() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 209691b875..843e02dd76 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1647,7 +1647,7 @@ mod tests { let image = clipboard.clone(); let mut app = PhotocraftApp::new( photocraft_engine::Session::new(), - crate::Services { clipboard_get_image: Some(Box::new(move || image.lock().ok()?.clone())), ..Default::default() }, + crate::Services { clipboard_get_image: Some(Box::new(move || Ok(image.lock().unwrap().clone()))), ..Default::default() }, ); let ctx = egui::Context::default(); let opened = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"newDocument"})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38118955c3..d75df23aa0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -341,8 +341,9 @@ pub type OpenUrlFn = Box Result<(), String>>; pub type EncodePngFn = Box Result, String>>; /// Put an RGBA8 image (width, height, pixels) on the OS clipboard. pub type ClipboardSetFn = Box Result<(), String>>; -/// Read an RGBA8 image from the OS clipboard. -pub type ClipboardGetFn = Box Option<(u32, u32, Vec)>>; +/// Read an RGBA8 image from the OS clipboard. `Ok(None)` means no image is available; +/// `Err` means the contents could not be read and must not invalidate the internal copy. +pub type ClipboardGetFn = Box Result)>, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). @@ -1007,15 +1008,11 @@ impl PhotocraftApp { if let Some(r) = crate::crop_ui::intercept(self, id) { return r; } - let suppress_events = self.automation_input && self.session.prefs().script_events.enabled; - if suppress_events { - self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); - } let t0 = gpu_canvas::now_ms(); // The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU). if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") { if !clip_read { - self.import_os_clipboard(); + self.import_os_clipboard()?; } if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { // Enabled on the strength of the OS clipboard, which held no image: a quiet no-op. @@ -1025,6 +1022,10 @@ impl PhotocraftApp { return Ok(serde_json::json!({"pasted": false})); } } + let suppress_events = self.automation_input && self.session.prefs().script_events.enabled; + if suppress_events { + self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + } // Long commands become background jobs when enabled (`jobs_ui`); the rest run inline. let params = self.with_mask_target(id, params); let params = vector_ui::with_active_path(self, id, params); @@ -2446,7 +2447,9 @@ impl PhotocraftApp { } } } - self.import_os_clipboard(); + if self.import_os_clipboard().is_err() { + return f; + } if let Some(c) = self.session.clipboard.as_ref().filter(|c| !c.bounds.is_empty()) { crate::new_doc_ui::set_clipboard(&mut f, c.bounds.width(), c.bounds.height()); } @@ -2455,22 +2458,32 @@ impl PhotocraftApp { /// If the OS clipboard holds an image that isn't the one we put there, make it the session /// clipboard (so ⌘V pastes screenshots and images copied in other apps, like Photoshop). - /// Returns true when a new external image was imported. Once the OS clipboard no longer holds + /// Returns `Ok(true)` when a new external image was imported. Once the OS clipboard no longer holds /// the image we mirrored or imported (text or a file was copied since), that image is stale: /// it is dropped rather than pasted. - pub(crate) fn import_os_clipboard(&mut self) -> bool { - let Some(get) = self.services.clipboard_get_image.as_mut() else { return false }; - let image = get().filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); + pub(crate) fn import_os_clipboard(&mut self) -> Result { + let Some(get) = self.services.clipboard_get_image.as_mut() else { return Ok(false) }; + // A read failure says nothing about the clipboard contents. Keep our editable copy and + // its signature, and abort this paste rather than treating the failure as an empty image. + let image = match get() { + Ok(image) => image, + Err(error) => { + self.ui.status = error.clone(); + self.ui.status_error = true; + return Err(error); + } + }; + let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { if self.os_clip_sig.take().is_some() { self.session.clipboard = None; self.clip_external = false; } - return false; + return Ok(false); }; let sig = clip_signature(w, h, &bytes); if self.os_clip_sig == Some(sig) && self.session.clipboard.is_some() { - return false; + return Ok(false); } let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); @@ -2478,7 +2491,7 @@ impl PhotocraftApp { self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None }); self.os_clip_sig = Some(sig); self.clip_external = true; - true + Ok(true) } } @@ -2611,7 +2624,7 @@ mod clipboard_tests { *a.lock().unwrap() = Some((w, h, px.to_vec())); Ok(()) })), - clipboard_get_image: Some(Box::new(move || b.lock().unwrap().clone())), + clipboard_get_image: Some(Box::new(move || Ok(b.lock().unwrap().clone()))), ..Default::default() }; let mut app = PhotocraftApp::new(Session::new(), services); @@ -2622,10 +2635,10 @@ mod clipboard_tests { let (w, h, px) = os.lock().unwrap().clone().unwrap(); assert_eq!((w, h, px.len()), (8, 4, 8 * 4 * 4)); // Our own image comes back unchanged (no re-import, keeps the original position). - assert!(!app.import_os_clipboard()); + assert!(!app.import_os_clipboard().unwrap()); // Text copied elsewhere replaces our image: nothing to paste, not the stale image. let ours = os.lock().unwrap().take(); - assert!(!app.import_os_clipboard()); + assert!(!app.import_os_clipboard().unwrap()); assert!(app.session.clipboard.is_none()); *os.lock().unwrap() = ours; app.run("edit.copy", serde_json::json!({})).unwrap(); @@ -2659,12 +2672,107 @@ mod clipboard_tests { type OsClip = Arc)>>>; + #[test] + #[ignore = "renders clipboard error and retry evidence with a GPU adapter"] + fn clipboard_error_and_retry_visual_evidence() { + use std::sync::atomic::{AtomicBool, Ordering}; + let busy = Arc::new(AtomicBool::new(true)); + let unavailable = busy.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = Services { + clipboard_get_image: Some(Box::new(move || { + if unavailable.load(Ordering::SeqCst) { + Err("The native clipboard is not accessible due to being held by another party.".into()) + } else { + Ok(None) + } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [80, 60, 200, 120], "fill": "#3070c0"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app + }); + let out = std::path::PathBuf::from(std::env::var_os("PHOTOCRAFT_CLIPBOARD_EVIDENCE").expect("evidence directory")); + std::fs::create_dir_all(&out).unwrap(); + h.run_steps(8); + let ctx = h.ctx.clone(); + assert!(crate::menus::invoke(h.state_mut(), &ctx, "edit.paste", serde_json::json!({})).is_err()); + h.run_steps(2); + assert!(h.state().ui.status_error); + h.render().unwrap().save(out.join("clipboard-busy.png")).unwrap(); + busy.store(false, Ordering::SeqCst); + crate::menus::invoke(h.state_mut(), &ctx, "edit.paste", serde_json::json!({})).unwrap(); + h.run_steps(3); + assert!(!h.state().ui.status_error); + assert!(h.state().ui.status.is_empty(), "retry clears the clipboard error: {}", h.state().ui.status); + // Direct harness mutations need a repaint after the command has cleared the error. + h.ctx.request_repaint(); + h.run_steps(2); + h.render().unwrap().save(out.join("clipboard-retry.png")).unwrap(); + } + + #[test] + fn failed_clipboard_reads_preserve_editable_contents_until_retry() { + use std::sync::atomic::{AtomicBool, Ordering}; + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let os = OsClip::default(); + let busy = Arc::new(AtomicBool::new(false)); + let (write, read, unavailable) = (os.clone(), os.clone(), busy.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), + clipboard_get_image: Some(Box::new(move || { + if unavailable.load(Ordering::SeqCst) { Err("clipboard is busy".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + let signature = app.os_clip_sig; + assert!(signature.is_some()); + let before = app.session.active().unwrap().doc.walk().len(); + busy.store(true, Ordering::SeqCst); + let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})); + assert_eq!(result.unwrap_err(), "clipboard is busy", "{id}"); + assert!(app.ui.status_error); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active().unwrap().doc.walk().len(), before); + assert_eq!(app.os_clip_sig, signature); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + // The New dialog also reads the clipboard; a failed preset probe must preserve it. + app.new_document_fields(); + assert_eq!(app.os_clip_sig, signature); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + // An automation error must not leave script events disabled by the dispatch guard. + app.automation_input = true; + app.session.edit_prefs(|prefs| prefs.script_events.enabled = true); + assert!(app.run(id, serde_json::json!({})).is_err()); + assert!(app.session.prefs().script_events.enabled); + app.automation_input = false; + busy.store(false, Ordering::SeqCst); + app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap(); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); + assert!(!app.ui.status_error); + } + } + /// An app whose OS clipboard is `os`, counting every read in `reads`. fn app_with_os_clipboard(os: &OsClip, reads: &Arc) -> PhotocraftApp { let (b, n) = (os.clone(), reads.clone()); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap(); @@ -2700,7 +2808,7 @@ mod clipboard_tests { let (b, n) = (os.clone(), Arc::clone(&reads)); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); let ctx = egui::Context::default(); @@ -2750,7 +2858,7 @@ mod clipboard_tests { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..2d4d312420 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -544,7 +544,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "edit.paste" if params.as_object().is_none_or(|o| o.is_empty()) => { // Photoshop: paste in place when the copied area is visible, else centred in the view; // images from other apps are always centred. - app.import_os_clipboard(); + app.import_os_clipboard()?; app.clip_read_for_paste = true; let external = app.clip_external; let visible = !external From 6f46fcfc4ae5d5dad7c28342a3519abfb79bf8f9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:17:59 +0700 Subject: [PATCH 0119/1452] fix(svg): budget rasterised fallback layers across imports --- crates/io/src/svg.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..cc858a0694 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,9 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Aggregate decoded raster memory allowed when opening one drawing. Per-element limits +/// alone do not stop thousands of masked groups from exhausting memory. +const MAX_PIXEL_BYTES: u64 = 1 << 30; /// Past this many drawable elements the whole drawing opens as one pixel layer. const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). @@ -212,6 +215,10 @@ pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: us } fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result { + import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES) +} + +fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result { let Parsed { tree, fontless_text } = parsed; let mut warnings = Vec::new(); if fontless_text { @@ -222,7 +229,7 @@ fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); @@ -311,6 +318,8 @@ struct Conv { noted: Vec<&'static str>, /// Unnamed shapes, groups, images and texts so far, for "Shape 3"-style names. counts: [usize; 4], + /// Remaining budget for rasterised fallback layers, measured in f32 sample bytes. + pixel_budget: u64, } impl Conv { @@ -321,6 +330,20 @@ impl Conv { } } + fn charge_pixels(&mut self, w: usize, h: usize) -> bool { + let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(u64::from(self.fmt.channels()) * 4); + match self.pixel_budget.checked_sub(bytes) { + Some(remaining) => { + self.pixel_budget = remaining; + true + } + None => { + self.note("pixel-budget", "SVG: rasterised elements exceed the import's pixel memory budget; remaining elements were dropped".into()); + false + } + } + } + /// The element's `id`, or a numbered name by kind (0 shape, 1 group, 2 image, 3 text). fn name(&mut self, id: &str, kind: usize) -> String { if !id.is_empty() { @@ -548,7 +571,7 @@ impl Conv { let y0 = ((bb.y() * k).floor() as i32).max(self.canvas.y0); let x1 = ((bb.right() * k).ceil() as i32).min(self.canvas.x1); let y1 = ((bb.bottom() * k).ceil() as i32).min(self.canvas.y1); - if x1 <= x0 || y1 <= y0 { + if x1 <= x0 || y1 <= y0 || !self.charge_pixels((x1 - x0) as usize, (y1 - y0) as usize) { return None; } let Some(mut pixmap) = pixmap((x1 - x0) as u32, (y1 - y0) as u32) else { @@ -768,6 +791,22 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + + #[test] + fn rasterized_groups_stop_at_the_aggregate_pixel_budget() { + let mut svg = String::from(""); + svg.push_str(""); + for i in 0..8 { + svg.push_str(&format!("")); + } + svg.push_str(""); + // Enough for one 256×256 RGBA-f32 surface, but not eight of them. + let budget = 256u64 * 256 * 4 * 4; + let result = import_parsed_with_budget("budget", parse(svg.as_bytes()).unwrap(), MAX_GROUP_DEPTH, budget).unwrap(); + assert_eq!(result.document.layers.len(), 1, "later raster layers are omitted"); + assert!(result.warnings.iter().any(|warning| warning.contains("pixel memory")), "{:?}", result.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; From 92647dd6ffcac9f00857b07afff28e652dead97f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:18:25 +0700 Subject: [PATCH 0120/1452] fix(svg): cast channel count for pixel budget arithmetic --- crates/io/src/svg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index cc858a0694..11d80ed92b 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -331,7 +331,7 @@ impl Conv { } fn charge_pixels(&mut self, w: usize, h: usize) -> bool { - let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(u64::from(self.fmt.channels()) * 4); + let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.channels() as u64 * 4); match self.pixel_budget.checked_sub(bytes) { Some(remaining) => { self.pixel_budget = remaining; From b851ca341762a9ef546806caa6b16d7a603f13fb Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 0121/1452] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 86d5d9c6f2119047f7bdaabe9fe2eab870eedfbb Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 0122/1452] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 932d0689ca9e4c865f3c8ac6cf2b69f93f5ee611 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 0123/1452] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From baae6f506f6797e4a9fc291545614c00a0f5f29c Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:57:08 +0200 Subject: [PATCH 0124/1452] Filters: Lens Correction, Adaptive Wide Angle and Camera Raw Filter respect locks on smart objects (#5621) Root cause: run_filter in lens_cmds.rs added the smart filter and returned for a smart object before it checked the pixel and full locks, so a smart object with Lock All or Lock Pixels (its own or a parent group's) took the filter and a history step. Fix: check the locks before the smart-object branch, as other filters do. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/lens_cmds.rs | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..812c4a9575 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -289,13 +289,14 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: let locks = doc.effective_locks(id); let background = doc.is_background(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // A pixel or full lock refuses the smart filter too, as it does for other filters. + if locks.all || locks.pixels { + return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + } if let LayerContent::Smart(_) = l.content { let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } - if locks.all || locks.pixels { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); - } // A Background layer gains transparency when the edge mode exposes it (as in Photoshop). if float_bg && background { l.locks.position = false; @@ -554,6 +555,36 @@ mod tests { assert!(corner[0] > 0.4, "{corner:?}"); } + #[test] + fn locked_smart_objects_refuse_lens_filters() { + // As other filters do: a pixel or full lock (the layer's own or its group's) refuses the + // smart filter, leaving the layer and the history as they were. + let filters = [(LENS, json!({"distortion": 10})), (WIDE, json!({"model": "perspective", "scale": 90})), (RAW, json!({"saturation": -100}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + for (cmd, p) in &filters { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the smart object ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} edited a smart object locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(layer).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{cmd} {lock} group: {on_group}: no history step"); + } + } + } + } + #[test] fn adaptive_wide_angle_straightens() { let mut s = session(8); From e29ec76d4cd8913e39fc6a1613b27ede889f782e Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:57:40 +0200 Subject: [PATCH 0125/1452] Smart Filters: deleting the last filter removes its filter mask (#5619) Deleting or dragging away the only smart filter now clears the filter mask, as Clear Smart Filters already did, so the next filter takes the new selection. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/smart_cmds.rs | 6 ++++++ crates/engine/src/smart_cmds/tests.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..ba4c9e8bd5 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1069,6 +1069,9 @@ fn delete_filter(s: &mut Session, p: &Value) -> Result { edit_filters(s, p, "Delete Smart Filter", |sm| { let i = filter_index("layer.smartFilter.delete", p, sm)?; sm.smart_filters.remove(i); + if sm.smart_filters.is_empty() { + sm.filter_mask = None; + } Ok(()) }) } @@ -1138,6 +1141,9 @@ fn transfer_filters(s: &mut Session, p: &Value) -> Result { match index { Some(i) => { src.smart_filters.remove(i); + if src.smart_filters.is_empty() { + src.filter_mask = None; + } } None => { src.smart_filters.clear(); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 1caaff6ce2..51d8d007f7 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -330,6 +330,37 @@ fn selection_becomes_the_filter_mask() { assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); } +/// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so +/// the next filter takes the new selection instead of the old one. +#[test] +fn removing_the_last_filter_removes_its_filter_mask() { + let mut s = session(8); + paint(&mut s); + let original = flat(&s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + assert!(active_smart(&s).filter_mask.is_some()); + s.execute("layer.smartFilter.delete", json!({})).unwrap(); + assert!(active_smart(&s).filter_mask.is_none(), "the last filter takes its mask along"); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let px = flat(&s); + let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize]; + assert_eq!(at(&px, 6, 20), at(&original, 6, 20), "the old selection is not filtered"); + assert_ne!(at(&px, 30, 20), at(&original, 30, 20), "the new selection is filtered"); + + // Dragging the only filter to another smart object leaves no mask behind either. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(a)).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty() && sm.filter_mask.is_none()); +} + /// Scales `id` to 10 % around the origin and back, returning the composite. fn shrink_and_grow(s: &mut Session, id: u64) -> Vec<[f32; 4]> { s.execute("edit.transform", json!({"layer": id, "matrix": [0.1, 0, 0, 0.1, 0, 0]})).unwrap(); From 54e7287ba669b82003285faef99ad3aa51f1e9a7 Mon Sep 17 00:00:00 2001 From: lloyd Date: Wed, 14 Oct 2026 18:46:26 +0200 Subject: [PATCH 0126/1452] Animated WebP: every frame loads into video layers (#1198) An animated WebP used to bring only its first frame into a video layer. Now New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, as they do for an animated GIF (#572): - photocraft-codecs: `decode_webp_frames` returns each frame composited onto the canvas by image-webp (offsets, blending, disposal), with its duration in milliseconds and the file's ICC profile. A still WebP is one frame, read as `decode` reads it. A damaged later frame keeps the frames before it and marks the first `Truncated`; the frame count and total bytes are checked against `Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz target. - photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop. - photocraft-engine: `load_frames` takes an animated WebP's frames; the timeline rate comes from the most common duration. A still WebP keeps the existing path and the 30 fps default. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 5 +- crates/codecs/fuzz/Cargo.toml | 7 + .../codecs/fuzz/fuzz_targets/decode_webp.rs | 13 ++ crates/codecs/src/codecs/gif_frames.rs | 8 +- crates/codecs/src/codecs/webp.rs | 50 ++++++ crates/codecs/src/lib.rs | 17 ++ crates/codecs/tests/webp_frames.rs | 166 ++++++++++++++++++ crates/engine/src/video_cmds.rs | 23 ++- crates/engine/src/video_cmds/tests.rs | 74 ++++++++ crates/io/src/flat.rs | 12 +- crates/io/src/lib.rs | 4 +- docs/file-format-parity.md | 5 +- 12 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 crates/codecs/fuzz/fuzz_targets/decode_webp.rs create mode 100644 crates/codecs/tests/webp_frames.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..71796c7fdd 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -128,7 +128,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes from `decode_gif_frames`: each frame composited onto the logical screen with the disposal methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and - `MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`). + `MAX_GIF_FRAMES`. Every frame of an animated WebP comes from `decode_webp_frames` the same + way, composited onto its canvas, with its duration in milliseconds and the file's ICC profile. + The video-layer commands use both (`photocraft_io::import_gif_frames`, + `photocraft_io::import_webp_frames`). * **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet. `caps.exif = false` for TIFF, and a warning is raised. * **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml index 62ec96c523..e01d1bd337 100644 --- a/crates/codecs/fuzz/Cargo.toml +++ b/crates/codecs/fuzz/Cargo.toml @@ -69,3 +69,10 @@ path = "fuzz_targets/decode_gif.rs" test = false doc = false bench = false + +[[bin]] +name = "decode_webp" +path = "fuzz_targets/decode_webp.rs" +test = false +doc = false +bench = false diff --git a/crates/codecs/fuzz/fuzz_targets/decode_webp.rs b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs new file mode 100644 index 0000000000..c1539e1600 --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs @@ -0,0 +1,13 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, decode_webp_frames, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + ..Default::default() + }; + let _ = decode_as_with(Format::WebP, data, &opts); + let _ = decode_webp_frames(data, &opts); +}); diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs index a5bed96254..6a99b54f7b 100644 --- a/crates/codecs/src/codecs/gif_frames.rs +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -14,16 +14,16 @@ use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, Image}; use crate::options::DecodeOptions; -/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise -/// ask for millions of them while staying inside the byte budget. +/// Most frames one GIF (or animated WebP) may yield. Frames are held in memory, and a tiny +/// canvas could otherwise ask for millions of them while staying inside the byte budget. pub const MAX_GIF_FRAMES: usize = 100_000; /// One composited frame of an animation and how long it stays on screen. #[derive(Debug, Clone, PartialEq)] pub struct AnimationFrame { pub image: Image, - /// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a - /// multiple of 10 (0 when the file gives none). + /// On-screen time in milliseconds. GIF counts in hundredths of a second, so there this is a + /// multiple of 10 (0 when the file gives none); WebP counts in milliseconds. pub delay_ms: u32, } diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index b7b6e2a668..f109f62e96 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -56,6 +56,56 @@ pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option bool { + image_webp::WebPDecoder::new(Cursor::new(bytes)).is_ok_and(|d| d.is_animated() && d.num_frames() > 1) +} + +/// Every frame of an animated WebP, or `None` when it is a still one (or an animation of one +/// frame), which [`decode`] reads whole. The frames come composited onto the canvas by +/// `image-webp` (offsets, blending and disposal applied), each a full picture with the file's +/// ICC profile and its duration in milliseconds. +/// +/// The first frame must decode. If a later frame is damaged the frames before it are returned +/// and the first one carries [`DecodeWarning::Truncated`]. The frame count +/// ([`super::gif_frames::MAX_GIF_FRAMES`]) and the total decoded bytes are checked against +/// `limits` before each frame is kept. +pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result>, CodecError> { + use super::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; + if !is_animated(bytes) { + return Ok(None); + } + let (mut dec, w, h, layout) = open(bytes, limits)?; + let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; + let icc = dec.icc_profile().ok().flatten(); + let mut buf = alloc_zeroed::(size)?; + let mut out: Vec = Vec::new(); + let mut damaged = false; + for _ in 0..dec.num_frames() { + if out.len() >= MAX_GIF_FRAMES { + return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); + } + if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); + } + let delay_ms = match dec.read_frame(&mut buf) { + Ok(d) => d, + Err(e) if out.is_empty() => return Err(err(e)), + Err(_) => { + damaged = true; + break; + } + }; + let mut image = Image::from_u8(w, h, layout, buf.clone())?; + image.icc.clone_from(&icc); + out.push(AnimationFrame { image, delay_ms }); + } + if damaged && let Some(first) = out.first_mut() { + first.image.warnings.push(DecodeWarning::Truncated { format: F }); + } + Ok(Some(out)) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..70ade5c9a4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -149,6 +149,23 @@ pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result Result, CodecError> { + match crate::codecs::webp::decode_frames(bytes, &opts.limits)? { + Some(frames) => Ok(frames), + None => Ok(vec![AnimationFrame { image: decode_as_with(Format::WebP, bytes, opts)?, delay_ms: 0 }]), + } +} + +/// Whether `bytes` is an animated WebP of more than one frame (which [`decode`] opens as its +/// first frame, with a warning, and [`decode_webp_frames`] reads whole). +pub fn webp_is_animated(bytes: &[u8]) -> bool { + crate::codecs::webp::is_animated(bytes) +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { diff --git a/crates/codecs/tests/webp_frames.rs b/crates/codecs/tests/webp_frames.rs new file mode 100644 index 0000000000..5045633cad --- /dev/null +++ b/crates/codecs/tests/webp_frames.rs @@ -0,0 +1,166 @@ +//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198). + +use photocraft_codecs::{ + AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode, + webp_is_animated, +}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +fn frames(bytes: &[u8]) -> Vec { + decode_webp_frames(bytes, &DecodeOptions::default()).unwrap() +} + +fn solid(c: [u8; 4], w: u32, h: u32) -> Image { + Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap() +} + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c +} + +fn u24(v: u32) -> [u8; 3] { + let b = v.to_le_bytes(); + [b[0], b[1], b[2]] +} + +/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP +/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the +/// canvas rather than replacing it. +struct Part { + image: Image, + at: [u32; 2], + duration_ms: u32, + blend: bool, +} + +fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part { + Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false } +} + +/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part, +/// each holding a lossless still from our own encoder. +fn animated(w: u32, h: u32, parts: &[Part]) -> Vec { + let mut body = b"WEBP".to_vec(); + let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags + vp8x.extend_from_slice(&u24(w - 1)); + vp8x.extend_from_slice(&u24(h - 1)); + body.extend(chunk(b"VP8X", &vp8x)); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever + for p in parts { + let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap(); + let at = still.windows(4).position(|w| w == b"VP8L").unwrap(); + let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize; + let mut anmf = Vec::new(); + anmf.extend_from_slice(&u24(p.at[0] / 2)); + anmf.extend_from_slice(&u24(p.at[1] / 2)); + anmf.extend_from_slice(&u24(p.image.width() - 1)); + anmf.extend_from_slice(&u24(p.image.height() - 1)); + anmf.extend_from_slice(&u24(p.duration_ms)); + anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend + anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len])); + body.extend(chunk(b"ANMF", &anmf)); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend(body); + out +} + +fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] { + let rgba = f.image.to_rgba8(); + let i = ((y * f.image.width() + x) * 4) as usize; + rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_still_webp_gives_one_frame_equal_to_decode() { + let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap(); + assert!(!webp_is_animated(&still)); + let f = frames(&still); + assert_eq!(f.len(), 1); + assert_eq!(f[0].delay_ms, 0); + assert_eq!(f[0].image, decode(&still).unwrap()); +} + +#[test] +fn a_one_frame_animation_is_not_animated() { + let a = animated(6, 4, &[part(RED, 6, 4, 70)]); + assert!(!webp_is_animated(&a)); + assert_eq!(frames(&a).len(), 1); +} + +#[test] +fn every_frame_comes_back_in_order_with_its_duration() { + let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]); + assert!(webp_is_animated(&a)); + let f = frames(&a); + assert_eq!(f.iter().map(|f| f.delay_ms).collect::>(), [40, 120, 7], "WebP durations are milliseconds"); + for (f, c) in f.iter().zip([RED, GREEN, BLUE]) { + assert_eq!((f.image.width(), f.image.height()), (6, 4)); + assert_eq!(pixel(f, 0, 0), c); + assert_eq!(pixel(f, 5, 3), c); + assert_eq!(f.image.warnings, []); + } +} + +#[test] +fn a_smaller_frame_is_drawn_over_the_previous_one() { + let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true }; + let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small])); + assert_eq!(f.len(), 2); + // Alpha blending rounds: within one level. + let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through"); + assert!(near(pixel(&f[1], 2, 2), BLUE)); + assert!(near(pixel(&f[1], 3, 3), BLUE)); + assert!(near(pixel(&f[1], 4, 2), RED)); +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]); + // Break the last frame's VP8L signature byte; the container around it stays whole. + let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap(); + a[last + 8] = 0; + let f = frames(&a); + assert_eq!(f.len(), 2); + assert_eq!(pixel(&f[0], 0, 0), RED); + assert_eq!(pixel(&f[1], 0, 0), GREEN); + assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]); +} + +#[test] +fn every_prefix_of_an_animated_webp_returns() { + let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]); + for n in 0..a.len() { + let _ = decode_webp_frames(&a[..n], &DecodeOptions::default()); + } +} + +#[test] +fn dimension_limits_apply() { + let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() }; + assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn the_byte_budget_covers_all_frames_together() { + // One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so + // two frames need more than 128; ten frames of the same size must not fit where two do. + let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let ten: Vec = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect(); + let ten = animated(4, 4, &ten); + let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() }; + assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2); + assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_)))); +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..3bc3e97106 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -212,7 +212,7 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { /// Frames loaded from a file or an image sequence. struct Footage { frames: Vec, - /// The frame rate the footage itself implies (a single animated GIF's delays); `None` for + /// The frame rate the footage itself implies (a single animated GIF's or WebP's delays); `None` for /// stills and sequences, which carry none. fps: Option, } @@ -237,7 +237,14 @@ fn fps_from_delays(delays: &[u32]) -> Option { best.map(|(d, _)| 1000.0 / d as f32) } -/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF), or a +/// `photocraft_io::import_gif_frames` / `import_webp_frames`. +type ImportFrames = fn( + &str, + &[u8], + &mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>, +) -> std::result::Result; + +/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result { @@ -267,8 +274,14 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Some(photocraft_io::import_gif_frames as ImportFrames), + Some(photocraft_codecs::Format::WebP) if photocraft_codecs::webp_is_animated(&bytes) => Some(photocraft_io::import_webp_frames as ImportFrames), + _ => None, + }; + if let Some(import) = animation { + import(&name, &bytes, &mut |doc, delay_ms| { frames.push(crate::file_cmds::flattened(&doc, fmt)); delays.push(delay_ms); Ok(()) @@ -283,7 +296,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { /// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller /// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { - let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?; + import_frames(name, codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +/// Every frame of a WebP as a single-layer document, as [`import_gif_frames`] does for a GIF: an +/// animation's frames composited onto its canvas, with their durations in milliseconds; a still +/// WebP is one frame. +pub fn import_webp_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { + import_frames(name, codecs::decode_webp_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +fn import_frames(name: &str, frames: Vec, each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { let n = frames.len(); for f in frames { each(image_to_document(name, &f.image)?.document, f.delay_ms)?; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..7be03bceeb 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -72,8 +72,8 @@ use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, - tga_default_bits, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames, + tga_alpha_channel_to_transparency, tga_default_bits, }; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5229842b6e..2c93f6766d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (animated WebP frames load into video layers, #1198; earlier: Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -29,7 +29,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Animated WebP: New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, composited, with the timeline rate from the durations (#1198) | | 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | From a0e5e6eb14d417854b155bc2aa571408f43c44d9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:49:52 +0200 Subject: [PATCH 0127/1452] Count tool: deleting an earlier count group keeps the active group (#5653) count.deleteGroup only clamped the active index to the new length, so removing a group before the active one silently made the next group active and later count.add calls landed there. Shift the index down when the removed group precedes it; deleting the active last group still selects the new last group. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/analysis_cmds.rs | 4 +++- crates/engine/src/analysis_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 6ad25951db..7cfd07e48e 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -856,7 +856,9 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { s.edit("Delete Count Group", |d, _| { d.measurement.count_groups.remove(g); let n = d.measurement.count_groups.len(); - d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1)); + // Removing a group before the active one shifts it down a slot. + let a = d.measurement.active_count_group; + d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1)); Ok(()) })?; Ok(count_json(doc(s)?.as_ref())) diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..6c1c6b6242 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -291,6 +291,27 @@ fn count_groups_markers_and_undo() { assert!(s.execute("count.add", json!({"x": 1})).is_err()); } +#[test] +fn deleting_an_earlier_count_group_keeps_the_active_group() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + for _ in 0..3 { + s.execute("count.newGroup", json!({})).unwrap(); + } + s.execute("count.setGroup", json!({"group": 2})).unwrap(); + let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // A later group leaves the active index alone. + let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // Deleting the active last group selects the new last group. + let r = s.execute("count.deleteGroup", json!({})).unwrap(); + assert_eq!(r["activeGroup"], 0); + assert_eq!(r["groups"][0]["name"], "Count Group 2"); +} + #[test] fn log_delete_and_csv_export() { let mut s = session(10, 10, 8); From 5834d960038aa115d69fd3710d1b1b628a7f5049 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:52:14 -0700 Subject: [PATCH 0128/1452] fix(codecs): respect uncompressed DDS row pitch --- crates/codecs/README.md | 5 +- crates/codecs/src/codecs/dds.rs | 51 +++++++++++----- crates/codecs/tests/dds.rs | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..8a0deb7acf 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -70,8 +70,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them * **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), - with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are - ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + with the legacy header or the DX10 one. Uncompressed row padding follows the header's + `DDSD_PITCH` stride; without that flag, rows are tightly packed. Everything opens as U8; + the other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index efe93c7f2e..a493d65305 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -14,6 +14,7 @@ const HEADER: usize = 128; /// DDS_HEADER_DXT10, present when the FourCC is `DX10`. const DX10_HEADER: usize = 20; +const DDSD_PITCH: u32 = 0x8; const DDSD_DEPTH: u32 = 0x80_0000; const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; @@ -106,15 +107,29 @@ impl Encoding { } } - /// Bytes of the top mip level. - fn level_size(&self, width: u32, height: u32) -> Option { - let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); - let block = match self { - Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), - Encoding::Bc1 => 8, - Encoding::Bc2 | Encoding::Bc3 => 16, + /// Stored row stride and byte size of the top mip level (block rows for BC). + fn level_layout(&self, width: u32, height: u32, pitch: Option) -> Result<(usize, usize), CodecError> { + let overflow = || bad("pixel data size overflow"); + let (w, h) = (usize::try_from(width).map_err(|_| overflow())?, usize::try_from(height).map_err(|_| overflow())?); + let (row_bytes, rows) = match self { + Encoding::Masks { bytes, .. } => { + let packed = w.checked_mul(*bytes).ok_or_else(overflow)?; + let row_bytes = match pitch { + Some(pitch) => usize::try_from(pitch).map_err(|_| overflow())?, + None => packed, + }; + if row_bytes < packed { + return Err(bad(format!("row pitch {row_bytes} is smaller than the {packed}-byte pixel row"))); + } + (row_bytes, h) + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block = if matches!(self, Encoding::Bc1) { 8 } else { 16 }; + (w.div_ceil(4).checked_mul(block).ok_or_else(overflow)?, h.div_ceil(4)) + } }; - w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + let size = row_bytes.checked_mul(rows).ok_or_else(overflow)?; + Ok((row_bytes, size)) } } @@ -184,19 +199,25 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; let layout = encoding.layout(); limits.check(width, height, layout, SampleType::U8)?; - let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + // The same header field is a total level size for BC, but a row stride when DDSD_PITCH is set. + let pitch = if flags & DDSD_PITCH != 0 { Some(le32(b, 20)?) } else { None }; + let (row_pitch, size) = encoding.level_layout(width, height, pitch)?; let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; let (w, h) = (width as usize, height as usize); let channels = layout.channels(); - let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let row_size = w.checked_mul(channels).ok_or_else(|| bad("pixel size overflow"))?; + let total = row_size.checked_mul(h).ok_or_else(|| bad("pixel size overflow"))?; let mut pixels = vec![0u8; total]; match encoding { Encoding::Masks { bytes, gray, fields } => { - for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { - let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); - let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); - let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; - dst.copy_from_slice(&values[..channels]); + for (src_row, dst_row) in data.chunks_exact(row_pitch).zip(pixels.chunks_exact_mut(row_size)) { + // Stop at the output width, leaving any stored row padding unread. + for (src, dst) in src_row.chunks_exact(bytes).zip(dst_row.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } } } Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs index 766555f6e5..9519d81331 100644 --- a/crates/codecs/tests/dds.rs +++ b/crates/codecs/tests/dds.rs @@ -6,6 +6,7 @@ const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; const DDPF_RGB: u32 = 0x40; const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSD_PITCH: u32 = 0x8; /// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { @@ -31,6 +32,11 @@ fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) } +fn set_pitch(b: &mut [u8], pitch: u32) { + b[8..12].copy_from_slice(&(0x1007 | DDSD_PITCH).to_le_bytes()); + b[20..24].copy_from_slice(&pitch.to_le_bytes()); +} + /// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { let mut b = compressed(width, height, b"DX10"); @@ -169,6 +175,101 @@ fn decodes_packed_masks_and_luminance() { assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); } +#[test] +fn decodes_padded_rgb_rows() { + // DWORD-aligned 24-bit rows: the fourth byte is padding, not the next pixel. + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout()), ((1, 2), ChannelLayout::Rgb)); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]); + + // Padding can contain whole pixels; only the declared width is decoded. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + set_pitch(&mut b, 8); + for v in [RED, GREEN, WHITE, WHITE, BLUE, BLACK, WHITE, WHITE] { + b.extend(v.to_le_bytes()); + } + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]); +} + +#[test] +fn decodes_padded_luminance_rows() { + let mut b = header(2, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Gray, &[10u8, 20, 30, 40][..])); + + let mut b = header(1, 2, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_padded_dx10_rows() { + let mut b = dx10(1, 2, 28, 3, 0, 1); // R8G8B8A8_UNORM + set_pitch(&mut b, 8); + b.extend([1, 2, 3, 4, 99, 98, 97, 96, 5, 6, 7, 8, 95, 94, 93, 92]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8]); +} + +#[test] +fn ignores_pitch_without_the_pitch_flag() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + b[20..24].copy_from_slice(&u32::MAX.to_le_bytes()); + b.extend([255, 0, 0, 0, 255, 0]); + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); + + set_pitch(&mut b, 3); // An explicitly tightly packed row is also valid. + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); +} + +#[test] +fn compressed_linear_size_is_not_a_row_pitch() { + let mut b = compressed(4, 8, b"DXT1"); + b[8..12].copy_from_slice(&(0x1007u32 | 0x8_0000).to_le_bytes()); // DDSD_LINEARSIZE + b[20..24].copy_from_slice(&16u32.to_le_bytes()); // Two 8-byte block rows. + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert!(im.data()[..64].as_chunks::<4>().0.iter().all(|p| p == &[255, 0, 0, 255])); + assert!(im.data()[64..].as_chunks::<4>().0.iter().all(|p| p == &[0, 0, 255, 255])); +} + +#[test] +fn rejects_pitch_smaller_than_a_pixel_row() { + for pitch in [0, 1, 2] { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, pitch); + b.extend([255, 0, 0, 0, 255, 0]); + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })), "pitch {pitch}"); + } +} + +#[test] +fn rejects_truncated_padded_rows() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + assert!(decode(&b).is_ok()); + for n in 128..b.len() { + assert!(matches!(decode(&b[..n]), Err(CodecError::Malformed { .. })), "decoded {n} of {} bytes", b.len()); + } +} + +#[test] +fn rejects_huge_pitch_without_allocating() { + let mut b = header(1, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, u32::MAX); + b.extend([10, 20]); + // On 32-bit targets pitch * height overflows; on 64-bit the payload is too short. + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. }))); +} + #[test] fn decodes_dx10_headers() { let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM From f31fb26fe83f833d013103b382bfb85766d7ca18 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:51:15 +0200 Subject: [PATCH 0129/1452] Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657) Both commands stored the `fps` param unchecked: 0 or a negative rate was kept, and 1e39 became an infinite rate the next .pcraft save refused. They now use timeline_cmds::fps_param (finite, above 0, at most MAX_FPS), as timeline.create and timeline.setProps do; a missing fps keeps the old fallback. Regression test covers 0, -1, 1e39 and "x" for both commands. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/timeline_cmds.rs | 2 +- crates/engine/src/video_cmds.rs | 6 +++--- crates/engine/src/video_cmds/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..3121c5dab4 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -59,7 +59,7 @@ fn duration_param(cmd: &str, p: &Value) -> Result> { } /// The optional `fps` param, rejected unless it's a finite number in `(0, MAX_FPS]` (#1009). -fn fps_param(cmd: &str, p: &Value) -> Result> { +pub(crate) fn fps_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("fps").filter(|v| !v.is_null()) else { return Ok(None) }; v.as_f64() .filter(|f| f.is_finite() && *f > 0.0 && *f <= f64::from(MAX_FPS)) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..301f6d12ba 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -297,7 +297,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; // An explicit `fps` wins, then the rate the footage implies, then 30. - let fps = p.get("fps").and_then(Value::as_f64).map(|f| f as f32).or(footage_fps).unwrap_or(30.0); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); let id = s.edit("New Video Layer from File", |doc, active| { store(doc); @@ -337,10 +337,10 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { } fn interpret_footage(s: &mut Session, p: &Value) -> Result { - let fps = p.get("fps").and_then(Value::as_f64); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.interpretFootage", p)?; edit_video(s, "Interpret Footage", move |v, _| { if let Some(f) = fps { - v.fps = f as f32; + v.fps = f; } Ok(()) }) diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..8c8673af9c 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -330,6 +330,32 @@ fn a_still_gif_is_one_frame_at_the_default_rate() { assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 10.0); } +/// Interpret Footage and New Video Layer from File reject an fps that isn't a finite number in +/// `(0, MAX_FPS]` and keep the layer as it was (#5657). 1e39 used to become an infinite rate the +/// next `.pcraft` save refused. +#[test] +fn an_invalid_frame_rate_is_an_error_and_changes_nothing() { + let mut s = session(); + let id = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let fps = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps; + assert_eq!(fps(&s), 30.0); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.interpretFootage", json!({"fps": bad})).is_err(), "fps {bad}"); + assert_eq!(fps(&s), 30.0, "fps {bad}"); + } + s.execute("layer.videoLayers.interpretFootage", json!({"fps": 24})).unwrap(); + assert_eq!(fps(&s), 24.0); + + let dir = tmpdir("gifin-badfps"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 10), ([0, 255, 0], 10)]); + let layers = s.active().unwrap().doc.layer_count(); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path, "fps": bad})).is_err(), "fps {bad}"); + assert_eq!(s.active().unwrap().doc.layer_count(), layers, "fps {bad}"); + } +} + #[test] fn video_frames_to_layers_takes_every_gif_frame() { let dir = tmpdir("gifin-ftl"); From afd735ba96fbf40e41b5b51a2343201af4a8a9f0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:51:45 +0200 Subject: [PATCH 0130/1452] Image > Adjustments: locked smart objects refuse new smart filters (#5655) adjust_as_smart_filter recorded image.adjustments.* (and Shadows/Highlights) as a smart filter without checking locks, so a Lock All or Lock Pixels smart object still took the filter. It now refuses with check_pixels_unlocked, the same rule filter.* smart filters use. Regression test covers both locks. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/adjust_cmds.rs | 2 ++ crates/engine/src/adjust_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..b242727345 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -270,6 +270,8 @@ pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p if !smart { return Ok(false); } + // A pixel or full lock refuses the smart filter, as for `filter.*` on a smart object. + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; // The recorded params are the command's own, minus the engine's private `__kind` key. let mut params = p.clone(); if let Value::Object(m) = &mut params { diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f614732de..249b88e4bf 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -479,6 +479,26 @@ fn adjustment_smart_filters_take_a_selection_as_their_mask() { assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged"); } +/// #5655: as for `filter.*` smart filters, a pixel or full lock refuses an adjustment smart +/// filter, leaving the layer and the history as they were. +#[test] +fn locked_smart_objects_refuse_adjustment_smart_filters() { + let adjustments = + [("image.adjustments.invert", json!({})), ("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for (cmd, p) in &adjustments { + let mut s = smart_session(); + s.execute("layer.lockLayers", lock.clone()).unwrap(); + let (steps, flat) = (s.active().unwrap().history.past_len(), flatten(&s)); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} added a smart filter to a smart object locked with {lock}: {r:?}"); + assert!(smart_filters_of(&s).is_empty(), "{cmd} {lock}: nothing recorded"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {lock}: no history step"); + assert_eq!(flatten(&s), flat, "{cmd} {lock}: pixels untouched"); + } + } +} + /// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments /// that can't re-render from a source (Replace Color, Match Color) stay off a smart object. #[test] From 08abbf7d902a907352e174b1a660eb1835fd7a1e Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 20:37:46 +0200 Subject: [PATCH 0131/1452] Layer masks: From Transparency and Mask All Objects convert the Background and match the document depth (#5679) From Transparency and Mask All Objects attached a mask to the Background without turning it into "Layer 0" first, unlike every other add-mask path. They and Hide Selection also built their masks as 8-bit, so a 16/32-bit layer's alpha was quantised to 256 levels by From Transparency. All three now build the mask with mask_at_depth at the document depth; 8-bit documents are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_menu_cmds.rs | 9 ++-- crates/engine/src/layer_menu_cmds/tests.rs | 53 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 3969af0576..9b118bc044 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -322,7 +322,7 @@ pub(crate) fn selection_mask(doc: &Document) -> Result> { /// New pixel masks use the document's depth, as PSD saves/imports do. Otherwise a later soft /// brush stroke quantizes differently depending on whether the document reopened as PSD or /// native. Converting a selection keeps its coverage and does not alter the selection itself. -fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { +pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { let format = mask.surface.format().with_sample(depth); if mask.surface.format() != format { mask.surface = mask.surface.try_convert(format)?; diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..45f405a2cd 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -168,11 +168,12 @@ fn apply_mask(s: &mut Session, p: &Value) -> Result { fn mask_from_transparency(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("From Transparency", |doc, _| { + let depth = doc.depth; let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; // Masks are attached to layer coordinates, including pixels temporarily off-canvas. // Clipping to document bounds loses their alpha permanently when moved back (#3043). let r = surf.content_bounds(); - let mut mask = LayerMask::hide_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::hide_all(), depth)?; if !r.is_empty() { let mut px = read_rgba(surf, r); let alpha: Vec = px.iter().map(|q| q[3]).collect(); @@ -183,6 +184,7 @@ fn mask_from_transparency(s: &mut Session, p: &Value) -> Result { } write_rgba(surf, r, &px); } + crate::extra_cmds::background_to_layer_for_mask(doc, id); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); Ok(Value::Null) }) @@ -198,7 +200,7 @@ fn hide_selection(s: &mut Session, p: &Value) -> Result { for x in &mut v { *x = 1.0 - *x; } - let mut mask = LayerMask::reveal_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::reveal_all(), doc.depth)?; mask.surface.write_region(bounds, &v); mask.surface.prune(); for id in ids { @@ -235,10 +237,11 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { let region = region.ok_or_else(|| other("no objects were found"))?; let bbox = region.bbox; s.edit("Mask All Objects", |doc, _| { - let mut mask = LayerMask::hide_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::hide_all(), doc.depth)?; let v: Vec = region.mask.iter().map(|m| f32::from(*m) / 255.0).collect(); mask.surface.write_region(bbox, &v); mask.surface.prune(); + crate::extra_cmds::background_to_layer_for_mask(doc, id); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); Ok(()) })?; diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 46688500b1..555421ed8b 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -505,6 +505,59 @@ fn mask_all_objects_masks_the_subject() { } } +fn subject(x: i32, y: i32) -> [f32; 4] { + let inside = (((x - 20) * (x - 20) + (y - 15) * (y - 15)) as f32).sqrt() < 8.0; + if inside { [0.9, 0.2, 0.1, 1.0] } else { [0.95, 0.95, 0.95, 1.0] } +} + +#[test] +fn from_transparency_and_mask_all_objects_turn_the_background_into_a_layer() { + for cmd in ["layer.layerMask.fromTransparency", "layer.maskAllObjects", "layer.layerMask.hideSelection"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + assert!(doc(&s).is_background(id)); + paint(&mut s, subject); + if cmd.ends_with("hideSelection") { + select_left_half(&mut s); + } + s.execute(cmd, json!({})).unwrap(); + assert!(!doc(&s).is_background(id), "{cmd}: the Background became a layer"); + assert_eq!(active(&s).name, "Layer 0", "{cmd}"); + assert!(active(&s).mask.is_some(), "{cmd}"); + } +} + +#[test] +fn layer_masks_are_built_at_the_document_depth() { + use photocraft_color::SampleType; + for (depth, sample, tol) in [(8, SampleType::U8, 1.0 / 255.0), (16, SampleType::U16, 1.0 / 65535.0), (32, SampleType::F32, 1e-6)] { + let mut s = session(depth, "rgb"); + paint(&mut s, |_, _| [0.2, 0.4, 0.6, 0.5003]); + s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "From Transparency at {depth}"); + assert!((m.value(5, 5) - 0.5003).abs() < tol, "From Transparency at {depth}: {}", m.value(5, 5)); + assert_eq!(m.value(-50, -50), 0.0, "outside the layer stays hidden at {depth}"); + + let mut s = session(depth, "rgb"); + paint(&mut s, |_, _| [0.2, 0.4, 0.6, 1.0]); + select_left_half(&mut s); + s.execute("layer.layerMask.hideSelection", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "Hide Selection at {depth}"); + assert_eq!((m.value(5, 5), m.value(30, 5), m.value(500, 500)), (0.0, 1.0, 1.0), "Hide Selection at {depth}"); + + let mut s = session(depth, "rgb"); + paint(&mut s, subject); + s.execute("layer.maskAllObjects", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "Mask All Objects at {depth}"); + assert!(m.value(20, 15) > m.value(1, 1), "Mask All Objects at {depth}"); + assert_eq!(m.value(500, 500), 0.0, "Mask All Objects at {depth}"); + } +} + #[test] fn layer_masks_in_gray_cmyk_lab() { for mode in ["gray", "cmyk", "lab"] { From 484b93b27febfc8583b633f1ecc3869e471b4bfa Mon Sep 17 00:00:00 2001 From: Florent Siegenthaler <64897087+Atalantis@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:44:05 +0200 Subject: [PATCH 0132/1452] i18n(fr): drop the rewording of existing rows Revert the effect of a23f7778b: every row that main already has is back to main's text, the rows this branch adds keep the wording of the first commit, and the header note recording the exception is removed. The catalog stays clean-room as its header says. --- crates/ui-egui/src/i18n/fr.tsv | 737 ++++++++++++++++----------------- 1 file changed, 367 insertions(+), 370 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bc36ae2b30..390843e94e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -13,9 +13,6 @@ # French computing vocabulary (calque, sélection, pinceau, dégradé, flou, opacité ...). No # proprietary localisation resource, string table or product UI was consulted or copied; in # particular not the French localisation of Adobe Photoshop. Add entries the same way. -# Exception, 2026-10: about 370 entries were realigned on the terms French retouchers use and that -# Adobe's public French help pages show (Couche, Zone de travail, Masque de fusion, Objet dynamique, -# Pixelliser, Réglage ...), so the paragraph above no longer holds for those entries. # Conventions: menus are nouns (Fichier, Édition, Image ...), commands are infinitives (Ouvrir…, # Enregistrer sous…), sentence case. French typography: a no-break space (U+00A0) before # « : ; ? ! » and "%", and inside guillemets « … ». Product and technology names (PhotoCraft, @@ -106,8 +103,8 @@ Render Video… Rendu vidéo… Generate Générer Image Assets Ressources d'image - Place Embedded… Importer et incorporer… - Place Linked… Importer et lier… + Place Embedded… Placer incorporé… + Place Linked… Placer lié… Package… Rassembler… Automate Automatisation Batch… Traitement par lots… @@ -148,11 +145,11 @@ Fade… Estomper… Cut Couper Copy Copier - Copy Merged Copier avec fusion + Copy Merged Copier les calques fusionnés Paste Coller Paste Special Collage spécial - Paste in Place Coller en place - Paste Into Coller dedans + Paste in Place Coller sur place + Paste Into Coller dans la sélection Paste Outside Coller hors de la sélection Paste Lorem Ipsum Coller du Lorem Ipsum Clear Effacer @@ -162,13 +159,13 @@ Add to Dictionary Ajouter au dictionnaire No Spelling Suggestions Aucune suggestion orthographique Fill… Remplir… - Stroke… Contour… + Stroke… Contourner… Content-Aware Fill… Remplissage d'après le contenu… Delete and Fill Selection Supprimer et remplir la sélection - Content-Aware Scale Échelle basée sur le contenu - Puppet Warp Déformation de la marionnette + Content-Aware Scale Mise à l'échelle d'après le contenu + Puppet Warp Déformation par épingles Perspective Warp Déformation de la perspective - Free Transform Transformation manuelle + Free Transform Transformation libre Transform Transformation Again Répéter Scale Mise à l'échelle @@ -218,37 +215,37 @@ Export… Exportation… Performance… Performances… Scratch Disks… Disques de travail… - Cursors… Pointeurs… - Transparency & Gamut… Transparence et couleurs non imprimables… + Cursors… Curseurs… + Transparency & Gamut… Transparence et gamut… Units & Rulers… Unités et règles… Guides, Grid & Slices… Repères, grille et tranches… - Plug-ins… Modules externes… + Plug-ins… Extensions… Type… Texte… Enhanced Controls… Commandes avancées… Camera Raw… Camera Raw… Raw Defaults… Valeurs Raw par défaut… Integrations… Intégrations… AI Integrations… Intégrations d'IA… - Install Plug-in… Installer un module externe… + Install Plug-in… Installer une extension… Language Options Options de langue Settings… Paramètres… Mode Mode Bitmap Bitmap Grayscale Niveaux de gris - Duotone Bichromie + Duotone Duotone Indexed Color Couleurs indexées RGB Color Couleurs RVB CMYK Color Couleurs CMJN Lab Color Couleurs Lab - Multichannel Multicouche - 8 Bits/Channel 8 bits/couche - 16 Bits/Channel 16 bits/couche - 32 Bits/Channel 32 bits/couche + Multichannel Multicanal + 8 Bits/Channel 8 bits/canal + 16 Bits/Channel 16 bits/canal + 32 Bits/Channel 32 bits/canal Color Table… Table des couleurs… Bitmap… Bitmap… - Duotone… Bichromie… + Duotone… Duotone… Indexed Color… Couleurs indexées… - Adjustments Réglages + Adjustments Ajustements Brightness/Contrast… Luminosité/Contraste… Levels… Niveaux… Curves… Courbes… @@ -258,33 +255,33 @@ Color Balance… Balance des couleurs… Black & White… Noir et blanc… Photo Filter… Filtre photo… - Channel Mixer… Mélangeur de couches… - Color Lookup… Correspondance des couleurs… + Channel Mixer… Mélangeur de canaux… + Color Lookup… Table de correspondance des couleurs… Invert Inverser - Posterize… Isohélie… + Posterize… Postériser… Threshold… Seuil… - Gradient Map… Courbe de transfert de dégradé… - Selective Color… Correction sélective… - Shadows/Highlights… Tons foncés/Tons clairs… - HDR Toning… Virage HDR… - Desaturate Désaturation - Match Color… Correspondance de la couleur… - Replace Color… Remplacement de couleur… + Gradient Map… Mappage de dégradé… + Selective Color… Couleur sélective… + Shadows/Highlights… Ombres/Hautes lumières… + HDR Toning… Tonalité HDR… + Desaturate Désaturer + Match Color… Faire correspondre la couleur… + Replace Color… Remplacer la couleur… Equalize Égaliser - Auto Tone Ton automatique + Auto Tone Tonalité automatique Auto Contrast Contraste automatique Auto Color Couleur automatique Image Size… Taille de l'image… - Canvas Size… Taille de la zone de travail… + Canvas Size… Taille du canevas… Image Rotation Rotation de l'image - Flip Canvas Horizontal Symétrie horizontale de la zone de travail - Flip Canvas Vertical Symétrie verticale de la zone de travail + Flip Canvas Horizontal Retourner le canevas horizontalement + Flip Canvas Vertical Retourner le canevas verticalement Crop Recadrer - Trim… Rognage… - Reveal All Tout faire apparaître + Trim… Rogner les bords… + Reveal All Tout afficher Duplicate… Dupliquer… Apply Image… Appliquer une image… - Calculations… Opérations… + Calculations… Calculs… Variables Variables Define… Définir… Data Sets… Jeux de données… @@ -296,7 +293,7 @@ Select Data Points… Sélectionner les points de données… Record Measurements Enregistrer les mesures Ruler Tool Outil Règle - Count Tool Outil Comptage + Count Tool Outil Compteur Place Scale Marker… Placer un repère d'échelle… Custom… Personnalisé… New Nouveau @@ -316,7 +313,7 @@ New Fill Layer Nouveau calque de remplissage New Adjustment Layer Nouveau calque de réglage New Layer Based Slice Nouvelle tranche d'après un calque - New Smart Object via Copy Nouvel objet dynamique par copie + New Smart Object via Copy Nouvel objet intelligent par copie New Blank Video Layer Nouveau calque vidéo vide New Video Layer from File… Nouveau calque vidéo à partir d'un fichier… Duplicate Layer… Dupliquer le calque… @@ -326,14 +323,14 @@ Create Layer Créer un calque Layer Style Style de calque Blending Options… Options de fusion… - Bevel & Emboss… Biseautage et estampage… - Inner Shadow… Ombre interne… - Inner Glow… Lueur interne… + Bevel & Emboss… Biseau et relief… + Inner Shadow… Ombre intérieure… + Inner Glow… Lueur intérieure… Satin… Satin… - Color Overlay… Incrustation couleur… - Gradient Overlay… Incrustation en dégradé… - Pattern Overlay… Incrustation de motif… - Outer Glow… Lueur externe… + Color Overlay… Superposition de couleur… + Gradient Overlay… Superposition de dégradé… + Pattern Overlay… Superposition de motif… + Outer Glow… Lueur extérieure… Drop Shadow… Ombre portée… Copy Layer Style Copier le style de calque Paste Layer Style Coller le style de calque @@ -341,35 +338,35 @@ Global Light… Lumière globale… Hide All Effects Masquer tous les effets Scale Effects… Mettre les effets à l'échelle… - Smart Filter Filtre dynamique - Convert for Smart Filters Convertir pour les filtres dynamiques - Clear Smart Filters Effacer les filtres dynamiques - Disable Smart Filters Désactiver les filtres dynamiques + Smart Filter Filtre intelligent + Convert for Smart Filters Convertir pour les filtres intelligents + Clear Smart Filters Effacer les filtres intelligents + Disable Smart Filters Désactiver les filtres intelligents Delete Filter Mask Supprimer le masque de filtre Disable Filter Mask Désactiver le masque de filtre Solid Color… Couleur unie… Gradient… Dégradé… Pattern… Motif… - Layer Mask Masque de fusion + Layer Mask Masque de calque Vector Mask Masque vectoriel - Reveal Selection Faire apparaître la sélection + Reveal Selection Afficher la sélection Hide Selection Masquer la sélection Hide All Tout masquer From Transparency D'après la transparence Delete Vector Mask Supprimer le masque vectoriel - Enable Layer Mask Activer le masque de fusion - Disable Layer Mask Désactiver le masque de fusion + Enable Layer Mask Activer le masque de calque + Disable Layer Mask Désactiver le masque de calque Enable Vector Mask Activer le masque vectoriel - Link Layer Mask Lier le masque de fusion - Unlink Layer Mask Délier le masque de fusion + Link Layer Mask Lier le masque de calque + Unlink Layer Mask Délier le masque de calque Link Vector Mask Lier le masque vectoriel Add Vector Mask Ajouter un masque vectoriel Vector Mask from Current Path Masque vectoriel d'après le tracé actif - Create Clipping Mask Créer un masque d'écrêtage - Release Clipping Mask Libérer le masque d'écrêtage - Smart Objects Objets dynamiques - Smart Object Objet dynamique - Convert to Smart Object Convertir en objet dynamique + Create Clipping Mask Créer un masque de découpe + Release Clipping Mask Libérer le masque de découpe + Smart Objects Objets intelligents + Smart Object Objet intelligent + Convert to Smart Object Convertir en objet intelligent Convert to Embedded Convertir en objet incorporé Convert to Layers Convertir en calques Convert to Linked… Convertir en objet lié… @@ -404,10 +401,10 @@ Duplicate Frame Dupliquer l'image Frame from Layers Image d'après les calques Video Frames to Layers… Images vidéo dans des calques… - Rasterize Pixelliser - Rasterize Type Pixelliser le texte - Rasterize Type Layer Pixelliser le calque de texte - Rasterize Shape Pixelliser la forme + Rasterize Rastériser + Rasterize Type Rastériser le texte + Rasterize Type Layer Rastériser le calque de texte + Rasterize Shape Rastériser la forme New Guide Layout… Nouvelle disposition de repères… New Guides From Shape Nouveaux repères d'après la forme Lock Layers… Verrouiller les calques… @@ -513,9 +510,9 @@ Save Default Type Styles Enregistrer les styles de texte par défaut Deselect Désélectionner Reselect Resélectionner - Inverse Intervertir - Inverse Selection Intervertir la sélection - Similar Généraliser + Inverse Inverser + Inverse Selection Inverser la sélection + Similar Similaire Color Range… Plage de couleurs… Focus Area… Zone de mise au point… Select and Mask… Sélectionner et masquer… @@ -528,43 +525,43 @@ Modify Modifier Border… Bordure… Smooth… Lisser… - Expand… Dilater… + Expand… Étendre… Contract… Contracter… - Feather… Contour progressif… - Grow Étendre + Feather… Adoucir… + Grow Agrandir Transform Selection Transformer la sélection - Edit in Quick Mask Mode Modifier en mode Masque - Load Selection… Récupérer la sélection… - Save Selection… Mémoriser la sélection… + Edit in Quick Mask Mode Modifier en mode masque rapide + Load Selection… Charger la sélection… + Save Selection… Enregistrer la sélection… Match All Tout faire correspondre Last Filter Dernier filtre Filter Gallery… Galerie de filtres… Adaptive Wide Angle… Grand angle adaptatif… Camera Raw Filter… Filtre Camera Raw… - Liquify… Fluidité… + Liquify… Liquéfier… Vanishing Point… Point de fuite… Blur Flou - Blur Gallery Galerie d'effets de flou - Field Blur… Flou de profondeur de champ… + Blur Gallery Galerie de flous + Field Blur… Flou de champ… # Field Blur pins (#3373) No pin selected Aucune épingle sélectionnée Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. - Iris Blur… Flou de diaphragme… + Iris Blur… Flou d'iris… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… Spin Blur… Flou de rotation… - Blur More Plus flou + Blur More Flou accentué Box Blur… Flou par boîte… Gaussian Blur… Flou gaussien… - Lens Blur… Flou de l'objectif… - Motion Blur… Flou directionnel… + Lens Blur… Flou d'objectif… + Motion Blur… Flou de mouvement… Radial Blur… Flou radial… Shape Blur… Flou selon une forme… - Smart Blur… Flou optimisé… + Smart Blur… Flou intelligent… Surface Blur… Flou de surface… Average Moyenne - Displace… Dispersion… - Pinch… Contraction… + Displace… Déplacement… + Pinch… Pincement… Polar Coordinates… Coordonnées polaires… Ripple… Ondulation… Shear… Cisaillement… @@ -574,16 +571,16 @@ ZigZag… Zigzag… Noise Bruit Add Noise… Ajout de bruit… - Despeckle Flou intérieur - Dust & Scratches… Antipoussière… + Despeckle Supprimer les mouchetures + Dust & Scratches… Poussière et rayures… Median… Médiane… Reduce Noise… Réduction du bruit… - Pixelate Pixellisation - Color Halftone… Demi-teintes couleur… + Pixelate Pixeliser + Color Halftone… Trame de demi-teintes couleur… Crystallize… Cristallisation… Facet Facettes Fragment Fragmentation - Mezzotint… Mezzo-tinto… + Mezzotint… Manière noire… Mosaic… Mosaïque… Pointillize… Pointillisme… Render Rendu @@ -591,38 +588,38 @@ Difference Clouds Nuages par différence Fibers… Fibres… Flame… Flamme… - Lens Flare… Halo… - Lighting Effects… Éclairage… + Lens Flare… Reflet d'objectif… + Lighting Effects… Effets d'éclairage… Relight… Rééclairage… Picture Frame… Cadre d'image… Tree… Arbre… Sharpen Netteté - Sharpen Edges Contours plus nets - Sharpen More Encore plus net - Smart Sharpen… Netteté optimisée… - Unsharp Mask… Accentuation… + Sharpen Edges Netteté des contours + Sharpen More Netteté accentuée + Smart Sharpen… Netteté intelligente… + Unsharp Mask… Masque flou… High Pass… Passe-haut… - Stylize Esthétiques - Diffuse… Diffusion… - Emboss… Estampage… - Extrude… Extrusion… - Find Edges Tracé des contours + Stylize Styliser + Diffuse… Diffuser… + Emboss… Relief… + Extrude… Extruder… + Find Edges Détecter les contours Oil Paint… Peinture à l'huile… - Solarize Solarisation - Tiles… Carrelage… - Trace Contour… Courbes de niveau… - Wind… Soufflerie… + Solarize Solariser + Tiles… Carreaux… + Trace Contour… Tracer les contours… + Wind… Vent… Video Vidéo - De-Interlace… Désentrelacement… + De-Interlace… Désentrelacer… NTSC Colors Couleurs NTSC - Other Divers + Other Autres Maximum… Maximum… Minimum… Minimum… - Offset… Translation… - Plug-ins Modules externes + Offset… Décalage… + Plug-ins Extensions Proof Setup Configuration d'épreuve Proof Colors Couleurs d'épreuve - Gamut Warning Couleurs non imprimables + Gamut Warning Alerte de gamut Working CMYK CMJN de travail Working Cyan Plate Plaque cyan de travail Working Magenta Plate Plaque magenta de travail @@ -664,11 +661,11 @@ HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) 32-bit Preview Options… Options d'aperçu 32 bits… - Screen Mode Mode d'affichage - Standard Screen Mode Mode d'affichage standard + Screen Mode Mode d'écran + Standard Screen Mode Mode d'écran standard Full Screen Mode With Menu Bar Mode plein écran avec barre de menus Full Screen Mode Mode plein écran - Cycle Screen Mode Changer de mode d'affichage + Cycle Screen Mode Changer de mode d'écran Show Afficher Extras Extras Selection Edges Contours de la sélection @@ -676,12 +673,12 @@ Current Path Tracé actif Grid Grille Guides Repères - Canvas Guides Repères de la zone de travail + Canvas Guides Repères du canevas Artboard Guides Repères du plan de travail Count Compteur - Smart Guides Repères commentés + Smart Guides Repères intelligents Slices Tranches - Notes Annotations + Notes Notes Pixel Grid Grille de pixels Pixel Art Pixel art Pixel Art Preview Aperçu pixel art @@ -697,7 +694,7 @@ Lock Guides Verrouiller les repères Clear Guides Effacer les repères Clear Selected Artboard Guides Effacer les repères du plan de travail sélectionné - Clear Canvas Guides Effacer les repères de la zone de travail + Clear Canvas Guides Effacer les repères du canevas New Guide… Nouveau repère… Lock Slices Verrouiller les tranches Clear Slices Effacer les tranches @@ -755,16 +752,16 @@ Actions Actions Brush Settings Paramètres du pinceau Brushes Pinceaux - Channels Couches + Channels Canaux Character Caractère Character Styles Styles de caractère - Clone Source Source de duplication + Clone Source Source de clonage Color Couleur Glyphs Glyphes Gradients Dégradés Histogram Histogramme History Historique - Info Informations + Info Infos Measurement Log Journal des mesures Modifier Keys Touches de modification Navigator Navigation @@ -792,7 +789,7 @@ Invert… Inverser… Load Files into Stack… Charger des fichiers dans une pile… Motion Mouvement - Notes… Annotations… + Notes… Notes… Other… Autre… Remove Warp Split Supprimer la division de la déformation Shape Forme @@ -813,18 +810,18 @@ Adaptive Adaptative Adaptive Wide Angle isn't open Grand angle adaptatif n'est pas ouvert Add Ajouter - Add Layer Mask Ajouter un masque de fusion + Add Layer Mask Ajouter un masque de calque Add Stop Ajouter un point Add a layer style Ajouter un style de calque Add a mask (from the selection; {key} inverts) Ajouter un masque (d'après la sélection ; {key} l'inverse) - Add an adjustment Ajouter un réglage + Add an adjustment Ajouter un ajustement Add disk Ajouter un disque Add space after paragraph Espace après le paragraphe Add space before paragraph Espace avant le paragraphe Add to Sample Ajouter à l'échantillon Add to selection ({key}) Ajouter à la sélection ({key}) Adjust for Zoom Adapter au zoom - Adjustment visible Réglage visible + Adjustment visible Ajustement visible Adobe RGB (1998) compatible Compatible Adobe RGB (1998) Align and Distribute Aligner et répartir Align and distribute Aligner et répartir @@ -848,7 +845,7 @@ Anti-alias Lissage Appearance Apparence Appearance (Layer Style) Apparence (style de calque) - Apply Layer Mask Appliquer le masque de fusion + Apply Layer Mask Appliquer le masque de calque Add Mask to Selection Ajouter le masque à la sélection Subtract Mask from Selection Soustraire le masque de la sélection Intersect Mask with Selection Intersection du masque et de la sélection @@ -915,7 +912,7 @@ Cancel transform (Esc) Annuler la transformation (Échap) Cancel warp (Esc) Annuler la déformation (Échap) Canvas Canevas - Canvas extension color: Couleur d'extension de la zone de travail : + Canvas extension color: Couleur d'extension du canevas : Capture Capturer Capture the visibility, position and style of every layer with + below, then switch between versions. Capturez la visibilité, la position et le style de chaque calque avec le + ci-dessous, puis passez d'une version à l'autre. Catch-up on Stroke End Rattraper en fin de trait @@ -924,10 +921,10 @@ Center text Centrer le texte Change Remplacer Change All Tout remplacer - Change Screen Mode (F) Changer de mode d'affichage (F) + Change Screen Mode (F) Changer de mode d'écran (F) Change To: Remplacer par : - Channel Mixer Mélangeur de couches - Channel: Couche : + Channel Mixer Mélangeur de canaux + Channel: Canal : Check All Layers Vérifier tous les calques Check Spelling Vérifier l'orthographe Clarity Clarté @@ -941,11 +938,11 @@ Click to edit the gradient Cliquez pour modifier le dégradé Click to restore the current colour Cliquez pour rétablir la couleur actuelle Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Cliquez pour zoomer · {key}-clic pour dézoomer · faites glisser vers la droite/gauche pour zoomer/dézoomer - Click with the Note tool to add a note. Cliquez avec l'outil Annotation pour ajouter une annotation. + Click with the Note tool to add a note. Cliquez avec l'outil Note pour ajouter une note. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic : angle · Glisser : lisse · Clic sur le premier point : fermer · {key} : terminer · Échap : annuler Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Clic : angle · Glisser : lisse · {alt}+glisser : briser la poignée · Clic ou glisser sur le premier point : fermer · {key} : terminer · Échap : annuler - Clip to layer below Écrêter selon le calque inférieur - Clipped Écrêté + Clip to layer below Découper selon le calque inférieur + Clipped Découpé Close Style Options Fermer les options de style Cmd Cmd Coated CMYK CMJN papier couché @@ -953,7 +950,7 @@ Color Balance Balance des couleurs Color Detail Détail des couleurs Color Grading Étalonnage des couleurs - Color Lookup Correspondance des couleurs + Color Lookup Table de correspondance des couleurs Color Management Gestion des couleurs Color Mixer Mélangeur de couleurs Color Mode Mode colorimétrique @@ -963,7 +960,7 @@ Colorize Colorer Colors Couleurs Commit Perspective Warp ({key}) Valider la déformation de la perspective ({key}) - Commit Puppet Warp ({key}) Valider la déformation de la marionnette ({key}) + Commit Puppet Warp ({key}) Valider la déformation par épingles ({key}) Commit any current edits ({key}) Valider les modifications en cours ({key}) Commit current crop operation ({key}) Valider le recadrage en cours ({key}) Commit transform ({key}) Valider la transformation ({key}) @@ -971,7 +968,7 @@ Constant Constant Constrain proportions Conserver les proportions Contact Sheet II Planche contact II - Content-Aware Contenu pris en compte + Content-Aware Selon le contenu Contiguous Contigu Contrast Contraste Control: Contrôle : @@ -993,9 +990,9 @@ Create a new group Créer un nouveau groupe Create a new layer Créer un nouveau calque Create new action Créer une nouvelle action - Create new channel Créer une nouvelle couche + Create new channel Créer un nouveau canal Create new document from current state Créer un nouveau document à partir de l'état actuel - Create new fill or adjustment layer Créer un nouveau calque de remplissage ou de réglage + Create new fill or adjustment layer Créer un nouveau calque de remplissage ou d'ajustement Create new gradient Créer un nouveau dégradé Create new group Créer un nouveau groupe Create new layer comp Créer une nouvelle composition de calques @@ -1016,7 +1013,7 @@ Cyan Cyan Cyan · Red Cyan · Rouge Darken Obscurcir - Darks Teintes sombres + Darks Tons sombres Data Sets Jeux de données Default Par défaut Default colours (D) Couleurs par défaut (D) @@ -1027,13 +1024,13 @@ Dehaze Correction de la brume Delete Cropped Pixels Supprimer les pixels recadrés Delete Group Supprimer le groupe - Delete Layer Mask Supprimer le masque de fusion + Delete Layer Mask Supprimer le masque de calque Delete Layers Supprimer les calques Delete Shortcut Supprimer le raccourci Delete Timeline Supprimer le montage Delete Tool Preset Supprimer le préréglage d'outil Delete count group Supprimer le groupe de comptage - Delete current channel Supprimer la couche active + Delete current channel Supprimer le canal actif Delete current state Supprimer l'état actuel Delete effect layer Supprimer le calque d'effet @@ -1049,7 +1046,7 @@ Delete layer Supprimer le calque Delete layer comp Supprimer la composition de calques - Delete note Supprimer l'annotation + Delete note Supprimer la note Delete selected measurements Supprimer les mesures sélectionnées Delete style Supprimer le style Delete tool preset Supprimer le préréglage d'outil @@ -1086,7 +1083,7 @@ Duplicate Layers… Dupliquer les calques… Duplicate style Dupliquer le style Edit Toolbar… Modifier la barre d'outils… - Edit in Quick Mask Mode (Q) Modifier en mode Masque (Q) + Edit in Quick Mask Mode (Q) Modifier en mode masque rapide (Q) Edit in Standard Mode (Q) Modifier en mode standard (Q) Effects Effets Embed Color Profile Incorporer le profil colorimétrique @@ -1105,15 +1102,15 @@ Factor: Facteur : Faux Bold Faux gras Faux Italic Faux italique - Feather Contour progressif + Feather Adoucissement Fewer Points Moins de points File Settings Paramètres du fichier Fill Remplissage - Fill: Fond : - Filter for adjustment layers Filtrer les calques de réglage + Fill: Remplissage : + Filter for adjustment layers Filtrer les calques d'ajustement Filter for pixel layers Filtrer les calques de pixels Filter for shape layers Filtrer les calques de forme - Filter for smart objects Filtrer les objets dynamiques + Filter for smart objects Filtrer les objets intelligents Filter for type layers Filtrer les calques de texte Filter: Filtre : Finger Painting Peinture au doigt @@ -1149,7 +1146,7 @@ Gamma: Gamma : Global Global Glow Color Couleur de la lueur - Gradient Map Courbe de transfert de dégradé + Gradient Map Mappage de dégradé Grain Grain Gray Gris Gray Gamma 2.2 Gris gamma 2,2 @@ -1161,9 +1158,9 @@ Height: Hauteur : Hide Auto Slices Masquer les tranches automatiques Hide Properties Masquer les propriétés - Highlight Compression Compression des tons clairs - Highlight Priority Priorité aux tons clairs - Highlights Tons clairs + Highlight Compression Compression des hautes lumières + Highlight Priority Priorité aux hautes lumières + Highlights Hautes lumières Home Accueil Horizontal scale Échelle horizontale Hue Teinte @@ -1192,7 +1189,7 @@ Interpolation: Interpolation : Intersect with selection ({key}) Intersection avec la sélection ({key}) Invert All Tout inverser - Invert has no settings. Inverser n'a aucun paramètre. + Invert has no settings. Inverser n'a aucun réglage. Italic Italique Join us on Discord Rejoignez-nous sur Discord Justify all Justifier tout @@ -1218,15 +1215,15 @@ Levels Niveaux Lighten Éclaircir Lightness Luminosité - Lights Teintes claires + Lights Tons clairs Limits: Limites : Linear Gradient Dégradé linéaire Link layers Lier les calques Link width and height Lier la largeur et la hauteur - Liquify is not open Fluidité n'est pas ouvert + Liquify is not open Liquéfier n'est pas ouvert Load Charger Load 3D LUT… Charger une LUT 3D… - Load channel as selection Récupérer la couche comme sélection + Load channel as selection Charger le canal comme sélection Load… Charger… Localized Color Clusters Groupes de couleurs localisés Lock Verrouiller @@ -1276,7 +1273,7 @@ New effect layer Nouveau calque d'effet New selection Nouvelle sélection Next frame Image suivante - Next note Annotation suivante + Next note Note suivante No Color Aucune couleur No Color Management Aucune gestion des couleurs No Dither Sans tramage @@ -1302,9 +1299,9 @@ Open a document to use the timeline. Ouvrez un document pour utiliser le montage. Optical Optique Optimized Optimisé - Option-click to define a source point to clone from Option-clic pour définir le point source de la duplication + Option-click to define a source point to clone from Option-clic pour définir le point source du clonage Original Original - Output Channel: Couche de sortie : + Output Channel: Canal de sortie : Output Levels: Niveaux de sortie : Output: Sortie : Outside Extérieur @@ -1341,7 +1338,7 @@ Portrait Portrait Position Position Position and Size Position et taille - Posterize Isohélie + Posterize Postériser Preserve Details Préserver les détails Preserve Luminosity Préserver la luminosité Preset Préréglage @@ -1355,7 +1352,7 @@ Prevent auto-nesting in and out of Artboards and Frames Empêcher l'imbrication automatique dans et hors des plans de travail et des cadres Preview Aperçu Previous frame Image précédente - Previous note Annotation précédente + Previous note Note précédente Print Imprimer Printer Imprimante Printer Manages Colors L'imprimante gère les couleurs @@ -1368,7 +1365,7 @@ Protect Tones Protéger les tons Proximity Match Correspondance de proximité Pulled String Mode Mode corde tendue - Puppet Warp is not active La déformation de la marionnette n'est pas active + Puppet Warp is not active La déformation par épingles n'est pas active Purity Pureté Quality Qualité Quick Actions Actions rapides @@ -1440,7 +1437,7 @@ Saturation Jitter Variation de la saturation Save HTML and Images Enregistrer le HTML et les images Save as PDF Enregistrer en PDF - Save selection as channel Mémoriser la sélection sur une couche + Save selection as channel Enregistrer la sélection comme canal Save… Enregistrer… Scale to Fit Media Adapter au support Scatter Diffusion @@ -1456,17 +1453,17 @@ Select: Sélection : Selections Sélections Selective Sélective - Selective Color Correction sélective + Selective Color Couleur sélective Send the selected pin backward Reculer l'épingle sélectionnée Set additional smoothing options Définir des options de lissage supplémentaires Set background color Définir la couleur d'arrière-plan Set foreground color Définir la couleur de premier plan Set painting symmetry options Définir les options de symétrie de peinture Symmetry Off Symétrie désactivée - Set shape fill type Définir le type de fond de la forme + Set shape fill type Définir le type de remplissage de la forme Set shape stroke type Définir le type de contour de la forme Set the text color Définir la couleur du texte - Shadows Tons foncés + Shadows Ombres Shape: Forme : Sharpening Netteté Shift Maj @@ -1478,7 +1475,7 @@ Show Mesh Afficher le maillage Show Overlay Afficher la superposition Show Transform Controls Afficher les contrôles de transformation - Show or hide the Notes panel Afficher ou masquer le panneau Annotations + Show or hide the Notes panel Afficher ou masquer le panneau Notes Show: Afficher : Sides Côtés Sides: Côtés : @@ -1514,7 +1511,7 @@ Suggestions: Suggestions : Swap colours (X) Permuter les couleurs (X) Swaps height and width Permute la hauteur et la largeur - Switch between free transform and warp modes Basculer entre les modes transformation manuelle et déformation + Switch between free transform and warp modes Basculer entre les modes transformation libre et déformation Switch theme Changer de thème Temperature Température Tetrahedral Tétraédrique @@ -1590,7 +1587,7 @@ No saved presets yet. Aucun préréglage enregistré pour le moment. Error Erreur Command Commande - Adjustment layers have nothing to transform Les calques de réglage n'ont rien à transformer + Adjustment layers have nothing to transform Les calques d'ajustement n'ont rien à transformer Altitude Altitude Android 1080p Android 1080p Apple Watch 45mm Apple Watch 45 mm @@ -1598,10 +1595,10 @@ Art & Illustration Art et illustration ArtCraft ArtCraft Background Eraser Tool Outil Gomme d'arrière-plan - Bevel & Emboss Biseautage et estampage + Bevel & Emboss Biseau et relief Blend Mode Mode de fusion Blues Bleus - Blur Tool Outil Goutte d'eau + Blur Tool Outil Flou Bold Gras Brush Pinceau Block Carré @@ -1609,24 +1606,24 @@ Brush Tip Shape Forme de la pointe Brush Tool Outil Pinceau Build-up Accumulation - Burn Tool Outil Densité + + Burn Tool Outil Assombrir Centimeters Centimètres Choke Resserrement Classic Classique Click to add text Cliquez pour ajouter du texte Click to fill similar colours Cliquez pour remplir les couleurs similaires - Clone Stamp Tool Outil Tampon de duplication + Clone Stamp Tool Outil Tampon de clonage Color Burn Densité couleur + Color Dodge Densité couleur − Color Dynamics Dynamique de la couleur - Color Overlay Incrustation couleur + Color Overlay Superposition de couleur Comic Book Bande dessinée Conform Conforme - Content-Aware Fill needs a selection on a pixel layer Le remplissage d'après le contenu nécessite une sélection sur un calque de pixels + Content-Aware Fill needs a selection on a pixel layer Le remplissage selon le contenu nécessite une sélection sur un calque de pixels Could not transform: the layer is empty Transformation impossible : le calque est vide Could not use Perspective Warp: the layer is empty Impossible d'utiliser la déformation de la perspective : le calque est vide - Could not use Puppet Warp: no opaque pixels Impossible d'utiliser la déformation de la marionnette : aucun pixel opaque - Could not use Puppet Warp: the layer is empty Impossible d'utiliser la déformation de la marionnette : le calque est vide + Could not use Puppet Warp: no opaque pixels Impossible d'utiliser la déformation par épingles : aucun pixel opaque + Could not use Puppet Warp: the layer is empty Impossible d'utiliser la déformation par épingles : le calque est vide Crop Tool Outil Recadrage Crosswise En croix Current Tool Outil actif @@ -1641,7 +1638,7 @@ Document Dimensions Dimensions du document Document Profile Profil du document Document Sizes Tailles du document - Dodge Tool Outil Densité − + Dodge Tool Outil Éclaircir Down Bas Drag to draw a gradient Faites glisser pour tracer un dégradé Drag to move the active layer Faites glisser pour déplacer le calque actif @@ -1652,30 +1649,30 @@ Efficiency Efficacité Efficiency: 100% Efficacité : 100 % Ellipse Tool Outil Ellipse - Elliptical Marquee Tool Outil Ellipse de sélection - Single Row Marquee Tool Outil Rectangle de sélection 1 rangée - Single Column Marquee Tool Outil Rectangle de sélection 1 colonne - Emboss Estampage + Elliptical Marquee Tool Outil Sélection elliptique + Single Row Marquee Tool Outil Sélection de ligne unique + Single Column Marquee Tool Outil Sélection de colonne unique + Emboss Relief Eraser Gomme Eraser Tool Outil Gomme ExtraBold Extra-gras ExtraLight Extra-maigre Eyedropper Tool Outil Pipette Fade Estompage - Fill Opacity Opacité du fond + Fill Opacity Opacité du remplissage Film & Video Film et vidéo From De General Général GitHub GitHub - Gradient Overlay Incrustation en dégradé + Gradient Overlay Superposition de dégradé Gradient Tool Outil Dégradé Greens Verts Hand Tool Outil Main Hard Dur Hard Mix Mélange maximal - Healing Brush Tool Outil Correcteur - Highlights from: Tons clairs à partir de : - History Brush Tool Outil Forme d'historique + Healing Brush Tool Outil Pinceau correcteur + Highlights from: Hautes lumières à partir de : + History Brush Tool Outil Pinceau d'historique Horizontal Type Tool Outil Texte horizontal Horizontal Type Mask Tool Outil Masque de texte horizontal Vertical Type Mask Tool Outil Masque de texte vertical @@ -1683,9 +1680,9 @@ Inches Pouces Indexed Indexé Initial Direction Direction initiale - Inner Bevel Biseau interne - Inner Glow Lueur interne - Inner Shadow Ombre interne + Inner Bevel Biseau intérieur + Inner Glow Lueur intérieure + Inner Shadow Ombre intérieure Lab Lab Landscape, 10 x 8 Paysage, 10 x 8 Landscape, 6 x 4 Paysage, 6 x 4 @@ -1714,20 +1711,20 @@ Mobile Mobile Move Tool Outil Déplacement Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé. - Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation manuelle, car le calque est verrouillé. + Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation libre, car le calque est verrouillé. Multiply Produit My Brushes Mes pinceaux Neutrals Neutres New Workspace Nouvel espace de travail - Note Tool Outil Annotation + Note Tool Outil Note Object Selection Tool Outil Sélection d'objet Off Désactivé Open Ouvrir Orange Orange Original Ratio Proportions d'origine - Out Of Gamut Couleurs non imprimables - Outer Bevel Biseau externe - Outer Glow Lueur externe + Out Of Gamut Hors gamut + Outer Bevel Biseau extérieur + Outer Glow Lueur extérieure Overlay Incrustation Paint Bucket Tool Outil Pot de peinture Paints from the document's opening state Peint à partir de l'état d'ouverture du document @@ -1737,7 +1734,7 @@ Delete Anchor Point Tool Outil Suppression de point d'ancrage Click a segment to add an anchor point · {alt}-click an anchor to delete it Cliquez sur un segment pour ajouter un point d'ancrage · {alt}-clic sur un point d'ancrage pour le supprimer Click an anchor point to delete it · {alt}-click a segment to add one Cliquez sur un point d'ancrage pour le supprimer · {alt}-clic sur un segment pour en ajouter un - Pattern Overlay Incrustation de motif + Pattern Overlay Superposition de motif Pen Pressure Pression du stylet Pen Tilt Inclinaison du stylet Pen Tool Outil Plume @@ -1745,7 +1742,7 @@ Photo Photo PhotoCraft website Site web de PhotoCraft Picas Picas - Pillow Emboss Estampage oreiller + Pillow Emboss Relief en coussin Pixel Replacement Remplacement de pixels Pixels Pixels Points Points @@ -1762,15 +1759,15 @@ Pro (Medium Gray) Pro (gris moyen) Protect Texture Protéger la texture Purple Violet - Quick Mask Mode Masque + Quick Mask Masque rapide Quick Selection Tool Outil Sélection rapide Radial Radial - Rasterize Layer Pixelliser le calque - Rasterize Layers Pixelliser les calques + Rasterize Layer Rastériser le calque + Rasterize Layers Rastériser les calques Ratio Proportions Recent Récent Rectangle Tool Outil Rectangle - Rectangular Marquee Tool Outil Rectangle de sélection + Rectangular Marquee Tool Outil Sélection rectangulaire Reds Rouges Reflected Réfléchi Sample: foreground colour Échantillon : couleur de premier plan @@ -1782,13 +1779,13 @@ Select Data Points Sélectionner les points de données Selection Sélection SemiBold Demi-gras - Shadows up to: Tons foncés jusqu'à : + Shadows up to: Ombres jusqu'à : Shape Dynamics Dynamique de la forme Sharpen Tool Outil Netteté Show Extras Options Options d'affichage des extras Slice Select Tool Outil Sélection de tranche Slice Tool Outil Tranche - Smart Filters Filtres dynamiques + Smart Filters Filtres intelligents Smudge Tool Outil Doigt Soft Doux Soften Adoucir @@ -1839,15 +1836,15 @@ @plural {n} Layer|{n} Layers {n} calque|{n} calques @plural {n} layer|{n} layers {n} calque|{n} calques @plural Group · {n} layer|Group · {n} layers Groupe · {n} calque|Groupe · {n} calques - Additional plugins folder Dossier de modules externes supplémentaire + Additional plugins folder Dossier d'extensions supplémentaire Adobe Adobe Adobe rgb Adobe RGB Allow agent control Autoriser le pilotage par des agents Allow scripts to connect Autoriser la connexion des scripts Always Toujours - Always create smart objects when placing Toujours créer des objets dynamiques lors du placement + Always create smart objects when placing Toujours créer des objets intelligents lors du placement Animated zoom Zoom animé - Apply auto tone Appliquer le ton automatique + Apply auto tone Appliquer la tonalité automatique Ask Demander Ask before saving layered tiff Demander avant d'enregistrer un TIFF avec calques Auto collapse icon panels Réduire automatiquement les panneaux en icônes @@ -1864,19 +1861,19 @@ Cache levels Niveaux de cache Cache tile size Taille des tuiles du cache Camera Raw Defaults Valeurs par défaut de Camera Raw - Canvas border Bordure de la zone de travail - Show canvas dots Afficher les points de la zone de travail + Canvas border Bordure du canevas + Show canvas dots Afficher les points du canevas Clip thumbnails to layer bounds Rogner les vignettes aux limites du calque Clip Thumbnails to Layer Bounds Rogner les vignettes aux limites du calque - Canvas color Couleur de la zone de travail - Canvas custom color Couleur personnalisée de la zone de travail + Canvas color Couleur du canevas + Canvas custom color Couleur personnalisée du canevas Color picker Sélecteur de couleurs Color space Espace colorimétrique Column width Largeur de colonne Concise Concis Control port Port de contrôle Convert to srgb Convertir en sRVB - Cursors Pointeurs + Cursors Curseurs Custom dark Personnalisé sombre Custom light Personnalisé clair Dark Sombre @@ -1886,7 +1883,7 @@ Detailed Détaillé Disks Disques Display p3 Display P3 - Double click layer mask launches select and mask Un double-clic sur le masque de fusion ouvre Sélectionner et masquer + Double click layer mask launches select and mask Un double-clic sur le masque de calque ouvre Sélectionner et masquer Drop shadow Ombre portée Dynamic color sliders Curseurs de couleur dynamiques East asian Asie orientale @@ -1903,8 +1900,8 @@ Fill new type layers with placeholder Remplir les nouveaux calques de texte avec un texte de substitution Font preview Aperçu des polices Full size tip Pointe en taille réelle - Gamut warning color Couleur des alertes de couleurs non imprimables - Gamut warning opacity Opacité des alertes de couleurs non imprimables + Gamut warning color Couleur de l'alerte de gamut + Gamut warning opacity Opacité de l'alerte de gamut Generator enabled Générateur activé Gif GIF Glossy Brillant @@ -1944,7 +1941,7 @@ Nearest neighbor Au plus proche Never Jamais Normal tip Pointe normale - Open as smart object Ouvrir en tant qu'objet dynamique + Open as smart object Ouvrir en tant qu'objet intelligent Open in camera raw Ouvrir dans Camera Raw Open documents as tabs Ouvrir les documents dans des onglets Overscroll Défilement au-delà des bords @@ -1976,7 +1973,7 @@ Scrubby slider acceleration Accélération des curseurs glissants Sessions only Sessions uniquement Sharpen for Netteté pour - Show channels in color Afficher les couches en couleur + Show channels in color Afficher les canaux en couleur Show crosshair in brush tip Afficher le réticule dans la pointe Show extension panels Afficher les panneaux d'extension Show font names in english Afficher les noms de police en anglais @@ -1988,7 +1985,7 @@ Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches - Smart guide color Couleur des repères commentés + Smart guide color Couleur des repères intelligents Smart quotes Guillemets typographiques Snap vector tools and transforms to pixel grid Aimanter les outils vectoriels et les transformations à la grille de pixels Srgb sRVB @@ -2003,15 +2000,15 @@ Text file Fichier texte Tiny Minuscule Touch gestures Gestes tactiles - Transparency & Gamut Transparence et couleurs non imprimables + Transparency & Gamut Transparence et gamut Type units Unités de texte UI font size Taille de police de l'interface UI scale Échelle de l'interface Units & Rulers Unités et règles - Use additional plugins folder Utiliser le dossier de modules externes supplémentaire + Use additional plugins folder Utiliser le dossier d'extensions supplémentaire Use esc to commit Valider avec Échap Use gpu Utiliser le GPU - Use legacy free transform Utiliser l'ancienne transformation manuelle + Use legacy free transform Utiliser l'ancienne transformation libre Use shift key for tool switch Utiliser Maj pour changer d'outil Vary round brush hardness on hud Varier la dureté du pinceau rond dans l'affichage tête haute Webp WebP @@ -2023,9 +2020,9 @@ Pass Through Transfert Dissolve Fondu Darker Color Couleur plus foncée - Linear Dodge (Add) Densité linéaire − (Ajout) + Linear Dodge (Add) Densité linéaire − (Addition) Lighter Color Couleur plus claire - Soft Light Lumière tamisée + Soft Light Lumière douce Hard Light Lumière crue Vivid Light Lumière vive Linear Light Lumière linéaire @@ -2035,7 +2032,7 @@ Luminosity Luminosité @plural {n} sample|{n} samples {n} échantillon|{n} échantillons {added}, {removed} subtracted {added}, {removed} soustraits - Adjustment Preview Aperçu du réglage + Adjustment Preview Aperçu de l'ajustement Airbrush: keeps depositing paint while the pointer rests. Aérographe : continue de déposer de la peinture tant que le pointeur reste immobile. Angle: Angle : Blend If Fusion si @@ -2077,18 +2074,18 @@ Click to sample the foreground colour · {key}-click for background Cliquez pour prélever la couleur de premier plan · {key}-clic pour l'arrière-plan {key}-click to set the source {key}-clic pour définir la source {key} to subtract {key} pour soustraire - Clone source {n} ({key}-click with Clone Stamp to set) Source de duplication {n} ({key}-clic avec le tampon de duplication pour la définir) - Source: not set ({key}-click with Clone Stamp) Source : non définie ({key}-clic avec le tampon de duplication) + Clone source {n} ({key}-click with Clone Stamp to set) Source de clonage {n} ({key}-clic avec le tampon de clonage pour la définir) + Source: not set ({key}-click with Clone Stamp) Source : non définie ({key}-clic avec le tampon de clonage) Pixel Layer Calque de pixels Type Layer Calque de texte Shape Layer Calque de forme Group Layer Calque de groupe Fill Layer Calque de remplissage - Smart Object Layer Calque d'objet dynamique + Smart Object Layer Calque d'objet intelligent Create Warped Text Créer un texte déformé - Rasterize Smart Object Pixelliser l'objet dynamique - Rasterize Fill Content Pixelliser le contenu du remplissage - Rasterize Layer Style Pixelliser le style de calque + Rasterize Smart Object Rastériser l'objet intelligent + Rasterize Fill Content Rastériser le contenu du remplissage + Rasterize Layer Style Rastériser le style de calque Unlock: picking a preset replaces these settings Déverrouiller : choisir un préréglage remplace ces paramètres Lock: keep these settings when picking another preset Verrouiller : conserver ces paramètres en choisissant un autre préréglage Diameter: Diamètre : @@ -2119,7 +2116,7 @@ Mix Mélange Wetness Jitter Variation de l'humidité Mix Jitter Variation du mélange - Show the layer mask as an overlay Afficher le masque de fusion en superposition + Show the layer mask as an overlay Afficher le masque de calque en superposition Toggle visibility Afficher/masquer Unlink the mask from the layer Délier le masque du calque Link the mask to the layer Lier le masque au calque @@ -2131,7 +2128,7 @@ # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Éditeur de dégradé Gradient picker Sélecteur de dégradé - Save As Preset Enregistrer comme préréglage + Save As Preset Enregistrer comme préreglage The gradient the Gradient tool paints with Le dégradé que l'outil Dégradé applique Adds the current gradient to the Gradients panel Ajoute le dégradé actuel au panneau Dégradés Gradient stops Arrêts de dégradé @@ -2143,7 +2140,7 @@ Vertical type Texte vertical Fill path with foreground color Remplir le tracé avec la couleur de premier plan Stroke path with brush Contourner le tracé avec le pinceau - Load path as a selection Récupérer le tracé comme sélection + Load path as a selection Charger le tracé comme sélection Make work path from selection Créer un tracé de travail à partir de la sélection Add vector mask Ajouter un masque vectoriel Create new path Créer un nouveau tracé @@ -2158,7 +2155,7 @@ OpenGL OpenGL Use tablet pressure Utiliser la pression de la tablette Vulkan Vulkan - Alt-click to define a source point to clone from Alt-clic pour définir le point source de la duplication + Alt-click to define a source point to clone from Alt-clic pour définir le point source du clonage No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés. Cancel opening Annuler l'ouverture Cancelled opening {name} Ouverture de {name} annulée @@ -2181,8 +2178,8 @@ Working… Traitement… @id select.all Tout sélectionner @id edit.purge.undo Historique d'annulation - Delete Smart Filters Supprimer les filtres dynamiques - Enable Smart Filters Activer les filtres dynamiques + Delete Smart Filters Supprimer les filtres intelligents + Enable Smart Filters Activer les filtres intelligents New Layer… Nouveau calque… Delete Layer… Supprimer le calque… Color Range Plage de couleurs @@ -2202,7 +2199,7 @@ Stamp Tampon Torn Edges Bords déchirés Water Paper Papier mouillé - Canvas rotation Rotation de la zone de travail + Canvas rotation Rotation du canevas Extensions Extensions Libraries Bibliothèques 3D 3D @@ -2210,27 +2207,27 @@ Move Déplacement Wand Baguette magique Pencil Crayon - Clone Stamp Tampon de duplication + Clone Stamp Tampon de clonage Healing Correcteur - Dodge Densité − - Burn Densité + + Dodge Éclaircir + Burn Assombrir Pen Plume Hand Main Sampler Échantillonneur de couleur - Note tool Outil Annotation + Note tool Outil Note Quick Selection Sélection rapide Magnetic Lasso Lasso magnétique - History Brush Forme d'historique - Art History Brush Forme d'historique artistique + History Brush Pinceau d'historique + Art History Brush Pinceau d'historique artistique Pattern Stamp Tampon de motif Spot Healing Correcteur localisé - Red Eye Œil rouge + Red Eye Yeux rouges Patch Pièce - Content-Aware Move Déplacement basé sur le contenu + Content-Aware Move Déplacement selon le contenu Rotate View Rotation de la vue - Perspective Crop Correction de perspective par recadrage + Perspective Crop Recadrage en perspective Color Replacement Remplacement de couleur - Dodge/Burn Densité −/Densité + + Dodge/Burn Éclaircir/Assombrir Dynamics Dynamique Tilt Inclinaison Reset Réinitialiser @@ -2246,7 +2243,7 @@ Advanced Blending Fusion avancée RGB RVB Alpha Alpha - Spot Channel Couche de ton direct + Spot Channel Canal de ton direct Working Gray Plate Plaque grise de travail HSL/Grayscale TSL/Niveaux de gris Tone Curve Courbe des tonalités @@ -2283,7 +2280,7 @@ Localize Color Clusters Localiser les groupes de couleurs Duplicated selection name Nom de la sélection dupliquée Operation Opération - New channel only Nouvelle couche uniquement + New channel only Nouveau canal uniquement Masked Areas Zones masquées Selected Areas Zones sélectionnées Spot Color Ton direct @@ -2409,7 +2406,7 @@ Document Size Taille du document Timing Minutage Status Information Informations d'état - Saved Multichannel Multicouche enregistré + Saved Multichannel Multicanal enregistré Mouse Coordinates Coordonnées de la souris Ruler Units Unités des règles Hide Masquer @@ -2531,10 +2528,10 @@ Units Unités Columns Colonnes Create new count group Créer un nouveau groupe de comptage - Adjustment Layer Calque de réglage + Adjustment Layer Calque d'ajustement Pencil Tool Outil Crayon Pixel Pixel - Adjustment Réglage + Adjustment Ajustement Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Faites glisser (lasso) ou cliquez des points (polygonal) · {add} ajouter · {sub} soustraire 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 Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · flèches le décalent · X permute l'orientation · {commit} valide · Échap annule {mode} Color · {bits} bit Couleurs {mode} · {bits} bits @@ -2578,12 +2575,12 @@ Brush Size Taille du pinceau Heal Color Couleur de correction Mask Masque - Channel Couche + Channel Canal Rectangle Rectangle Result Résultat - New Channel Nouvelle couche - Save Selection Mémoriser la sélection - Load Selection Récupérer la sélection + New Channel Nouveau canal + Save Selection Enregistrer la sélection + Load Selection Charger la sélection Duplicate Dupliquer Fast Rapide Accurate Précis @@ -2605,7 +2602,7 @@ Click to zoom in · ⌥-click to zoom out Cliquez pour zoomer · ⌥-clic pour dézoomer Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic : angle · Glisser : lisse · Clic sur le premier point : fermer · ↩ : terminer · Échap : annuler Commit Perspective Warp (↩) Valider la déformation de la perspective (↩) - Commit Puppet Warp (↩) Valider la déformation de la marionnette (↩) + Commit Puppet Warp (↩) Valider la déformation par épingles (↩) Commit any current edits (⌘↩) Valider les modifications en cours (⌘↩) Commit current crop operation (↵) Valider le recadrage en cours (↵) Commit transform (↩) Valider la transformation (↩) @@ -2633,7 +2630,7 @@ # Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). New from Clipboard Créer depuis le presse-papiers - Free Transform a Copy Transformation manuelle d'une copie + Free Transform a Copy Transformer librement une copie Transform Again on a Copy Répéter la transformation sur une copie Select Layer Above Sélectionner le calque au-dessus Select Layer Below Sélectionner le calque au-dessous @@ -2688,8 +2685,8 @@ cameraRaw Light Lumière Camera Raw Filter ({layer}) Filtre Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) -cameraRaw Lights Teintes claires -cameraRaw Darks Teintes sombres +cameraRaw Lights Tons clairs +cameraRaw Darks Tons sombres cameraRaw Blacks Noirs cameraRaw Amount Quantité cameraRaw Tint Teinte @@ -2725,7 +2722,7 @@ cameraRaw Blending Fusion Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Faites glisser ou cliquez des points de polygone · maintenez Alt pendant le lasso pour des segments droits · {add} ajouter · {sub} avant de dessiner soustrait Extend Extension Structure: Structure : - Content-Aware Move Tool Outil Déplacement basé sur le contenu + Content-Aware Move Tool Outil Déplacement selon le contenu TIFF Options Options TIFF Targa Options Options Targa 24 bits/pixel 24 bits/pixel @@ -2792,8 +2789,8 @@ cameraRaw Blending Fusion # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte) Color Picker ({name}) Sélecteur de couleurs ({name}) - Color Picker (Fill Color) Sélecteur de couleurs (Couleur de fond) - Fill color Couleur de fond + Color Picker (Fill Color) Sélecteur de couleurs (Couleur de remplissage) + Fill color Couleur de remplissage Color Picker (Stroke Color) Sélecteur de couleurs (Couleur du contour) Stroke color Couleur du contour Pick screen color Choisir une couleur à l’écran @@ -2835,7 +2832,7 @@ cameraRaw Blending Fusion Rounded Steps Paliers arrondis Sawtooth 1 Dents de scie 1 - Red Eye Tool Outil Œil rouge + Red Eye Tool Outil Yeux rouges Pupil Size Taille de la pupille Darken Amount Quantité d'assombrissement Click a red pupil to neutralize it Cliquez sur une pupille rouge pour la corriger @@ -2894,8 +2891,8 @@ fontWeight Italic Italique 51 by 51 Average Moyenne 51 × 51 101 by 101 Average Moyenne 101 × 101 Sample Size: Taille de l'échantillon : - All Layers No Adjustments Tous les calques sans réglages - Current & Below No Adjustments Actif et inférieurs sans réglages + All Layers No Adjustments Tous les calques sans ajustements + Current & Below No Adjustments Actif et inférieurs sans ajustements Show Sampling Ring Afficher l'anneau d'échantillonnage # Color Picker warnings @@ -2991,7 +2988,7 @@ fontWeight Italic Italique Follows only edges this close to the pointer ([ and ] change it) Ne suit que les contours aussi proches du pointeur ([ et ] le modifient) Higher values follow only edges that contrast sharply with their surroundings Plus la valeur est élevée, plus seuls les contours très contrastés sont suivis How often fastening points are placed by themselves Fréquence de pose automatique des points d’ancrage - Appearance Settings… Paramètres d’apparence… + Appearance Settings… Réglages d’apparence… Add to favorites Ajouter aux favoris Remove from favorites Retirer des favoris # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). @@ -3026,7 +3023,7 @@ cameraData Lens Objectif Create a new rectangular frame Créer un cadre rectangulaire Create a new elliptical frame Créer un cadre elliptique Include in playback Inclure dans la lecture - Perspective Crop Tool Outil Correction de perspective par recadrage + Perspective Crop Tool Outil Recadrage perspective Show Grid Afficher la grille Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Tracez un cadre ou cliquez sur quatre coins · faites glisser les coins sur les bords d'une forme inclinée · {commit} valide · Échap annule Artboard Tool Outil Plan de travail @@ -3046,15 +3043,15 @@ cameraData Lens Objectif There is no crop size or ratio to save Aucune taille ni proportion de recadrage à enregistrer Scale: Échelle : Snap to Origin Aligner sur l'origine - Search settings… Rechercher des paramètres… - No matching settings Aucun paramètre correspondant + Search settings… Rechercher des réglages… + No matching settings Aucun réglage correspondant Loading fonts… Chargement des polices… Response curve Courbe de réponse Tool Outil Simulate Pressure Simuler la pression Stroke Path… Contourner le tracé… Background Eraser Gomme d'arrière-plan - Healing Brush Correcteur + Healing Brush Pinceau correcteur Spot Healing Brush Correcteur localisé Smudge Doigt Sponge Éponge @@ -3090,14 +3087,14 @@ curves Smooth Lisser Presentation Présentation # Color Sampler (added with the i18n coverage repair) - Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors de la zone de travail pour supprimer + Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer No document open Aucun document ouvert {n} of {max} sample points {n} sur {max} points d'échantillonnage # Print dialog (#2697) Emulsion Down Émulsion verso Functions Fonctions Print Resolution: Résolution d’impression : - Use Previous Layer to Create Clipping Mask Utiliser le calque précédent pour créer un masque d'écrêtage + Use Previous Layer to Create Clipping Mask Utiliser le calque précédent pour créer un masque de découpe Fill with {mode}-neutral color (white) Remplir avec la couleur neutre de {mode} (blanc) Fill with {mode}-neutral color (black) Remplir avec la couleur neutre de {mode} (noir) Fill with {mode}-neutral color (50% gray) Remplir avec la couleur neutre de {mode} (gris 50 %) @@ -3180,8 +3177,8 @@ curves Smooth Lisser Add Noise Ajout de bruit Added swatch {name} Nuance {name} ajoutée Adjust Interval For Loops Ajuster l'intervalle pour les boucles - Adjustment Layers Calques de réglage - Adjustment Properties Propriétés du réglage + Adjustment Layers Calques d'ajustement + Adjustment Properties Propriétés de l'ajustement Adobe-rgb-compat Adobe RGB (compatible) Adwaita (Dark) Adwaita (sombre) Adwaita (Light) Adwaita (clair) @@ -3212,7 +3209,7 @@ curves Smooth Lisser Artboards Plans de travail Artboards to Files Plans de travail en fichiers Artboards to PDF Plans de travail en PDF - Artistic Artistiques + Artistic Artistique Ask On Mismatch Demander en cas de non-concordance Ask On Missing Demander en cas d'absence Ask On Paste Demander au collage @@ -3238,7 +3235,7 @@ curves Smooth Lisser Blade Curvature Courbure des lames Blast Rafale Bleach Bypass Sans blanchiment - Blend Clipped Layers as Group Fusionner les calques écrêtés en tant que groupe + Blend Clipped Layers as Group Fusionner les calques découpés en tant que groupe Blend Interior Effects as Group Fusionner les effets internes en tant que groupe Blend Text Gamma Gamma de fusion du texte Blending Mode Mode de fusion @@ -3262,12 +3259,12 @@ curves Smooth Lisser Brush Strokes Traits de pinceau Brush Type Type de pinceau Burlap Toile de jute - Calculations Opérations + Calculations Calculs Camera Raw Camera Raw Camera Raw Filter Filtre Camera Raw Cancel transform Annuler la transformation Candle Light Bougie - Canvas Size Taille de la zone de travail + Canvas Size Taille du canevas Cell Size Taille des cellules Center X Centre X Center Y Centre Y @@ -3275,10 +3272,10 @@ curves Smooth Lisser Changes Saved Modifications enregistrées Changes have not been saved. Les modifications n'ont pas été enregistrées. Change… Modifier… - Channel1 Couche 1 - Channel2 Couche 2 - Channel3 Couche 3 - Channel4 Couche 4 + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 Charcoal Area Zone de fusain Charcoal Thickness Épaisseur du fusain Check for Updates Rechercher des mises à jour @@ -3288,22 +3285,22 @@ curves Smooth Lisser Cleanliness Propreté Clear Amount Zone sans lueur Clear Selection Effacer la sélection - Clipping Mask Masque d'écrêtage + Clipping Mask Masque de découpe Clipping Path… Tracé de détourage… Clone Cloner - Clone source set at {x}, {y} Source de duplication définie en {x}, {y} + Clone source set at {x}, {y} Source de clonage définie en {x}, {y} Close Window Fermer la fenêtre Clumped Aggloméré Coarse Dots Points grossiers Coated-cmyk CMJN papier couché Color Decontaminate Décontaminer les couleurs - Color Halftone Demi-teintes couleur + Color Halftone Trame de demi-teintes couleur Color Indicates Masked Areas La couleur indique les zones masquées Color Indicates Selected Areas La couleur indique les zones sélectionnées Color Lookup Tables Tables de correspondance des couleurs Color Picker Sélecteur de couleurs Color Picker (Background Color) Sélecteur de couleurs (Couleur d'arrière-plan) - Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée de la zone de travail) + Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée du canevas) Color Picker (Foreground Color) Sélecteur de couleurs (Couleur de premier plan) Color Settings Paramètres de couleur Color Swatches Nuancier @@ -3321,7 +3318,7 @@ curves Smooth Lisser Contract Contracter Contrasty Contrasté Convert Convertir - Convert to Alpha Channel Convertir en couche alpha + Convert to Alpha Channel Convertir en canal alpha Convert to Linked Convertir en objet lié Convert to Profile Convertir en profil Cool Froid @@ -3329,7 +3326,7 @@ curves Smooth Lisser Cooling80 Refroidissement 80 Cooling82 Refroidissement 82 Copy Complete Stroke Copier le contour complet - Copy Fill Copier le fond + Copy Fill Copier le remplissage Correct C A Corriger l'aberration chromatique Correct Distortion Corriger la distorsion Correct Vignette Corriger le vignettage @@ -3348,10 +3345,10 @@ curves Smooth Lisser Crosshatch Hachures croisées Crystallize Cristallisation Currency Devise - Curve Darks Courbe : teintes sombres - Curve Highlights Courbe : tons clairs - Curve Lights Courbe : teintes claires - Curve Shadows Courbe : tons foncés + Curve Darks Courbe : tons sombres + Curve Highlights Courbe : hautes lumières + Curve Lights Courbe : tons clairs + Curve Shadows Courbe : ombres Curve Splits Courbe : points de séparation Custom Branch Color Couleur de branche personnalisée Custom Pixel Aspect Ratio Format des pixels personnalisé @@ -3370,7 +3367,7 @@ curves Smooth Lisser Date Date Date and Time Date et heure Day For Night Nuit américaine - De-Interlace Désentrelacement + De-Interlace Désentrelacer Deep Profond Deep Blue Bleu profond Deep Emerald Émeraude profond @@ -3386,13 +3383,13 @@ curves Smooth Lisser Definition Définition Defringe Supprimer les franges Degrees Degrés - Delete Channel Supprimer la couche + Delete Channel Supprimer le canal Delete Mask Supprimer le masque Delete… Supprimer… Depth Map Carte de profondeur Depth Mode Mode de profondeur Diameter Diamètre - Diffuse Diffusion + Diffuse Diffuser Diffuse Glow Lueur diffuse Direction Balance Équilibre des directions Disable Désactiver @@ -3400,7 +3397,7 @@ curves Smooth Lisser Disable Symmetry Path Désactiver le tracé de symétrie Disabled Désactivé Discard Changes Ignorer les modifications - Displace Dispersion + Displace Déplacement Display Affichage Display profile not used Profil d'affichage non utilisé Display profiles: {reason} Profils d'affichage : {reason} @@ -3417,11 +3414,11 @@ curves Smooth Lisser Drag: draw freehand · Release on the start point: close Glisser : tracer à main levée · Relâcher sur le point de départ : fermer Driver: {driver} Pilote : {driver} Dry Brush Pinceau sec - Duplicate Channel Dupliquer la couche + Duplicate Channel Dupliquer le canal Duplicate Layer Dupliquer le calque Duplicate Layers Dupliquer les calques Duplication Duplication - Dust & Scratches Antipoussière + Dust & Scratches Poussière et rayures Edge Brightness Luminosité du contour Edge Extension Extension du contour Edge Fidelity Fidélité des contours @@ -3440,7 +3437,7 @@ curves Smooth Lisser End Y Fin Y Enlarged Agrandi Even Fields Trames paires - Exit Quick Mask Quitter le mode Masque + Exit Quick Mask Quitter le mode masque rapide Export As Exporter sous Export Contents Exporter le contenu Export Image Exporter l'image @@ -3454,23 +3451,23 @@ curves Smooth Lisser Exported {path} {path} exporté Exported {path} ({size}) {path} exporté ({size}) Exporting… Exportation… - Extrude Extrusion + Extrude Extruder Faded Film Film délavé Failed Échec Failed to open file Échec de l'ouverture du fichier Failed to save file Échec de l'enregistrement du fichier Fallback: {reason} Repli : {reason} - Feather Radius Rayon du contour progressif + Feather Radius Rayon d'adoucissement Fiber Length Longueur des fibres Fibers Fibres - Field Blur Flou de profondeur de champ + Field Blur Flou de champ File Format Format de fichier File Info Informations sur le fichier File Name Nom du fichier File Size Taille du fichier File not found Fichier introuvable Filename Nom de fichier - Fill Color Couleur de fond + Fill Color Couleur de remplissage Fill Path… Remplir le tracé… Film Grain Grain de film Filter Gallery Galerie de filtres @@ -3496,7 +3493,7 @@ curves Smooth Lisser Foreground Level Niveau de premier plan Forward Warp Déformation vers l'avant Frame Cadre - Free Transform Path Transformation manuelle du tracé + Free Transform Path Transformation libre du tracé Freeze Lasso Lasso de figeage Freeze Mask Masque de figeage Fresco Fresque @@ -3526,9 +3523,9 @@ curves Smooth Lisser Grade Balance Balance de l'étalonnage Grade Blending Fusion de l'étalonnage Grade Global Étalonnage global - Grade Highlights Étalonnage des tons clairs + Grade Highlights Étalonnage des hautes lumières Grade Midtones Étalonnage des tons moyens - Grade Shadows Étalonnage des tons foncés + Grade Shadows Étalonnage des ombres Grain Amount Quantité de grain Grain Roughness Rugosité du grain Grain Size Taille du grain @@ -3548,7 +3545,7 @@ curves Smooth Lisser Groups Groupes Grout Width Largeur du joint Guide Layout… Disposition des repères… - HDR Toning Virage HDR + HDR Toning Tonalité HDR Hearts Cœurs Heptagon Heptagone Hexagon Hexagone @@ -3557,12 +3554,12 @@ curves Smooth Lisser Hide Others Masquer les autres Hide {app} Masquer {app} High Pass Passe-haut - Highlight Tons clairs - Highlight Amount Quantité des tons clairs - Highlight Area Zone des tons clairs - Highlight Radius Rayon des tons clairs - Highlight Strength Intensité des tons clairs - Highlight Tone Amplitude tonale des tons clairs + Highlight Hautes lumières + Highlight Amount Quantité des hautes lumières + Highlight Area Zone des hautes lumières + Highlight Radius Rayon des hautes lumières + Highlight Strength Intensité des hautes lumières + Highlight Tone Amplitude tonale des hautes lumières Horizontal Only Horizontal seulement Hotspot Point chaud Hsb TSL @@ -3588,7 +3585,7 @@ curves Smooth Lisser Input Entrée Input Levels Niveaux d'entrée Input Mode Mode d'entrée - Install Plug-in Installer un module externe + Install Plug-in Installer une extension Integrated Density Densité intégrée Intensity Intensité Intent Intention de rendu @@ -3596,8 +3593,8 @@ curves Smooth Lisser Interval Intervalle Invalid panel layout; restored the default dock Disposition des panneaux non valide ; l'ancrage par défaut a été rétabli Invert Mask Inverser le masque - Invert Selection Intervertir la sélection - Iris Blur Flou de diaphragme + Invert Selection Inverser la sélection + Iris Blur Flou d'iris Ivy Lierre Jag Dentelé Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft @@ -3612,7 +3609,7 @@ curves Smooth Lisser Layer Comps to Files Compositions de calques en fichiers Layer Content Options Options de contenu du calque Layer From Background Calque d'après l'arrière-plan - Layer Mask Hides Effects Le masque de fusion masque les effets + Layer Mask Hides Effects Le masque de calque masque les effets Layer Properties Propriétés du calque Layer Styles Styles de calque Layer from Background Calque d'après l'arrière-plan @@ -3625,9 +3622,9 @@ curves Smooth Lisser Left Diagonal Diagonale gauche Legacy Ancienne version Length Longueur - Lens Blur Flou de l'objectif + Lens Blur Flou d'objectif Lens Correction Correction de l'objectif - Lens Flare Halo + Lens Flare Reflet d'objectif Less Moins Level Based Selon les niveaux Library Bibliothèque @@ -3642,11 +3639,11 @@ curves Smooth Lisser Lighten Grout Éclaircir le joint Lighten Only Éclaircir seulement Lighting Éclairage - Lighting Effects Éclairage + Lighting Effects Effets d'éclairage Linear Dodge Densité linéaire − Linear-srgb sRVB linéaire Link Lier - Liquify Fluidité + Liquify Liquéfier List Liste List view Affichage en liste Live Preview Aperçu en direct @@ -3674,7 +3671,7 @@ curves Smooth Lisser Mask Incomplete Blocks Masquer les blocs incomplets Mask None Ne rien masquer Masks Masques - Match Color Correspondance de la couleur + Match Color Faire correspondre la couleur Math Symbols Symboles mathématiques Max Offset Décalage max. Max Radius Rayon max. @@ -3682,11 +3679,11 @@ curves Smooth Lisser Medium Gray Gris moyen Medium Lines Lignes moyennes Medium Strokes Traits moyens - Merge Channels… Fusionner les couches… - Merge Spot Channel Fusionner la couche de ton direct + Merge Channels… Fusionner les canaux… + Merge Spot Channel Fusionner le canal de ton direct Merge to HDR Pro Fusionner en HDR Pro Metallic Métallique - Mezzotint Mezzo-tinto + Mezzotint Manière noire Middle Milieu Midtone Contraste des tons moyens Monitor Profile Profil du moniteur @@ -3700,7 +3697,7 @@ curves Smooth Lisser More Plus Mosaic Mosaïque Mosaic Tiles Carreaux de mosaïque - Motion Blur Flou directionnel + Motion Blur Flou de mouvement Mouse Souris Move Layer Déplacer le calque Movie Prime Focale fixe cinéma @@ -3712,10 +3709,10 @@ curves Smooth Lisser Neon Glow Lueur néon Neutralize Neutraliser New Brush Preset Nouveau préréglage de pinceau - New Channel… Nouvelle couche… + New Channel… Nouveau canal… New Guide Nouveau repère New Guide Layout Nouvelle disposition de repères - New Spot Channel… Nouvelle couche de ton direct… + New Spot Channel… Nouveau canal de ton direct… New Video Layer from File Nouveau calque vidéo à partir d'un fichier New Window Nouvelle fenêtre New document Nouveau document @@ -3771,7 +3768,7 @@ curves Smooth Lisser Paper Brightness Luminosité du papier Parallel Parallèle Paste Complete Stroke Coller le contour complet - Paste Fill Coller le fond + Paste Fill Coller le remplissage Patchwork Patchwork Path Blur Flou de tracé Paths to Illustrator Tracés vers Illustrator @@ -3784,10 +3781,10 @@ curves Smooth Lisser PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continue de réessayer ; tant qu'un enregistrement n'a pas abouti, les modifications des préférences seront perdues à la fermeture. Photomerge Photomerge Picture Frame Cadre d'image - Pinch Contraction + Pinch Pincement Pins Épingles - Place Embedded Importer et incorporer - Place Linked Importer et lier + Place Embedded Placer incorporé + Place Linked Placer lié Place Scale Marker Placer un repère d'échelle Plane Plan Planes Plans @@ -3854,14 +3851,14 @@ curves Smooth Lisser Remove All Tout supprimer Remove Jpeg Artifact Supprimer l'artefact JPEG Rename Brush Renommer le pinceau - Rename Channel… Renommer la couche… + Rename Channel… Renommer le canal… Rename Group Renommer le groupe Rename Tool Preset Renommer le préréglage d'outil Render Video Rendu vidéo Rendering… Rendu en cours… Reorder Layer Réorganiser le calque Replace Remplacer - Replace Color Remplacement de couleur + Replace Color Remplacer la couleur Replace Contents Remplacer le contenu Replace Footage Remplacer le métrage Report a Bug Signaler un bogue @@ -3910,7 +3907,7 @@ curves Smooth Lisser Search menus Rechercher dans les menus Seed Valeur initiale Select Custom Color… Choisir une couleur personnalisée… - Select Inverse Intervertir la sélection + Select Inverse Inverser la sélection Select Layer Sélectionner le calque Select and Mask Sélectionner et masquer Selected at launch: {backend} Sélectionné au lancement : {backend} @@ -3921,12 +3918,12 @@ curves Smooth Lisser Set Colors Définir les couleurs Set Measurement Scale Définir l'échelle de mesure Sgray sGray - Shadow Tons foncés - Shadow Amount Quantité des tons foncés - Shadow Intensity Intensité des tons foncés - Shadow Radius Rayon des tons foncés - Shadow Tone Amplitude tonale des tons foncés - Shadows/Highlights Tons foncés/Tons clairs + Shadow Ombres + Shadow Amount Quantité des ombres + Shadow Intensity Intensité des ombres + Shadow Radius Rayon des ombres + Shadow Tone Amplitude tonale des ombres + Shadows/Highlights Ombres/Hautes lumières Shallow Superficiel Shape Blur Flou selon une forme Sharpen Amount Quantité de netteté @@ -3947,8 +3944,8 @@ curves Smooth Lisser Sketch Esquisse Skip Ignorer Slice Options Options de tranche - Smart Blur Flou optimisé - Smart Sharpen Netteté optimisée + Smart Blur Flou intelligent + Smart Sharpen Netteté intelligente Smoothness Lissage Smudge Stick Estompe Snap to Grid Aimanter à la grille @@ -3966,7 +3963,7 @@ curves Smooth Lisser Spherize Sphérisation Spin Rotation Spin Blur Flou de rotation - Split Channels Séparer les couches + Split Channels Séparer les canaux Spot Spot Spray Radius Rayon de pulvérisation Sprayed Strokes Traits pulvérisés @@ -4009,8 +4006,8 @@ curves Smooth Lisser Texture Coverage Couverture de la texture Texturizer Texturisation Thaw Mask Masque de libération - The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. La zone de travail est affichée en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. - The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. La zone de travail sur {display} est affichée en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas sur {display} est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. The current gradient (pick one in Window › Gradients) Le dégradé actuel (à choisir dans Fenêtre › Dégradés) The document does not have an embedded colour profile. Working space: {working}. Le document ne contient aucun profil colorimétrique incorporé. Espace de travail : {working}. The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Le document contient un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail.\nIncorporé : {embedded}\nEspace de travail : {working} @@ -4018,15 +4015,15 @@ curves Smooth Lisser The file could not be saved. Impossible d'enregistrer le fichier. Thickness Épaisseur This Layer: Ce calque : - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet dynamique doit être pixellisé pour continuer. Son contenu ne sera plus modifiable. Pixelliser l'objet dynamique ? - This adjustment ({key}) is kept as imported and can't be edited yet. Ce réglage ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. - This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être pixellisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Pixelliser le calque ? - This layer must be rasterized before proceeding. Ce calque doit être pixellisé pour continuer. + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? + This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. This session: {reason} Cette session : {reason} - This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être pixellisé pour continuer. Il ne sera plus modifiable en tant que forme. Pixelliser la forme ? - This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être pixellisé pour continuer. Son texte ne sera plus modifiable. Pixelliser le texte ? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être rastérisé pour continuer. Il ne sera plus modifiable en tant que forme. Rastériser la forme ? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être rastérisé pour continuer. Son texte ne sera plus modifiable. Rastériser le texte ? Tile Size Taille des carreaux - Tiles Carrelage + Tiles Carreaux Tilt-Shift Inclinaison-décalage Tint Color Couleur de la nuance Tiny Lens Petites lentilles @@ -4034,13 +4031,13 @@ curves Smooth Lisser To Center Vers le centre Toggle the Brush Settings panel Afficher/masquer le panneau Paramètres du pinceau Tool Options Options d'outil - Trace Contour Courbes de niveau + Trace Contour Tracer les contours Transformation Transformation Transition Transition Transparency Shapes Layer La transparence détermine la forme du calque Trap Recouvrement Tree Arbre - Trim Rognage + Trim Rogner les bords Turbulent Turbulent Twirl Tourbillon Twirl Clockwise Tourbillon horaire @@ -4057,7 +4054,7 @@ curves Smooth Lisser Unlock Déverrouiller Unlocked Déverrouillé Unsaved Changes Modifications non enregistrées - Unsharp Mask Accentuation + Unsharp Mask Masque flou Unsupported file format Format de fichier non pris en charge Unsupported format Format non pris en charge Up Haut @@ -4075,8 +4072,8 @@ curves Smooth Lisser Vertical Type Tool Outil Texte vertical Video Frames to Layers Images vidéo dans des calques Vignette Amount Quantité de vignettage - Vignette Feather Contour progressif du vignettage - Vignette Highlights Tons clairs du vignettage + Vignette Feather Adoucissement du vignettage + Vignette Highlights Hautes lumières du vignettage Vignette Midpoint Point médian du vignettage Vignette Roundness Arrondi du vignettage Vignette Style Style du vignettage @@ -4102,7 +4099,7 @@ curves Smooth Lisser White Is High Le blanc est le plus haut Wide Blurry Large et flou Wide Sharp Large et net - Wind Soufflerie + Wind Vent Window renderer: {renderer} Moteur de rendu de la fenêtre : {renderer} Working Espace de travail Working Cmyk CMJN de travail @@ -4119,7 +4116,7 @@ curves Smooth Lisser gradients dégradés gray gris green vert - layer mask masque de fusion + layer mask masque de calque manual manuel none aucun off désactivé @@ -4141,11 +4138,11 @@ curves Smooth Lisser ✓ Color Indicates Masked Areas ✓ La couleur indique les zones masquées ✓ Color Indicates Selected Areas ✓ La couleur indique les zones sélectionnées @id edit.clear Effacer -@id edit.stroke Contour… -@id edit.transform.scale Homothétie +@id edit.stroke Contourner… +@id edit.transform.scale Mise à l'échelle @id file.close Fermer -@id filter.sharpen.sharpen Plus net -@id layer.layerMask.apply Appliquer le masque de fusion +@id filter.sharpen.sharpen Netteté +@id layer.layerMask.apply Appliquer le masque de calque @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} nuance exportée vers {file}|{n} nuances exportées vers {file} @plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pinceau importé depuis {file}|{n} pinceaux importés depuis {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group} @@ -4153,7 +4150,7 @@ curves Smooth Lisser @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} nuance importée depuis {file}|{n} nuances importées depuis {file} @plural Played {n} step|Played {n} steps {n} étape lue|{n} étapes lues @plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet dynamique lié modifié sur le disque|Mise à jour de {n} objets dynamiques liés modifiés sur le disque +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet intelligent lié modifié sur le disque|Mise à jour de {n} objets intelligents liés modifiés sur le disque @plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré par cette composition a été supprimé. Mettez la composition à jour ou effacez l'avertissement.|{n} calques enregistrés par cette composition ont été supprimés. Mettez la composition à jour ou effacez l'avertissement. @plural {n} plane|{n} planes {n} plan|{n} plans @plural {n} preset|{n} presets {n} préréglage|{n} préréglages @@ -4176,10 +4173,10 @@ cameraRaw Dehaze Correction de la brume cameraRaw Detail Détail cameraRaw Effects Effets cameraRaw Exposure Exposition -cameraRaw Feather Contour progressif +cameraRaw Feather Adoucissement cameraRaw Global Global cameraRaw Green Vert -cameraRaw Highlights Tons clairs +cameraRaw Highlights Hautes lumières cameraRaw Hue Teinte cameraRaw Luminance Luminance cameraRaw Luminance Detail Détail de la luminance @@ -4193,7 +4190,7 @@ cameraRaw Red Rouge cameraRaw Roughness Rugosité cameraRaw Roundness Arrondi cameraRaw Saturation Saturation -cameraRaw Shadows Tons foncés +cameraRaw Shadows Ombres cameraRaw Size Taille cameraRaw Temperature Température cameraRaw Texture Texture @@ -4205,7 +4202,7 @@ document Orientation Orientation layerLabel Magenta Magenta layerLabel No Color Aucune couleur layerLabel Red Rouge -layers Fill Fond -layers Fill: Fond : +layers Fill Remplissage +layers Fill: Remplissage : navigator Zoom Zoom shape Appearance Apparence From 3264c2fa6f3118ecbbaf4d9bef13383a7226ad21 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:08:10 -0700 Subject: [PATCH 0133/1452] fix(format): ignore accidental ZIP directory signatures in comments --- crates/format/src/zip.rs | 67 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 9ec92c2fba..5057e52168 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -127,11 +127,24 @@ impl<'a> ZipReader<'a> { if bytes.len() < 22 { return Err(FormatError::corrupt("not a zip archive (too short)")); } - // End of central directory: scan back over a possible comment. + // A comment may itself contain the signature. Only accept a record whose + // declared comment reaches the end of the archive (APPNOTE 4.3.16). let min = bytes.len().saturating_sub(22 + 65535); let eocd = (min..=bytes.len() - 22) .rev() - .find(|&i| u32_at(bytes, i).ok() == Some(EOCD_SIG)) + .find(|&i| { + if u32_at(bytes, i).ok() != Some(EOCD_SIG) { + return false; + } + let comment_end = u16_at(bytes, i + 20).ok().and_then(|length| i.checked_add(22)?.checked_add(usize::from(length))); + let directory_end = u32_at(bytes, i + 16) + .ok() + .zip(u32_at(bytes, i + 12).ok()) + .and_then(|(offset, size)| usize::try_from(offset).ok()?.checked_add(usize::try_from(size).ok()?)); + // In the supported non-ZIP64 layout, the central directory immediately + // precedes this record. A zeroed signature in a comment is not an empty ZIP. + comment_end == Some(bytes.len()) && directory_end == Some(i) + }) .ok_or_else(|| FormatError::corrupt("zip end-of-central-directory not found"))?; let count = u16_at(bytes, eocd + 10)? as usize; let cd_size = u32_at(bytes, eocd + 12)? as usize; @@ -263,6 +276,56 @@ mod tests { assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); } + fn archive_with_comment(comment: &[u8]) -> Vec { + let mut writer = ZipWriter::new(); + writer.add("manifest.json", b"project content").unwrap(); + let mut bytes = writer.finish().unwrap(); + let length_offset = bytes.len() - 2; + bytes[length_offset..].copy_from_slice(&u16::try_from(comment.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(comment); + bytes + } + + #[test] + fn directory_signature_inside_comment_does_not_hide_entries() { + for length in [22, 32, usize::from(u16::MAX)] { + let mut comment = vec![0; length]; + comment[..4].copy_from_slice(&EOCD_SIG.to_le_bytes()); + let bytes = archive_with_comment(&comment); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.entries.len(), 1, "comment length {length}"); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + } + + #[test] + fn archive_comments_support_empty_short_and_maximum_lengths() { + for length in [0, 1, 21, 22, 100, usize::from(u16::MAX)] { + let bytes = archive_with_comment(&vec![b'x'; length]); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content", "comment length {length}"); + } + } + + #[test] + fn central_directory_digital_signature_remains_supported() { + let mut bytes = archive_with_comment(b"signed archive"); + let eocd = bytes.len() - 22 - b"signed archive".len(); + let signature = [0x50, 0x4b, 0x05, 0x05, 3, 0, 1, 2, 3]; + let size = u32_at(&bytes, eocd + 12).unwrap() + signature.len() as u32; + bytes[eocd + 12..eocd + 16].copy_from_slice(&size.to_le_bytes()); + bytes.splice(eocd..eocd, signature); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + + #[test] + fn truncated_archive_comment_is_rejected() { + let mut bytes = archive_with_comment(b"comment"); + bytes.pop(); + assert!(ZipReader::new(&bytes).is_err()); + } + #[test] fn crc_matches_bitwise_reference() { // Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross From b768970d4dcf8d86c4008223f766b903368af62b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 15:43:48 -0400 Subject: [PATCH 0134/1452] Brush smoothing: the lagging brush keeps the pressure of where it is (#5713) Smoother::push moved the brush part of the way to the pointer but gave every point the pointer's newest pose (`..p`). On a fast stroke the brush trails far behind, so when the pen lifted, the trailing brush took the lift's near-zero pressure while still short of the end, and the end catch-up drew a straight hairline from there to the pointer: a thick stroke stepping down to a thin tail. The brush's pressure, tilt, rotation (the short way round) and wheel now move with its position, for exponential smoothing and the pulled string. The end catch-up then ramps from the brush's pressure to the pen's last, a taper instead of a step. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/paint/src/dynamics.rs | 25 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..7a5e4dec43 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -874,6 +874,38 @@ fn smoothing_without_catch_up_stops_while_the_pointer_pauses() { assert_eq!(out.len(), n, "held still: the brush doesn't move"); } +#[test] +fn a_smoothed_brush_keeps_the_pressure_of_where_it_is_when_the_pen_lifts() { + // #5713: a fast stroke at full pressure, lifted in the last sample. The brush trails far + // behind the pointer; it used to take the lift's pressure there, and the end catch-up drew + // a hairline from the trailing brush to the pointer. + let pts: Vec = + (0..=20).map(|i| StrokePoint { time: f64::from(i) * 4.0, ..StrokePoint::new(f64::from(i) * 10.0, 0.0, if i < 20 { 1.0 } else { 0.05 }) }).collect(); + let out = smooth_points(&Smoothing { amount: 0.8, ..Default::default() }, 1.0, &pts); + let (last, rest) = out.split_last().unwrap(); + assert_eq!((last.x, last.pressure), (200.0, 0.05), "the catch-up still ends at the pen's last sample"); + let lifted = rest.last().unwrap(); + assert!(lifted.x < 190.0, "the brush lags the pointer: {}", lifted.x); + for p in rest { + assert!(p.pressure > 0.5, "the trailing brush at x {} has pressure {}", p.x, p.pressure); + } +} + +#[test] +fn a_pulled_string_brush_takes_the_pose_along_the_string() { + let ps = Smoothing { amount: 0.2, pulled_string: true, catch_up_on_end: false, ..Default::default() }; + let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(0.0, 0.0, 1.0) }; + let b = StrokePoint { rotation: 10.0, tilt_x: 40.0, ..StrokePoint::new(100.0, 0.0, 0.0) }; + let out = smooth_points(&ps, 1.0, &[a, b]); + // The 20 px string leaves the brush 80 % of the way to the pointer. + let p = out.last().unwrap(); + assert!((p.x - 80.0).abs() < 1e-4, "{}", p.x); + assert!((p.pressure - 0.2).abs() < 1e-4, "{}", p.pressure); + assert!((p.tilt_x - 32.0).abs() < 1e-3, "{}", p.tilt_x); + // Rotation the short way round: 350° + 80 % of 20°. + assert!((p.rotation - 6.0).abs() < 1e-3, "{}", p.rotation); +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index ab846f1959..c0cad09460 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -46,6 +46,27 @@ fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint { // Smoothing // --------------------------------------------------------------------------- +/// The smoothed brush moved `f` of the way from `brush` towards the pointer `p`. Its pose +/// (pressure, tilt, rotation, wheel) moves with it, so the lagging brush paints with the pose the +/// pen had along the way, not the pointer's newest one. Taking the pointer's pressure made a fast +/// stroke's lift thin the brush while it still trailed far behind, and the end catch-up a hairline +/// (#5713). Time is the pointer's. +fn pull(brush: &StrokePoint, p: &StrokePoint, f: f64) -> StrokePoint { + let ff = f as f32; + // Barrel rotation takes the shorter way round. + let dr = (p.rotation - brush.rotation + 540.0).rem_euclid(360.0) - 180.0; + StrokePoint { + x: brush.x + (p.x - brush.x) * f, + y: brush.y + (p.y - brush.y) * f, + pressure: brush.pressure + (p.pressure - brush.pressure) * ff, + tilt_x: brush.tilt_x + (p.tilt_x - brush.tilt_x) * ff, + tilt_y: brush.tilt_y + (p.tilt_y - brush.tilt_y) * ff, + rotation: (brush.rotation + dr * ff).rem_euclid(360.0), + wheel: brush.wheel + (p.wheel - brush.wheel) * ff, + time: p.time, + } +} + /// Incremental stroke smoothing: exponential (with optional time-based catch-up) or pulled string. #[derive(Clone, Debug)] pub struct Smoother { @@ -87,7 +108,7 @@ impl Smoother { let d = (dx * dx + dy * dy).sqrt(); if d > len { let k = (d - len) / d; - let np = StrokePoint { x: pos.x + dx * k, y: pos.y + dy * k, ..p }; + let np = pull(&pos, &p, k); self.pos = Some(np); out.push(np); } @@ -113,7 +134,7 @@ impl Smoother { }; let mut cur = pos; for _ in 0..iters { - cur = StrokePoint { x: cur.x + (p.x - cur.x) * step, y: cur.y + (p.y - cur.y) * step, ..p }; + cur = pull(&cur, &p, step); out.push(cur); } self.pos = Some(cur); From 02b3f0125aa39cd96aaf3f1972e72427e3a576ba Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Wed, 14 Oct 2026 22:49:56 +0300 Subject: [PATCH 0135/1452] i18n(tr): comprehensive review, terminology refinement and UI consistency overhaul --- crates/ui-egui/src/i18n/tr.tsv | 1652 ++++++++++++++++---------------- 1 file changed, 826 insertions(+), 826 deletions(-) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 01c37a18b2..c1201f78f8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -16,7 +16,7 @@ Top: Üst: Left: Sol: Bottom: Alt: - Right: Doğru: + Right: Sağ: Target: Hedef: Selected Artboards Seçilen Çalışma Yüzeyleri Center Columns Merkezi Sütunlar @@ -43,18 +43,18 @@ Help Yardım (coming soon) (yakında) New… Yeni… - New from Clipboard Pano'dan yeni + New from Clipboard Panodan Yeni Open… Aç… Open As… Farklı Aç… - Open EXR Parts… EXR Parçalarını açın… - Add beauty precomp from the AOVs AOV'lerden güzellik ön kompozisyonu ekleyin - Both Her ikisi de + Open EXR Parts… EXR Parçalarını Aç… + Add beauty precomp from the AOVs AOV'lerden güzellik ön kompozisyonu ekle + Both Her İkisi Left Sol Right Sağ Open Recent Son Kullanılanları Aç Clear Recent Files Son Dosyaları Temizle - Remove from Recent En Son'dan kaldır - Remove {name} from Recent {name} öğesini En Son'dan kaldır + Remove from Recent Son Kullanılanlardan Kaldır + Remove {name} from Recent {name} öğesini Son Kullanılanlardan kaldır Close Kapat Minimize Küçült Maximize Büyüt @@ -73,36 +73,36 @@ Export Preferences… Dışa Aktarma Tercihleri… Save for Web (Legacy)… Web İçin Kaydet (Eski)… Artboards to Files… Çalışma Yüzeylerinden Dosyalara… - Artboards to PDF… Çalışma yüzeylerinden PDF'ye… + Artboards to PDF… Çalışma Yüzeylerinden PDF'ye… Layers to Files… Katmanlardan Dosyalara… - Layer Comps to Files… Dosyalara Katman Kompozisyonları… + Layer Comps to Files… Katman Kompozisyonlarından Dosyalara… Color Lookup Tables… Renk Arama Tabloları… - Data Sets as Files… Dosya Olarak Veri Kümeleri… - Paths to Illustrator… İllüstratöre Giden Yollar… + Data Sets as Files… Veri Kümelerini Dosya Olarak… + Paths to Illustrator… Yollardan Illustrator'a… Render Video… Videoyu Oluştur… Generate Oluştur - Image Assets Resim Varlıkları + Image Assets Görüntü Varlıkları Place Embedded… Gömülü Olarak Yerleştir… Place Linked… Bağlantılı Olarak Yerleştir… Package… Paketle… Automate Otomatikleştir Batch… Toplu… - Create Droplet… Damlacık oluştur… + Create Droplet… Damlacık Oluştur… Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt Contact Sheet II… İletişim Sayfası II… Conditional Mode Change… Koşullu Mod Değişikliği… - Fit Image… Resmi Sığdır… + Fit Image… Görüntüyü Sığdır… Lens Correction… Mercek Düzeltme… Merge to HDR Pro… HDR Pro'ya Birleştir… Photomerge… Fotoğraf Birleştir… - Scripts Komut dosyaları + Scripts Komut Dosyaları Image Processor… Görüntü İşlemcisi… Delete All Empty Layers Tüm Boş Katmanları Sil Flatten All Layer Effects Tüm Katman Efektlerini Düzleştir Flatten All Masks Tüm Maskeleri Düzleştir Script Events Manager… Komut Dosyası Olay Yöneticisi… - Import İthalat - Browse… Göz at… + Import İçe Aktar + Browse… Gözat… Source Files Kaynak Dosyalar Use: Kullan: Files Dosyalar @@ -120,7 +120,7 @@ Undo Geri Al Redo Yinele Toggle Last State Son Durumu Değiştir - Fade… Solmaya… + Fade… Soldur… Cut Kes Copy Kopyala Copy Merged Birleştirilmiş Olarak Kopyala @@ -138,15 +138,15 @@ No Spelling Suggestions Yazım Önerisi Yok Fill… Doldur… Stroke… Kontur… - Content-Aware Fill… İçeriğe Uygun Dolgu… + Content-Aware Fill… İçeriğe Duyarlı Dolgu… Delete and Fill Selection Seçimi Sil ve Doldur - Content-Aware Scale İçeriğe Uygun Ölçek - Puppet Warp Kukla Çözgü + Content-Aware Scale İçeriğe Duyarlı Ölçekleme + Puppet Warp Kukla Çarpıtma Perspective Warp Perspektif Çarpıtma Free Transform Serbest Dönüştürme - Free Transform a Copy Bir Kopyayı Ücretsiz Dönüştürme + Free Transform a Copy Bir Kopyayı Serbest Dönüştür Transform Dönüştür - Again Yinele + Again Tekrarla Transform Again on a Copy Bir Kopyada Yeniden Dönüştür Scale Ölçekle Rotate Döndür @@ -155,7 +155,7 @@ Skew Eğ Distort Çarpıt Perspective Perspektif - Warp Deforme Et + Warp Çarpıt Flip Horizontal Yatay Çevir Flip Vertical Dikey Çevir Rotate 180° 180° Döndür @@ -169,13 +169,13 @@ Auto-Blend Layers… Katmanları Otomatik Karıştır… Define Brush Preset… Fırça Ön Ayarını Tanımla… Define Pattern… Deseni Tanımla… - Define Custom Shape… Özel Şekil Tanımlayın… + Define Custom Shape… Özel Şekli Tanımla… Purge Temizle Clipboard Pano Histories Geçmiş Video Cache Video Önbelleği All Tümü - Presets Ön ayarlar + Presets Ön Ayarlar Preset Manager… Ön Ayar Yöneticisi… Migrate Presets Ön Ayarları Taşı Export/Import Presets… Ön Ayarları Dışa/İçe Aktar… @@ -192,17 +192,17 @@ Tools… Araçlar… History Log… Geçmiş Günlüğü… File Handling… Dosya İşleme… - Export… İhracat… + Export… Dışa Aktar… Performance… Performans… - Scratch Disks… Kazı Diskleri… + Scratch Disks… Karalama Diskleri… Cursors… İmleçler… - Transparency & Gamut… Şeffaflık ve Gamut… + Transparency & Gamut… Saydamlık ve Gamut… Units & Rulers… Birimler ve Cetveller… Guides, Grid & Slices… Kılavuzlar, Izgaralar ve Dilimler… Plug-ins… Eklentiler… - Type… Tür… + Type… Metin… Enhanced Controls… Gelişmiş Kontroller… - Camera Raw… Kamera Raw… + Camera Raw… Camera Raw… Raw Defaults… Ham Varsayılanlar… Integrations… Entegrasyonlar… AI Integrations… Yapay Zeka Entegrasyonları… @@ -210,14 +210,14 @@ Language Options Dil Seçenekleri Settings… Ayarlar… Mode Mod - Bitmap Bit eşlem - Grayscale Gri tonlamalı - Duotone Çift tonlu + Bitmap Bit Eşlem + Grayscale Gri Tonlamalı + Duotone Çift Tonlu Indexed Color İndekslenmiş Renk RGB Color RGB Rengi CMYK Color CMYK Rengi - Lab Color Laboratuvar Rengi - Multichannel Çok kanallı + Lab Color Lab Renk + Multichannel Çok Kanallı 8 Bits/Channel 8 Bit/Kanal 8 bit 8 bitlik Integer Tamsayı @@ -235,8 +235,8 @@ Levels… Düzeyler… Curves… Eğriler… Exposure… Pozlama… - Vibrance… Titreşim… - Hue/Saturation… Renk Tonu/Doygunluk… + Vibrance… Canlılık… + Hue/Saturation… Ton/Doygunluk… Color Balance… Renk Dengesi… Black & White… Siyah ve Beyaz… Photo Filter… Fotoğraf Filtresi… @@ -259,42 +259,42 @@ Image Size… Görüntü Boyutu… Canvas Size… Tuval Boyutu… Image Rotation Görüntü Döndürme - Flip Canvas Horizontal Kanvası Yatay Çevir - Flip Canvas Vertical Kanvası Dikey Çevir + Flip Canvas Horizontal Tuvali Yatay Çevir + Flip Canvas Vertical Tuvali Dikey Çevir Crop Kırp Trim… Kırp (Kenar)… Reveal All Tümünü Göster Duplicate… Çoğalt… - Apply Image… Resmi Uygula… + Apply Image… Görüntüyü Uygula… Calculations… Hesaplamalar… Variables Değişkenler Define… Tanımla… - Data Sets… Veri Setleri… + Data Sets… Veri Kümeleri… Apply Data Set… Veri Kümesini Uygula… - Variable Data Sets… Değişken Veri Setleri… + Variable Data Sets… Değişken Veri Kümeleri… Trap… Tuzak… Analysis Analiz Set Measurement Scale… Ölçüm Ölçeğini Ayarla… - Select Data Points… Veri Noktalarını Seçin… + Select Data Points… Veri Noktalarını Seç… Record Measurements Ölçümleri Kaydet Ruler Tool Cetvel Aracı Count Tool Sayma Aracı - Place Scale Marker… Ölçek İşaretçisini Yerleştirin… + Place Scale Marker… Ölçek İşaretçisini Yerleştir… Custom… Özel… New Yeni Layer… Katman… Group… Grup… Group from Layers… Katmanlardan Grup… - Artboard… Çalışma yüzeyi… - Artboard from Group… Gruptan çalışma yüzeyi… + Artboard… Çalışma Yüzeyi… + Artboard from Group… Gruptan Çalışma Yüzeyi… Artboard from Layers… Katmanlardan Çalışma Yüzeyi… - Layer via Copy Kopyalama yoluyla katman + Layer via Copy Kopyalama Yoluyla Katman Layer via Cut Kesim Yoluyla Katman Layer From Background… Arka Plandan Katman… Layer from Background… Arka Plandan Katman… Background From Layer Katmandan Arka Plan New Shape Layer Yeni Şekil Katmanı - New Type Layer Yeni Tip Katmanı + New Type Layer Yeni Metin Katmanı New Fill Layer Yeni Dolgu Katmanı New Adjustment Layer Yeni Ayarlama Katmanı New Layer Based Slice Yeni Katman Tabanlı Dilim @@ -309,47 +309,47 @@ Layer Style Katman Stili Blending Options… Karışım Seçenekleri… Bevel & Emboss… Eğim ve Kabartma… - Use Global Light Global Light'ı kullanın + Use Global Light Genel Işığı Kullan Inner Shadow… İç Gölge… Inner Glow… İç Işıma… Satin… Saten… Color Overlay… Renk Kaplama… - Gradient Overlay… Degrade Kaplama… - Pattern Overlay… Desen Kaplama… + Gradient Overlay… Degrade Kaplaması… + Pattern Overlay… Desen Kaplaması… Outer Glow… Dış Işıma… Drop Shadow… Alt Gölge… Copy Layer Style Katman Stilini Kopyala Paste Layer Style Katman Stilini Yapıştır Clear Layer Style Katman Stilini Temizle - Global Light… Küresel Işık… + Global Light… Genel Işık… Hide All Effects Tüm Efektleri Gizle - Scale Effects… Ölçek Etkileri… + Scale Effects… Efektleri Ölçekle… Smart Filter Akıllı Filtre - Convert for Smart Filters Akıllı Filtreler için Dönüştürme + Convert for Smart Filters Akıllı Filtreler için Dönüştür Clear Smart Filters Akıllı Filtreleri Temizle Disable Smart Filters Akıllı Filtreleri Devre Dışı Bırak Delete Filter Mask Filtre Maskesini Sil Disable Filter Mask Filtre Maskesini Devre Dışı Bırak Solid Color… Düz Renk… - Gradient… Gradyan… + Gradient… Degrade… Pattern… Desen… Layer Mask Katman Maskesi - Vector Mask vektör maskesi + Vector Mask Vektör Maskesi Reveal Selection Seçimi Göster Hide Selection Seçimi Gizle Hide All Tümünü Gizle - From Transparency Şeffaflıktan + From Transparency Saydamlıktan Delete Vector Mask Vektör Maskesini Sil Enable Layer Mask Katman Maskesini Etkinleştir Disable Layer Mask Katman Maskesini Devre Dışı Bırak Enable Vector Mask Vektör Maskesini Etkinleştir - Link Layer Mask Bağlantı Katmanı Maskesi + Link Layer Mask Katman Maskesini Bağla Unlink Layer Mask Katman Maskesinin Bağlantısını Kaldır - Link Vector Mask Bağlantı Vektör Maskesi + Link Vector Mask Vektör Maskesini Bağla Add Vector Mask Vektör Maskesi Ekle Vector Mask from Current Path Geçerli Yoldan Vektör Maskesi Create Clipping Mask Kırpma Maskesi Oluştur - Release Clipping Mask Kırpma Maskesini Serbest Bırakın + Release Clipping Mask Kırpma Maskesini Serbest Bırak Smart Objects Akıllı Nesneler Smart Object Akıllı Nesne Convert to Smart Object Akıllı Nesneye Dönüştür @@ -367,53 +367,53 @@ Entropy Entropi Kurtosis Basıklık Maximum Maksimum - Mean ortalama + Mean Ortalama Median Medyan - Minimum Asgari + Minimum Minimum Range Aralık - Skewness çarpıklık + Skewness Çarpıklık Standard Deviation Standart Sapma Summation Toplama Variance Varyans Video Layers Video Katmanları Interpret Footage… Görüntüyü Yorumla… Replace Footage… Görüntüyü Değiştir… - Reload Frame Çerçeveyi Yeniden Yükle + Reload Frame Kareyi Yeniden Yükle Show Altered Video Değiştirilen Videoyu Göster - Restore Frame Çerçeveyi Geri Yükle - Restore All Frames Tüm Çerçeveleri Geri Yükle - Insert Blank Frame Boş Çerçeve Ekle - Delete Frame Çerçeveyi Sil - Duplicate Frame Çerçeveyi Çoğalt - Frame from Layers Katmanlardan Çerçeve + Restore Frame Kareyi Geri Yükle + Restore All Frames Tüm Kareleri Geri Yükle + Insert Blank Frame Boş Kare Ekle + Delete Frame Kareyi Sil + Duplicate Frame Kareyi Çoğalt + Frame from Layers Katmanlardan Kare Video Frames to Layers… Video Karelerinden Katmanlara… Rasterize Rasterleştir - Rasterize Type Rasterleştirme Türü - Rasterize Type Layer Tip Katmanını Rasterleştir + Rasterize Type Metni Rasterleştir + Rasterize Type Layer Metin Katmanını Rasterleştir Rasterize Shape Şekli Rasterleştir New Guide Layout… Yeni Kılavuz Düzeni… - New Guides From Shape Shape'den Yeni Kılavuzlar + New Guides From Shape Şekilden Yeni Kılavuzlar Lock Layers… Katmanları Kilitle… Link Layers Katmanları Bağla - Select Linked Layers Bağlantılı Katmanları Seçin + Select Linked Layers Bağlantılı Katmanları Seç Merge Layers Katmanları Birleştir Merge Down Aşağı Birleştir Merge Visible Görünürleri Birleştir - Stamp Visible Damga Görünür - Select Layer Above Yukarıdaki Katmanı Seçin - Select Layer Below Aşağıdaki Katmanı Seçin - Select Top Layer Üst Katmanı Seçin - Select Bottom Layer Alt Katmanı Seçin + Stamp Visible Görünür Katmanları Damgala + Select Layer Above Yukarıdaki Katmanı Seç + Select Layer Below Aşağıdaki Katmanı Seç + Select Top Layer Üst Katmanı Seç + Select Bottom Layer Alt Katmanı Seç Add Layer Above to Selection Seçime Yukarıdaki Katmanı Ekle Add Layer Below to Selection Aşağıdaki Katmanı Seçime Ekle - Stamp Down Damgalama + Stamp Down Aşağıya Damgala Flatten Image Görüntüyü Düzleştir - Matting Paspas + Matting Matlama Color Decontaminate… Renkli Dekontamine… Remove Black Matte Siyah Matı Kaldır Remove White Matte Beyaz Matı Kaldır - Defringe… Borcu kaldır… - Group Layers Grup Katmanları + Defringe… Kenar Halesini Kaldır… + Group Layers Katmanları Gruplandır Ungroup Layers Katmanların Grubunu Çöz Hide Layers Katmanları Gizle Arrange Sırala @@ -423,9 +423,9 @@ Send to Back En Arkaya Gönder Reverse Ters Combine Shapes Şekilleri Birleştir - Unite Shapes Şekilleri Birleştir - Subtract Front Shape Ön Şekli Çıkart - Intersect Shape Areas Şekil Alanlarını Kesiş + Unite Shapes Şekil Alanlarını Birleştir + Subtract Front Shape Ön Şekli Çıkar + Intersect Shape Areas Şekil Alanlarını Kesiştir Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir Align Hizala @@ -436,7 +436,7 @@ Horizontal Centers Yatay Merkezler Right Edges Sağ Kenarlar Distribute Dağıt - Vertically Dikey olarak + Vertically Dikey Olarak Horizontally Yatay olarak Layer Edges Katman Kenarları Isolate Layers Katmanları Yalıt @@ -462,7 +462,7 @@ Crisp Çıtır Strong Güçlü Smooth Pürüzsüz - Windows LCD Windows LCD'si + Windows LCD Windows LCD Windows Windows Orientation Oryantasyon Horizontal Yatay @@ -471,25 +471,25 @@ Standard Ligatures Standart Bitişik Harfler Contextual Alternates Bağlamsal Alternatifler Discretionary Ligatures İsteğe Bağlı Bitişik Harfler - Swash çalkalama - Oldstyle Eski tarz + Swash Süslü Harfler + Oldstyle Eski Stil Stylistic Alternates Stilistik Alternatifler Titling Alternates Başlık Alternatifleri Ornaments Süsler Ordinals Sıra Sayıları Fractions Kesirler - Create Work Path İş Yolu Oluştur + Create Work Path Çalışma Yolu Oluştur Convert to Shape Şekle Dönüştür - Warp Text… Metni Çözgü… + Warp Text… Metni Çarpıt… Convert to Point Text Nokta Metnine Dönüştür Convert to Paragraph Text Paragraf Metnine Dönüştür Default Features Varsayılan Özellikler East Asian Features Doğu Asya Özellikleri Middle Eastern Features Orta Doğu Özellikleri - Middle Eastern & South Asian Composer Orta Doğulu ve Güney Asyalı Besteci + Middle Eastern & South Asian Composer Orta Doğu ve Güney Asya Dizgi Motoru Update All Text Layers Tüm Metin Katmanlarını Güncelle Replace All Missing Fonts Tüm Eksik Yazı Tiplerini Değiştir - Resolve Missing Fonts… Eksik Yazı Tiplerini Çözün… + Resolve Missing Fonts… Eksik Yazı Tiplerini Çöz… Font Preview Size Yazı Tipi Önizleme Boyutu None Yok Small Küçük @@ -497,8 +497,8 @@ Large Büyük Extra Large Ekstra Büyük Huge Devasa - Load Default Type Styles Varsayılan Yazım Stillerini Yükle - Save Default Type Styles Varsayılan Yazım Stillerini Kaydet + Load Default Type Styles Varsayılan Metin Stillerini Yükle + Save Default Type Styles Varsayılan Metin Stillerini Kaydet Deselect Seçimi Kaldır Reselect Yeniden Seç Inverse Ters Çevir @@ -510,7 +510,7 @@ Smart Radius Akıllı Yarıçap Shift Edge Kenarı Kaydır Decontaminate Dekontaminasyon - Refine Edge Kenarı İyileştir + Refine Edge Kenarı Hassaslaştır Modify Değiştir Border… Kenarlık… Smooth… Düzelt… @@ -524,7 +524,7 @@ Save Selection… Seçimi Kaydet… Match All Tümünü Eşleştir Last Filter Son Filtre - Filter Gallery… Galeriyi Filtrele… + Filter Gallery… Filtre Galerisi… Adaptive Wide Angle… Uyarlanabilir Geniş Açı… Camera Raw Filter… Camera Raw Filtresi… Liquify… Sıvılaştır… @@ -543,34 +543,34 @@ Gaussian Blur… Gauss Bulanıklığı… Lens Blur… Mercek Bulanıklığı… Motion Blur… Hareket Bulanıklığı… - Radial Blur… Radyal Bulanıklık… + Radial Blur… Radyal Bulanıklığı… Shape Blur… Şekil Bulanıklığı… Smart Blur… Akıllı Bulanıklaştırma… Surface Blur… Yüzey Bulanıklığı… Average Ortalama - Displace… Yer değiştir… - Pinch… Tut… + Displace… Yer Değiştir… + Pinch… Sıkıştır… Polar Coordinates… Kutupsal Koordinatlar… Ripple… Dalgalanma… Shear… Kesme… Spherize… Küreselleştir… - Twirl… Döndür… + Twirl… Girdap… Wave… Dalga… - ZigZag… ZigZag… + ZigZag… Zikzak… Noise Gürültü Add Noise… Gürültü Ekle… - Despeckle Benek giderme + Despeckle Benekleri Gider Dust & Scratches… Toz ve Çizikler… Median… Medyan… - Reduce Noise… Gürültüyü Azaltın… + Reduce Noise… Gürültüyü Azalt… Pixelate Pikselleştir Color Halftone… Renk Yarım Tonu… - Crystallize… Kristalleşin… + Crystallize… Kristalleştir… Facet Faset Fragment Parça Mezzotint… Mezzotint… Mosaic… Mozaik… - Pointillize… Noktalaştırın… + Pointillize… Noktalaştır… Render İşleme Clouds Bulutlar Difference Clouds Fark Bulutları @@ -578,17 +578,17 @@ Flame… Alev… Lens Flare… Mercek Parlaması… Lighting Effects… Aydınlatma Efektleri… - Relight… Yeniden aydınlat… + Relight… Yeniden Aydınlat… Picture Frame… Resim Çerçevesi… Tree… Ağaç… Sharpen Keskinleştir - Sharpen Edges Kenarları Keskinleştirin + Sharpen Edges Kenarları Keskinleştir Sharpen More Daha Fazla Keskinleştir Smart Sharpen… Akıllı Keskinleştirme… Unsharp Mask… Keskinliği Azaltma Maskesi… High Pass… Yüksek Geçiş… Stylize Stilize Et - Diffuse… Yaygın… + Diffuse… Yay… Emboss… Kabartma… Extrude… Ekstrüzyon… Find Edges Kenarları Bul @@ -596,29 +596,29 @@ Solarize Solarizasyon Tiles… Fayans… Trace Contour… Konturu İzle… - Wind… Rüzgar… - Video video + Wind… Rüzgâr… + Video Video De-Interlace… Taramasızlaştırma… NTSC Colors NTSC Renkleri Other Diğer Maximum… Maksimum… - Minimum… Asgari… - Offset… Ofset… + Minimum… Minimum… + Offset… Kaydırma… Plug-ins Eklentiler Proof Setup Prova Kurulumu Proof Colors Prova Renkleri Gamut Warning Gamut Uyarısı - Working CMYK CMYK'de çalışıyor - Working Cyan Plate Çalışan Camgöbeği Plaka - Working Magenta Plate Çalışan Macenta Plaka - Working Yellow Plate Sarı Plaka Çalışması - Working Black Plate Siyah Plaka Çalışma - Working CMY Plate Çalışan CMY Plakası + Working CMYK Çalışma CMYK'si + Working Cyan Plate Çalışma Camgöbeği Plakası + Working Magenta Plate Çalışma Macenta Plakası + Working Yellow Plate Çalışma Sarı Plakası + Working Black Plate Çalışma Siyah Plakası + Working CMY Plate Çalışma CMY Plakası Legacy Macintosh RGB Eski Macintosh RGB Internet Standard RGB İnternet Standardı RGB - Monitor RGB RGB'yi izleyin + Monitor RGB Monitör RGB Color Blindness — Protanopia-type Renk Körlüğü — Protanopi tipi - Color Blindness — Deuteranopia-type Renk Körlüğü – Döteranopi tipi + Color Blindness — Deuteranopia-type Renk Körlüğü — Döteranopi tipi Zoom In Yakınlaştır Zoom Out Uzaklaştır Fit on Screen Ekrana Sığdır @@ -632,7 +632,7 @@ Custom Pixel Aspect Ratio… Özel Piksel En Boy Oranı… Square Kare D1/DV NTSC (0.91) D1/DV NTSC (0,91) - D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1.21) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1,21) D1/DV PAL (1.09) D1/DV PAL (1,09) D1/DV PAL Widescreen (1.46) D1/DV PAL Geniş Ekran (1,46) Anamorphic 2:1 (2) Anamorfik 2:1 (2) @@ -643,7 +643,7 @@ Standard Screen Mode Standart Ekran Modu Full Screen Mode With Menu Bar Menü Çubuklu Tam Ekran Modu Full Screen Mode Tam Ekran Modu - Cycle Screen Mode Döngü Ekranı Modu + Cycle Screen Mode Ekran Modları Arasında Geç Show Göster Extras Ekstralar Selection Edges Seçim Kenarları @@ -651,7 +651,7 @@ Current Path Geçerli Yol Grid Izgara Guides Kılavuzlar - Canvas Guides Kanvas Kılavuzları + Canvas Guides Tuval Kılavuzları Artboard Guides Çalışma Yüzeyi Kılavuzları Count Sayım Smart Guides Akıllı Kılavuzlar @@ -662,8 +662,8 @@ Pixel Art Preview Piksel Sanat Önizlemesi Pattern Preview Desen Önizlemesi Brush Preview Fırça Önizlemesi - Mesh ağ - Edit Pins Pinleri Düzenle + Mesh Ağ + Edit Pins Pimleri Düzenle Document Bounds Belge Sınırları Show Extras Options… Ekstra Seçeneklerini Göster… Rulers Cetveller @@ -672,15 +672,15 @@ Lock Guides Kılavuzları Kilitle Clear Guides Kılavuzları Temizle Clear Selected Artboard Guides Seçilen Çalışma Yüzeyi Kılavuzlarını Temizle - Clear Canvas Guides Şeffaf Kanvas Kılavuzları + Clear Canvas Guides Tuval Kılavuzlarını Temizle New Guide… Yeni Kılavuz… Lock Slices Dilimleri Kilitle Clear Slices Dilimleri Temizle Match Zoom Yakınlaştırmayı Eşleştir - Match Location Eşleşme Konumu - Match Rotation Maç Rotasyonu - Cascade Çağlayan - Tile Fayans + Match Location Konumu Eşleştir + Match Rotation Döndürmeyi Eşleştir + Cascade Basamakla + Tile Döşe Tile All Horizontally Tümünü Yatay Olarak Döşe Tile All Vertically Tümünü Dikey Olarak Döşe 2-up Horizontal 2'li Yatay @@ -691,11 +691,11 @@ 4-up 4'lü 6-up 6'lı Consolidate All to Tabs Tümünü Sekmelerde Birleştir - Float in Window Pencerede Kaydır - Float All in Windows Tümünü Windows'ta Kaydır + Float in Window Pencerede Yüzdür + Float All in Windows Tümünü Pencerelerde Yüzdür New Window for Document Belge için Yeni Pencere Workspace Çalışma alanı - Essentials Temel bilgiler + Essentials Temel Öğeler Graphic and Web Grafik ve Web Painting Boyama Photography Fotoğrafçılık @@ -707,27 +707,27 @@ Reset all tools to their default settings? Tüm araçlar varsayılan ayarlarına sıfırlansın mı? Lock Workspace Çalışma Alanını Kilitle Theme Tema - Pro Theme Profesyonel Tema + Pro Theme Pro Tema Pro Medium Gray Theme Pro Orta Gri Tema Pro Light Gray Theme Pro Açık Gri Tema - Pro Light Theme Profesyonel Işık Teması + Pro Light Theme Pro Açık Tema Studio Theme Stüdyo Teması - Studio Light Theme Stüdyo Işık Teması + Studio Light Theme Stüdyo Açık Tema Classic Theme Klasik Tema - Solarized dark Solarize karanlık - Solarized Dark Theme Solarize Karanlık Tema - Breeze dark Esinti karanlık - Breeze Dark Theme Esinti Koyu Tema - Breeze light Esinti ışığı - Breeze Light Theme Esinti Işık Teması + Solarized dark Solarized koyu + Solarized Dark Theme Solarized Koyu Tema + Breeze dark Breeze koyu + Breeze Dark Theme Breeze Koyu Tema + Breeze light Breeze açık + Breeze Light Theme Breeze Açık Tema Soft dark Yumuşak koyu Soft light Yumuşak açık Soft Dark Theme Yumuşak koyu tema Soft Light Theme Yumuşak açık tema - Adwaita Light Theme Adwaita Işık Teması - Adwaita Dark Theme Adwaita Karanlık Tema + Adwaita Light Theme Adwaita Açık Tema + Adwaita Dark Theme Adwaita Koyu Tema Adwaita Adwaita - Adwaita dark Adwaita karanlık + Adwaita dark Adwaita koyu Next Theme Sonraki Tema Actions Eylemler Brush Settings Fırça Ayarları @@ -740,7 +740,7 @@ Glyphs Glifler Gradients Degradeler Histogram Histogram - History Tarih + History Geçmiş Info Bilgi Measurement Log Ölçüm Günlüğü Modifier Keys Değiştirici Tuşlar @@ -754,15 +754,15 @@ Shapes Şekiller Styles Stiller Swatches Renk Örnekleri - Timeline Zaman çizelgesi + Timeline Zaman Çizelgesi Tool Presets Araç Ön Ayarları Tools Araçlar About PhotoCraft PhotoCraft Hakkında PhotoCraft Website PhotoCraft Web Sitesi PhotoCraft on GitHub GitHub'da PhotoCraft ArtCraft Website ArtCraft Web Sitesi - Join the ArtCraft Discord… ArtCraft Discord'una katılın… - Report an Issue… Bir Sorun Bildirin… + Join the ArtCraft Discord… ArtCraft Discord'una Katıl… + Report an Issue… Sorun Bildir… WIA Support… WIA Desteği… Apply Uygula HSB/HSL HSB/HSL @@ -771,16 +771,16 @@ Motion Hareket Notes… Notlar… Other… Diğer… - Remove Warp Split Warp Split'i Kaldır + Remove Warp Split Çarpıtma Bölünmesini Kaldır Shape Şekil Split Warp Crosswise Çözgüyü Çapraz Olarak Böl Split Warp Horizontally Çözgüyü Yatay Olarak Böl Split Warp Vertically Çözgüyü Dikey Olarak Böl Statistics… İstatistikler… - Refine Strokes Konturları İyileştir + Refine Strokes Fırça Vuruşlarını Hassaslaştır @plural {n} item|{n} items {n} öğe Auto Otomatik - (print to the printer) (yazıcıya yazdırın) + (print to the printer) (yazıcıya yazdır) + Add variable + Değişken ekle 30 frames @ 30 fps 30 kare @ 30 fps 3D LUT File 3D LUT Dosyası @@ -792,34 +792,34 @@ Add Ekle Add Layer Mask Katman Maskesi Ekle Add Stop Durak Ekle - Add a layer style Katman stili ekleme + Add a layer style Katman stili ekle Add a mask (from the selection; {key} inverts) Bir maske ekleyin (seçimden; {key} tersine çevirir) - Add an adjustment Düzenleme ekle + Add an adjustment Ayarlama ekle Add disk Disk ekle Add space after paragraph Paragraftan sonra boşluk ekle - Add space before paragraph Paragraftan önce boşluk ekleyin + Add space before paragraph Paragraftan önce boşluk ekle Add to Sample Örneğe Ekle Add to selection ({key}) Seçime ekle ({key}) Adjust for Zoom Yakınlaştırma için Ayarlama - Adjustment visible Ayar görünür + Adjustment visible Ayarlama görünür Adobe RGB (1998) compatible Adobe RGB (1998) uyumlu Align and Distribute Hizala ve Dağıt Align and distribute Hizala ve dağıt - Align horizontal centers Yatay merkezleri hizalayın + Align horizontal centers Yatay merkezleri hizala Align left edges Sol kenarları hizala - Align right edges Sağ kenarları hizalayın + Align right edges Sağ kenarları hizala Aligned Hizalı All Caps Tamamı Büyük Harf All Slices Tüm Dilimler All User Slices Tüm Kullanıcı Dilimleri All shortcuts reset to PhotoCraft defaults. Tüm kısayollar PhotoCraft varsayılanlarına sıfırlanır. - Alt Alternatif - Always use pressure for opacity Opaklık için her zaman baskıyı kullanın - Always use pressure for size Her zaman boyuta göre baskı kullanın + Alt Alt + Always use pressure for opacity Opaklık için her zaman basıncı kullan + Always use pressure for size Boyut için her zaman basıncı kullan Amount Miktar - Anchor: Çapa: + Anchor: Çapa noktası: Angle Açı - Angle Gradient Açı Gradyanı + Angle Gradient Açısal Degrade Angle Jitter Açı Değişimi Answer the unsaved-changes prompt first Önce kaydedilmemiş değişiklikler istemini yanıtlayın Anti-alias Kenar yumuşatma @@ -829,24 +829,24 @@ Add Mask to Selection Maskeyi Seçime Ekle Subtract Mask from Selection Maskeyi Seçimden Çıkar Intersect Mask with Selection Maskeyi Seçimle Kesiştir - Apply Per Tip İpucu Başına Uygula + Apply Per Tip Uç Başına Uygula Apply next selected layer comp Sonraki seçilen katman kompozisyonunu uygula Apply previous selected layer comp Önceki seçilen katman kompozisyonunu uygula Artboard Çalışma yüzeyi Artboard Background Color Çalışma Yüzeyi Arka Plan Rengi - Assign working space Çalışma alanı atayın + Assign working space Çalışma renk uzayını ata Author: Yazar: Auto Hide Otomatik Gizle Auto-Select: Otomatik Seç: Automatically level near-horizontal line segments Yataya yakın çizgi bölümlerini otomatik olarak hizalayın - Automatically straighten and level Otomatik olarak düzeltin ve seviyelendirin - Automatically straighten near-vertical line segments Dikeye yakın çizgi bölümlerini otomatik olarak düzelt + Automatically straighten and level Otomatik olarak doğrultun ve hizalayın + Automatically straighten near-vertical line segments Dikeye yakın çizgi bölümlerini otomatik olarak doğrultun Background Arka plan Background Color Arka Plan Rengi Background Contents Arka Plan İçeriği Custom background color Özel Arka Plan Rengi - Balance Bakiye - Baseline Temel + Balance Denge + Baseline Taban Çizgisi Baseline shift Taban çizgisi kayması Basic Temel Before (Y) Önce (Y) @@ -858,7 +858,7 @@ Bicubic Sharper (reduction) Bikübik Daha Keskin (azaltma) Bilinear Çift doğrusal Black Siyah - Black & White Siyah Beyaz + Black & White Siyah ve Beyaz Black Point Compensation Kara Nokta Telafisi Black, White Siyah, Beyaz Blacks Siyahlar @@ -869,9 +869,9 @@ Both Axes Her İki Eksen Bottom Alt Brightness Parlaklık - Brightness Jitter Parlaklık Değişimi + Brightness Jitter Parlaklık Titreşimi Brightness/Contrast Parlaklık/Kontrast - Bring the selected pin forward Seçilen pini öne getir + Bring the selected pin forward Seçilen pimi öne getir Brush Preset picker Fırça Ön Ayarı seçici Brush Reconstruct Options Fırçayla Yeniden Yapılandırma Seçenekleri Brush Tool Options Fırça Aracı Seçenekleri @@ -881,15 +881,15 @@ Reset Sıfırla Cancel (Esc) İptal (Esc) Manage submitted jobs in the system print queue Gönderilen işleri sistem yazdırma kuyruğunda yönetin - Cancel any current edits (Esc) Mevcut düzenlemeleri iptal et (Esc) + Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc) Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc) Cancel transform (Esc) Dönüşümü iptal et (Esc) Cancel warp (Esc) Çözgüyü iptal et (Esc) - Canvas Kanvas - Canvas extension color: Kanvas uzatma rengi: + Canvas Tuval + Canvas extension color: Tuval uzatma rengi: Capture Yakala Capture the visibility, position and style of every layer with + below, then switch between versions. Aşağıdaki + ile her katmanın görünürlüğünü, konumunu ve stilini yakalayın, ardından sürümler arasında geçiş yapın. - Catch-up on Stroke End Strok Sonunda Yakalama + Catch-up on Stroke End Vuruş Sonunda Yakalama Center Merkez Center Crop Marks Orta Kırpma İşaretleri Center text Metni ortala @@ -897,33 +897,33 @@ Change All Tümünü Değiştir Change Screen Mode (F) Ekran Modunu Değiştir (F) Change To: Şuna Değiştir: - Channel Mixer Kanal Mikseri + Channel Mixer Kanal Karıştırıcı Channel: Kanal: - Check All Layers Tüm Katmanları Kontrol Et - Check Spelling Yazımı Kontrol Et + Check All Layers Tüm Katmanları Denetle + Check Spelling Yazımı Denetle Clarity Netlik Clear All Tümünü Temizle Clear Override Geçersiz Kılmayı Temizle Clear Samples Örnekleri Temizle - Click sets foreground · right-click sets background Ön planı ayarlar'a tıklayın · arka planı sağ tıklayın + Click sets foreground · right-click sets background Tıklama ön planı, sağ tıklama arka planı ayarlar Click the first point or press {key} to close · Esc cancels Kapatmak için ilk noktayı tıklayın veya {key} tuşuna basın · Esc iptal eder - Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · sürükleyin veya {key} tuşuna basıp tıklayın veya kaldırmak için Sil + Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · kaldırmak için dışarı sürükleyin, {key}-tıklayın veya Delete'e basın Click to apply, {key}-click to clear overrides, double-click for Style Options Uygulamak için tıklayın, geçersiz kılmaları temizlemek için {key}-tıklayın, Stil Seçenekleri için çift tıklayın Click to edit the gradient Degradeyi düzenlemek için tıklayın - Click to restore the current colour Mevcut rengi geri yüklemek için tıklayın - Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · {key}-uzaklaştırmak için tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin + Click to restore the current colour Geçerli rengi geri yüklemek için tıklayın + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · uzaklaştırmak için {key}-tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin Click with the Note tool to add a note. Not eklemek için Not aracıyla tıklayın. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · İlk noktaya tıklayın: kapat · {key} bitiş · Esc iptal Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · {alt}-sürükleyin: tutamacı kırın · İlk noktayı tıklayın veya sürükleyin: kapat · {key} bitiş · Esc iptal - Clip to layer below Aşağıdaki katmana klip + Clip to layer below Aşağıdaki katmana kırp Clipped Kırpılmış Close Style Options Stil Seçeneklerini Kapat - Cmd Komut + Cmd Cmd Coated CMYK Kaplamalı CMYK Color & Swatches Renk ve Renk Örnekleri Color Balance Renk Dengesi - Color Detail Renk Detayı - Color Grading Renk Derecelendirmesi + Color Detail Renk Ayrıntısı + Color Grading Renk Derecelendirme Color Lookup Renk Arama Color Management Renk Yönetimi Color Mixer Renk Karıştırıcı @@ -933,20 +933,20 @@ Color: Renk: Colorize Renklendir Colors Renkler - Commit Perspective Warp ({key}) Perspektif Çarpıtma İşlemi ({key}) - Commit Puppet Warp ({key}) Kukla Çarpıtma İşlemi ({key}) - Commit any current edits ({key}) Mevcut düzenlemeleri tamamlayın ({key}) + Commit Perspective Warp ({key}) Perspektif Çarpıtmayı Uygula ({key}) + Commit Puppet Warp ({key}) Kukla Çarpıtmayı Uygula ({key}) + Commit any current edits ({key}) Geçerli düzenlemeleri tamamlayın ({key}) Commit current crop operation ({key}) Geçerli kırpma işlemini gerçekleştir ({key}) Commit transform ({key}) Dönüşümü kaydet ({key}) - Commit warp ({key}) Çözgü gerçekleştir ({key}) + Commit warp ({key}) Çarpıtmayı onayla ({key}) Constant Sabit - Constrain proportions Oranları sınırla + Constrain proportions Oranları koru Contact Sheet II İletişim Sayfası II Content-Aware İçeriğe Duyarlı Contiguous Bitişik Contrast Kontrast Control: Kontrol: - Convert document's colors to the working space Belgenin renklerini çalışma alanına dönüştürün + Convert document's colors to the working space Belgenin renklerini çalışma renk uzayına dönüştürün Convert to sRGB sRGB'ye dönüştür Copies Kopyalar Copper Bakır @@ -955,18 +955,18 @@ Corner Crop Marks Köşe Kırpma İşaretleri Corner radius Köşe yarıçapı Correction Düzeltme - Count Jitter Titreşimi say + Count Jitter Sayım Titreşimi Create Oluştur Create Droplet Damlacık Oluştur Create Texture Doku Oluştur - Create Video Timeline Video Zaman Çizelgesi Oluşturun + Create Video Timeline Video Zaman Çizelgesi Oluştur Create a new count group Yeni bir sayım grubu oluşturun Create a new document or open an existing file. Yeni bir belge oluşturun veya mevcut bir dosyayı açın. Create a new group Yeni bir grup oluştur Create a new layer Yeni bir katman oluştur Create new action Yeni eylem oluştur Create new channel Yeni kanal oluştur - Create new document from current state Mevcut durumdan yeni belge oluştur + Create new document from current state Geçerli durumdan yeni belge oluştur Create new fill or adjustment layer Yeni dolgu veya ayarlama katmanı oluştur Create new gradient Yeni degrade oluştur Create new group Yeni grup oluştur @@ -974,14 +974,14 @@ Create new pattern from the selection Seçimden yeni desen oluştur Create new shape from the current path Geçerli yoldan yeni şekil oluştur Create new snapshot Yeni anlık görüntü oluştur - Create new style (from the selected type) Yeni stil oluştur (seçilen türden) + Create new style (from the selected type) Yeni stil oluştur (seçilen metinden) Create new style from the selected layer Seçilen katmandan yeni stil oluştur Create new tool preset Yeni araç ön ayarı oluştur - Crop Factor Mahsul Faktörü + Crop Factor Kırpma Faktörü Ctrl Ctrl - Current & Below Güncel ve Aşağıda + Current & Below Geçerli ve Altındakiler Current Layer Geçerli Katman - Current Size Mevcut Boyut + Current Size Geçerli Boyut Current Tool Only Yalnızca Geçerli Araç Curve Eğri Curves Eğriler @@ -990,8 +990,8 @@ Cyan Camgöbeği Cyan · Red Camgöbeği · Kırmızı Darken Koyulaştır - Darks Karanlıklar - Data Sets Veri Setleri + Darks Koyu Tonlar + Data Sets Veri Kümeleri Default Varsayılan Default colours (D) Varsayılan renkler (D) Default printer Varsayılan yazıcı @@ -1007,8 +1007,8 @@ Delete Timeline Zaman Çizelgesini Sil Delete Tool Preset Araç Ön Ayarını Sil Delete count group Sayım grubunu sil - Delete current channel Mevcut kanalı sil - Delete current state Mevcut durumu sil + Delete current channel Geçerli kanalı sil + Delete current state Geçerli durumu sil Delete effect layer Efekt katmanını sil Delete layer ‘{name}’? '{name}' katmanı silinsin mi? Delete {count} layers? {names} {count} katman silinsin mi? {names} @@ -1027,17 +1027,17 @@ Density Yoğunluk Density: Yoğunluk: Depth Derinlik - Depth Jitter Derinlik Değişimi + Depth Jitter Derinlik Titreşimi Description Açıklama Deselect All Tümünün Seçimini Kaldır - Detail Detay + Detail Ayrıntı Diamond Gradient Elmas Degrade Difference Fark Diffusion Difüzyon Discard the embedded profile (don't color manage) Gömülü profili atın (renk yönetimi yapmayın) - Discontiguous bitişik değil + Discontiguous Bitişik Değil Discord Discord - Dismiss Reddet + Dismiss Kapat Display P3 Display P3 Display Text Metni Görüntüle Dither Titreşim @@ -1049,18 +1049,18 @@ Done Bitti 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 resmin üzerine sürükleyin; Yatay/dikey için kaydırma; silmek için sağ tıklayın. + 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 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: çapayı seçin · Sürükleyin: çapayı, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı + 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. Drop an image or PSD anywhere to open it. Açmak için bir görüntüyü veya PSD'yi herhangi bir yere bırakın. Native file drag-and-drop is unavailable Yerel dosya sürükle ve bırak özelliği kullanılamıyor - Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosyayı Kullan › Aç veya resmi dosya yöneticinize kopyalayıp {paste} ile yapıştırın. + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosya › Aç'ı kullanın veya görüntüyü dosya yöneticinizde kopyalayıp {paste} ile yapıştırın. To drop files, start PhotoCraft under XWayland: `{command}` Dosyaları bırakmak için XWayland altında PhotoCraft'ı başlatın: `{command}` - Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Veya Tercihler › Performans › Linux görüntüleme sunucusunu X11: PhotoCraft olarak ayarlayın, ardından her zaman XWayland altında başlar. + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Veya Tercihler › Performans › Linux görüntüleme sunucusunu X11 olarak ayarlayın: PhotoCraft bundan sonra her zaman XWayland altında başlar. Edit › Paste Düzenle › Yapıştır Duplicate Layers… Katmanları Çoğalt… - Duplicate style Yinelenen stil + Duplicate style Stili çoğalt Edit Toolbar… Araç Çubuğunu Düzenle… Use File › Open, or paste a copied image with {paste}. Dosya › Aç'ı kullanın veya kopyalanan görüntüyü {paste} ile yapıştırın. Edit in Quick Mask Mode (Q) Hızlı Maske Modunda Düzenleme (Q) @@ -1068,12 +1068,12 @@ Effects Efektler Embed Color Profile Renk Profilini Göm Embedded Profile Mismatch Gömülü Profil Uyuşmazlığı - Enable airbrush-style build-up effects Airbrush stili oluşturma efektlerini etkinleştirin + Enable airbrush-style build-up effects Airbrush tarzı birikim efektlerini etkinleştir Enhance Edge Kenarı Geliştir Entire Font Yazı Tipinin Tamamı - Exact tam + Exact Tam Expansion: Genişleme: - Export Presets Ön Ayarları Dışa Aktarma + Export Presets Ön Ayarları Dışa Aktar Export selected measurements (CSV) Seçilen ölçümleri dışa aktar (CSV) Exposure Pozlama Exposure and Gamma Pozlama ve Gama @@ -1082,52 +1082,52 @@ Factor: Faktör: Faux Bold Sahte Kalın Faux Italic Sahte İtalik - Feather Tüy - Fewer Points Daha Az Puan + Feather Geçiş Yumuşatma + Fewer Points Daha Az Nokta File Settings Dosya Ayarları Fill Doldur - Fill: Doldurun: + Fill: Dolgu: Filter for adjustment layers Ayarlama katmanları için filtre Filter for pixel layers Piksel katmanları için filtre Filter for shape layers Şekil katmanları için filtre Filter for smart objects Akıllı nesneler için filtre - Filter for type layers Yazım katmanları için filtre + Filter for type layers Metin katmanları için filtre Filter: Filtre: Finger Painting Parmak Boyama - Fisheye Balıkgözü - Fit Uygun + Fisheye Balık Gözü + Fit Sığdır Fit Screen Ekrana Sığdır - Fill Screen Doldurma Ekranı + Fill Screen Ekranı Doldur Fit in View Görünüme Sığdır Fixed Sabit Fixed Ratio Sabit Oran Fixed Size Sabit Boyut Flip Çevir Flip X X'i Çevir - Flip X Jitter X Değişimini Çevir - Flip Y Y Çevirme - Flip Y Jitter Çevir Y Titreşimi + Flip X Jitter X Çevirme Titreşimi + Flip Y Y'yi Çevir + Flip Y Jitter Y Çevirme Titreşimi Flow Akış - Flow Jitter Akış Değişimi + Flow Jitter Akış Titreşimi Focal Length Odak Uzunluğu Font Yazı tipi Font Size: Yazı Tipi Boyutu: Font size Yazı tipi boyutu Foreground Ön plan Foreground to Background Ön Plandan Arka Plana - Foreground/Background Jitter Ön Plan/Arka Plan Değişimi + Foreground/Background Jitter Ön Plan/Arka Plan Titreşimi Format Biçim - Frame from Layers… Katmanlardan Çerçeve… - Free Ücretsiz + Frame from Layers… Katmanlardan Kare… + Free Serbest Full Spherical Tam Küresel Fuzziness: Bulanıklık: GPU GPU Gamma Correction Gama Düzeltmesi Gamma: Gama: - Global Küresel - Glow Color Parıltılı Renk - Gradient Map Gradyan Haritası - Grain Tahıl + Global Genel + Glow Color Işıma Rengi + Gradient Map Degrade Eşlemesi + Grain Tane Gray Gri Gray Gamma 2.2 Gri Gama 2.2 Green Yeşil @@ -1138,28 +1138,28 @@ Height: Yükseklik: Hide Auto Slices Otomatik Dilimleri Gizle Hide Properties Özellikleri Gizle - Highlight Compression Sıkıştırmayı Vurgula - Highlight Priority Önceliği Vurgula + Highlight Compression Parlak Ton Sıkıştırma + Highlight Priority Parlak Ton Önceliği Highlights Öne Çıkanlar Home Ana sayfa Horizontal scale Yatay ölçek - Hue Renk Tonu - Hue Jitter Ton Değişimi + Hue Ton + Hue Jitter Renk Tonu Titreşimi Hue/Saturation Ton/Doygunluk Hyphenate Tireleme Ignore Yoksay Ignore All Tümünü Yoksay - Image Size Resim Boyutu + Image Size Görüntü Boyutu Import Brushes… Fırçaları İçe Aktar… Import brushes (.abr) Fırçaları içe aktar (.abr) Import Presets Ön Ayarları İçe Aktar - Imported İthal - Indent First Önce Girintiyi Girin + Imported İçe Aktarıldı + Indent First İlk Satır Girintisi Indent Left Sola Girinti Indent Right Sağa Girinti Indent first line İlk satıra girinti koy Indent left margin Sol kenar boşluğunu girintile - Indent right margin Sağ kenar boşluğuna girinti + Indent right margin Sağ kenar boşluğunu girintile Input: Giriş: Inside İçeride Interlaced Taramalı @@ -1167,12 +1167,12 @@ Intersect with selection ({key}) Seçimle kesiştir ({key}) Invert All Tümünü Ters Çevir Invert has no settings. Tersine çevirmenin hiçbir ayarı yoktur. - Italic italik + Italic İtalik Join us on Discord Discord'da bize katılın - Justify all Hepsini gerekçelendir - Justify last centered Son ortayı iki yana yasla - Justify last left Son sola yasla - Justify last right Son sağa yasla + Justify all Tümünü iki yana yasla + Justify last centered Son satır ortalı, iki yana yasla + Justify last left Son satır sola yaslı, iki yana yasla + Justify last right Son satır sağa yaslı, iki yana yasla Kerning Karakter Aralığı Keyboard Shortcuts Klavye Kısayolları Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler @@ -1180,40 +1180,40 @@ Label Size Etiket Boyutu Labels Etiketler Lanczos (sharp) Lanczos (keskin) - Landscape Manzara + Landscape Yatay Last Document State Son Belge Durumu Layout Düzen - Leading Lider + Leading Satır Aralığı Leading (set to the font size × auto-leading when Auto) Satır Aralığı (Otomatik olduğunda yazı tipi boyutu × otomatik satır aralığına ayarlanır) Leave as is (don't color manage) Olduğu gibi bırakın (renk yönetimi yapmayın) Left align text Metni sola hizala Length: Uzunluk: - Level Seviye - Levels Seviyeler + Level Düzey + Levels Düzeyler Lighten Açıklaştır - Lightness hafiflik - Lights Işıklar + Lightness Açıklık + Lights Açık Tonlar Limits: Sınırlar: Linear Gradient Doğrusal Degrade - Link layers Katmanları bağlayın - Link width and height Bağlantı genişliği ve yüksekliği + Link layers Katmanları bağla + Link width and height Genişliği ve yüksekliği bağla Liquify is not open Sıvılaştırma açık değil Load Yükle - Load 3D LUT… 3D LUT'u yükleyin… + Load 3D LUT… 3D LUT'u Yükle… Load channel as selection Kanalı seçim olarak yükle Load… Yükle… - Localized Color Clusters Yerelleştirilmiş Renk Kümeleri + Localized Color Clusters Yerel Renk Kümeleri Lock Kilit Lock all Tümünü kilitle - Lock image pixels Resim piksellerini kilitle + Lock image pixels Görüntü piksellerini kilitle Lock layer Katmanı kilitle - Lock position Kilit konumu - Lock transparent pixels Şeffaf pikselleri kilitle + Lock position Konumu kilitle + Lock transparent pixels Saydam pikselleri kilitle Lock: Kilit: Logical Length: Mantıksal Uzunluk: Logical Units: Mantıksal Birimler: Luminance parlaklık - Luminance Detail Parlaklık Detayı + Luminance Detail Lüminans Ayrıntısı Magenta Macenta Magenta · Green Macenta · Yeşil Maintain aspect ratio En boy oranını koru @@ -1221,23 +1221,23 @@ Mask All Tümünü Maskele Mask Options Maske Seçenekleri Masking Maskeleme - Master Usta + Master Ana Matte Mat Median: Medyan: Menus Menüler - Mesh Size Örgü Boyutu + Mesh Size Ağ Boyutu Metadata Meta veriler Metrics Metrikler Midpoint Orta nokta Midtones Orta tonlar - Midtones from: Orta tonlar: - Midtones to: Orta tonlar: + Midtones from: Orta tonlar (başlangıç): + Midtones to: Orta tonlar (bitiş): Minimum Depth Minimum Derinlik Minimum Diameter Minimum Çap Minimum Roundness Minimum Yuvarlaklık Mode: Mod: Monochrome Tek renkli - More Points Daha Fazla Puan + More Points Daha Fazla Nokta Move effect layer down Efekt katmanını aşağı taşı Move effect layer up Efekt katmanını yukarı taşı Move to Şuraya taşı: @@ -1260,16 +1260,16 @@ No matching commands Eşleşen komut yok No presets for the current tool. Geçerli araç için ön ayar yok. No presets of this type. Bu türden ön ayar yok. - No properties Mülk yok + No properties Özellik yok No tool presets. Araç ön ayarı yok. Noise Reduction Gürültü Azaltma Normal Normal Not in Dictionary: Sözlükte Yok: OK Tamam - Offset Ofset + Offset Kaydırma Only Web Colors Yalnızca Web Renkleri Opacity Opaklık - Opacity Jitter Opaklık Değişimi + Opacity Jitter Opaklık Titreşimi Opacity: Opaklık: Open a document to define variables. Değişkenleri tanımlamak için bir belge açın. Open a document to select a colour range. Bir renk aralığı seçmek için bir belge açın. @@ -1279,7 +1279,7 @@ Option-click to define a source point to clone from Kopyalanacak bir kaynak noktası tanımlamak için Option tuşunu basılı tutarak tıklayın Original Orijinal Output Channel: Çıkış Kanalı: - Output Levels: Çıkış Seviyeleri: + Output Levels: Çıkış Düzeyleri: Output: Çıkış: Outside Dışarıda Overlay Color Kaplama Rengi @@ -1288,7 +1288,7 @@ Override Pressure Basıncı Geçersiz Kıl Override Rotation Döndürmeyi Geçersiz Kıl Override Tilt Eğimi Geçersiz Kıl - PRESET DETAILS ÖN AYAR DETAYLARI + PRESET DETAILS ÖN AYAR AYRINTILARI PSD PSD Paint Overlay Boya Kaplaması Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panel konumları çalışma alanına kaydedilir. Klavye kısayolları, menüler ve araç çubuğu isteğe bağlıdır. @@ -1298,22 +1298,22 @@ Pattern Size Desen Boyutu Pause Duraklat Percent Yüzde - Perceptual algısal + Perceptual Algısal Perspective Warp is not active Perspektif Çarpıtma etkin değil Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektif Çarpıtma: görüntünün perspektifi boyunca düzlemler çizin, ardından Çarpıtma'ya geçin Photo Filter Fotoğraf Filtresi PhotoCraft PhotoCraft PhotoCraft Manages Colors PhotoCraft Renkleri Yönetir - PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust'ta yazılmış açık kaynaklı, yerel bir resim düzenleyici. + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust'ta yazılmış açık kaynaklı, yerel bir görüntü düzenleyici. Pin Depth: Pim Derinliği: Pixel Length: Piksel Uzunluğu: Pixels/Centimeter Piksel/Santimetre Pixels/Inch Piksel/İnç Pixels: Piksel: - Play Oyna + Play Oynat Play selection Seçimi oynat - Portrait Portre - Position Pozisyon + Portrait Dikey + Position Konum Position and Size Konum ve Boyut Posterize Posterleştir Preserve Details Ayrıntıları Koru @@ -1325,10 +1325,10 @@ Press keys… Tuşlara basın… Pressure Basınç Pressure for Opacity Opaklık Basıncı - Pressure for Size Boyuta Göre Basınç + Pressure for Size Boyut Basıncı Prevent auto-nesting in and out of Artboards and Frames Çalışma Yüzeyleri ve Çerçevelerin içine ve dışına otomatik yerleştirmeyi önleyin Preview Önizleme - Previous frame Önceki çerçeve + Previous frame Önceki kare Previous note Önceki not Print Yazdır Printer Yazıcı @@ -1336,22 +1336,22 @@ Printer Setup Yazıcı Kurulumu Printing Marks Baskı İşaretleri Progressive Aşamalı - Promote Tanıt - Protect Detail Detayı Koruyun - Protect Foreground Color Ön Plan Rengini Koruyun - Protect Tones Tonları Koruyun + Promote Yükselt + Protect Detail Ayrıntıyı Koru + Protect Foreground Color Ön Plan Rengini Koru + Protect Tones Tonları Koru Proximity Match Yakınlık Eşleşmesi - Pulled String Mode Çekilmiş Dize Modu + Pulled String Mode Çekilen İp Modu Puppet Warp is not active Kukla Çarpıtma etkin değil - Purity saflık + Purity Saflık Quality Kalite Quick Actions Hızlı Eylemler Quick Export As PNG PNG Olarak Hızlı Dışa Aktarma - Radial Gradient Radyal Gradyan + Radial Gradient Radyal Degrade Radius Yarıçap Radius: Yarıçap: Range: Aralık: - Rate Oran + Rate Hız Recently Used Son Kullanılanlar Reconstruct Yeniden inşa et Record ● a sequence of edits, then play ▶ it on any document. Bir dizi düzenlemeyi kaydedin ● ve ardından herhangi bir belgede oynatın. @@ -1359,14 +1359,14 @@ Redefine style by current selection Stili geçerli seçime göre yeniden tanımlayın Refine Hassaslaştır Reflected Gradient Yansıyan Degrade - Registration Marks Tescil İşaretleri - Regular Düzenli - Relative akraba + Registration Marks Kayıt İşaretleri + Regular Normal + Relative Bağıl Relative Colorimetric Bağıl Kolorimetrik Remove Kaldır - Remove All Pins Tüm Pinleri Kaldır + Remove All Pins Tüm Pimleri Kaldır Remove Background Arka Planı Kaldır - Remove Planes Uçakları Kaldır + Remove Planes Düzlemleri Kaldır Remove stop Durağı kaldır Remove variable Değişkeni kaldır Rename Yeniden adlandır @@ -1374,16 +1374,16 @@ Rename Layer… Katmanı Yeniden Adlandır… Rename Tool Preset… Araç Ön Ayarını Yeniden Adlandır… Rename… Yeniden adlandır… - Resample Yeniden örnekle + Resample Yeniden Örnekle Reset All to Defaults Tümünü Varsayılanlara Sıfırla Import Shortcuts… Kısayolları İçe Aktar… Load a keyboard shortcut set saved as .kys .kys olarak kaydedilen bir klavye kısayolu kümesi yükleyin - Imported {n} shortcuts from {file}. Click OK to keep them. {n} kısayolları {file} adresinden içe aktarıldı. Bunları saklamak için Tamam'ı tıklayın. - {n} commands are not in PhotoCraft. {n} komutları PhotoCraft'ta değil. - Keyboard shortcuts imported İçe aktarılan klavye kısayolları - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. {set} klavye kısayol grubunuz burada kullanılıyor: {n} kısayolları varsayılanlardan farklı. Düzenle › Klavye Kısayolları bunları gösterir; Tümünü Varsayılanlara Sıfırla bu işlemi geri alır. + Imported {n} shortcuts from {file}. Click OK to keep them. {n} kısayol {file} dosyasından içe aktarıldı. Saklamak için Tamam'a tıklayın. + {n} commands are not in PhotoCraft. {n} komut PhotoCraft'ta yok. + Keyboard shortcuts imported Klavye kısayolları içe aktarıldı + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. {set} klavye kısayol setiniz burada kullanılıyor: {n} kısayol varsayılanlardan farklı. Düzenle › Klavye Kısayolları bunları gösterir; Tümünü Varsayılanlara Sıfırla bu işlemi geri alır. Import keyboard shortcuts? Klavye kısayolları içe aktarılsın mı? - PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft, {set} klavye kısayol setinizi bu bilgisayardaki başka bir resim düzenleyicide buldu: {n} kısayolları PhotoCraft'ınkilerden farklı. İçe aktarılsın mı? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft, bu bilgisayardaki başka bir görüntü düzenleyicide {set} klavye kısayol setinizi buldu: {n} kısayol PhotoCraft'tan farklı. İçe aktarılsın mı? Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Düzenle › Klavye Kısayolları › Tümünü Varsayılanlara Sıfırla, içe aktarma işlemini geri alır. Don't Import İçe Aktarma The keyboard shortcut set was not imported: {error} Klavye kısayol seti içe aktarılmadı: {error} @@ -1401,62 +1401,62 @@ Rotation Döndürme Roughness Pürüzlülük Roundness Yuvarlaklık - Roundness Jitter Yuvarlaklık Değişimi + Roundness Jitter Yuvarlaklık Titreşimi Rulers & Grids Cetveller ve Izgaralar Sample All Layers Tüm Katmanları Örnekle Sample: Örnek: Sampling: Background Swatch Örnekleme: Arka Plan Renk Örneği Sampling: Continuous Örnekleme: Sürekli Sampling: Once Örnekleme: Bir kez - Saturate Doygunluk + Saturate Doygunlaştır Saturation Doygunluk - Saturation Jitter Doygunluk Değişimi - Save HTML and Images HTML ve Görselleri Kaydet + Saturation Jitter Doygunluk Titreşimi + Save HTML and Images HTML ve Görüntüleri Kaydet Save as PDF PDF olarak kaydet Save selection as channel Seçimi kanal olarak kaydet Save… Kaydet… - Scale to Fit Media Medyaya Sığacak Şekilde Ölçeklendirin + Scale to Fit Media Ortama Sığacak Şekilde Ölçekle Scatter Dağılım - Scratch disks Kazıma diskleri + Scratch disks Karalama diskleri Search Ara Search commands Arama komutları Search commands, tools and panels… Komutları, araçları ve panelleri arayın… - Search fonts Yazı tiplerini arayın + Search fonts Yazı tipi ara Select All Tümünü Seç - Select Subject Konuyu Seçin - Select a type layer or click into text to insert glyphs Bir yazım katmanı seçin veya glif eklemek için metne tıklayın + Select Subject Konuyu Seç + Select a type layer or click into text to insert glyphs Bir metin katmanı seçin veya glif eklemek için metne tıklayın Select text to apply a character style Karakter stili uygulamak için metni seçin - Select: Seçin: + Select: Seç: Selections Seçimler - Selective seçici + Selective Seçici Selective Color Seçici Renk - Send the selected pin backward Seçilen pini geriye gönder - Set additional smoothing options Ek yumuşatma seçeneklerini ayarlayın + Send the selected pin backward Seçilen pimi geriye gönder + Set additional smoothing options Ek yumuşatma seçeneklerini ayarla Set background color Arka plan rengini ayarla Set foreground color Ön plan rengini ayarla - Set painting symmetry options Boyama simetrisi seçeneklerini ayarlama + Set painting symmetry options Boyama simetrisi seçeneklerini ayarla Symmetry Off Simetri Kapalı Set shape fill type Şekil doldurma türünü ayarla - Set shape stroke type Şekil kontur türünü ayarlama - Set the text color Metin rengini ayarlama + Set shape stroke type Şekil kontur türünü ayarla + Set the text color Metin rengini ayarla Shadows Gölgeler Shape: Şekil: - Sharpening Bileme + Sharpening Keskinleştirme Shift Shift Shortcut removed. Kısayol kaldırıldı. Show All Menu Items Tüm Menü Öğelerini Göster Show Backdrop Arka Planı Göster Show Guides Kılavuzları Göster Show Mask Maskeyi Göster - Show Mesh Mesh'i Göster + Show Mesh Ağı Göster Show Overlay Kaplamayı Göster Show Transform Controls Dönüştürme Kontrollerini Göster - Show or hide the Notes panel Notlar panelini gösterme veya gizleme + Show or hide the Notes panel Notlar panelini göster veya gizle Show: Göster: - Sides Taraflar - Sides: Taraflar: + Sides Kenarlar + Sides: Kenar sayısı: Size Boyut - Size Jitter Boyut Değişimi + Size Jitter Boyut Titreşimi Size: Boyut: Slice Options… Dilim Seçenekleri… Slices From Guides Kılavuzlardan Dilimler @@ -1470,33 +1470,33 @@ Split: Bölünmüş: Star ratio Yıldız oranı Stop playing/recording Oynatmayı/kaydetmeyi durdur - Straighten Düzelt - Straighten Layer Katmanı Düzleştir + Straighten Doğrult + Straighten Layer Katmanı Doğrult Strength: Güç: Strikethrough Üstü çizili Stroke Kontur - Stroke Catch-up İnme Yakalama - Stroke: İnme: + Stroke Catch-up Vuruş Yakalama + Stroke: Kontur: Style Stil Style Options Stil Seçenekleri Style Options… Stil Seçenekleri… - Style: Normal Tarz: Normal - Subtract from Sample Örnekten Çıkart + Style: Normal Stil: Normal + Subtract from Sample Örnekten Çıkar Subtract from selection ({key}) Seçimden çıkar ({key}) Suggestions: Öneriler: Swap colours (X) Renkleri değiştir (X) Swaps height and width Yüksekliği ve genişliği değiştirir - Switch between free transform and warp modes Serbest dönüştürme ve çarpıtma modları arasında geçiş yapın + Switch between free transform and warp modes Serbest dönüştürme ve bükme modları arasında geçiş yapın Switch theme Temayı değiştir Temperature Sıcaklık - Tetrahedral dörtyüzlü + Tetrahedral Dörtyüzlü Text Position: Metin Konumu: Texture Doku - Texture Each Tip Her Ucu Doku + Texture Each Tip Her Uca Doku Uygula Threshold Eşik - Threshold Level Eşik Seviyesi + Threshold Level Eşik Düzeyi Thumbnail size (smallest: list) Küçük resim boyutu (en küçük: liste) - Tilt X X'i yatır + Tilt X X Eğimi Tilt Y Y Eğimi Tint Renk tonu Toggle count group visibility Sayım grubu görünürlüğünü değiştir @@ -1507,30 +1507,30 @@ Toolbar Araç Çubuğu Top Üst Total: Toplam: - Tracking Takip - Tracking (1/1000 em) Takip (1/1000 em) - Transparency Şeffaflık - Transparent Şeffaf + Tracking İzleme + Tracking (1/1000 em) İzleme (1/1000 em) + Transparency Saydamlık + Transparent Saydam Trilinear Üç çizgili - Type Styles Tip Stilleri + Type Styles Metin Stilleri Type: Tür: Underline Altı çizili - Untitled İsimsiz - Untitled-1 İsimsiz-1 + Untitled Adsız + Untitled-1 Adsız-1 Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm) Use Default Varsayılanı Kullan Use Legacy Eskiyi Kullan Use Measurement Scale Ölçüm Ölçeğini Kullan - Use the embedded profile (instead of the working space) Gömülü profili kullanın (çalışma alanı yerine) + Use the embedded profile (instead of the working space) Gömülü profili kullanın (çalışma renk uzayı yerine) Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Vertical scale Dikey ölçek Vibrance Canlılık - View Options Seçenekleri Görüntüle - Vignetting Kenar ayrıntısı kaybı + View Options Görünüm Seçenekleri + Vignetting Vinyet Violet, Orange Menekşe, Turuncu Visibility Görünürlük Visible Görünür - Warp: Çözgü: + Warp: Çarpıtma: Web Snap Web Snap Weight Ağırlık Weight: Ağırlık: @@ -1547,12 +1547,12 @@ Zoom in Yakınlaştır Zoom out Uzaklaştır command or shortcut komut veya kısayol - current akım - image file path resim dosyası yolu + current geçerli + image file path görüntü dosyası yolu modified değiştirilmiş - name isim + name ad new yeni - {name} options {name} seçenekler + {name} options {name} seçenekleri ↑↓ navigate {key} run esc close ↑↓ gezin {key} çalıştır esc kapat Version {version} Sürüm {version} New Document Yeni Belge @@ -1565,15 +1565,15 @@ Command Komut Adjustment layers have nothing to transform Ayarlama katmanlarının dönüştürülecek hiçbir şeyi yok Altitude Rakım - Android 1080p Android1080p + Android 1080p Android 1080p Apple Watch 45mm Apple Watch 45mm - Aqua Su + Aqua Turkuaz Art & Illustration Sanat ve İllüstrasyon ArtCraft ArtCraft Background Eraser Tool Arka Plan Silgisi Aracı - Bevel & Emboss Eğim Ver ve Kabart - Blend Mode Karışım Modu - Blues Blues + Bevel & Emboss Eğim ve Kabartma + Blend Mode Karıştırma Modu + Blues Maviler Blur Tool Bulanıklık Aracı Bold Kalın Brush Fırça @@ -1581,40 +1581,40 @@ Brush Pose Fırça Duruşu Brush Tip Shape Fırça Ucu Şekli Brush Tool Fırça Aracı - Build-up İnşaat - Burn Tool Koyulaştırma Aracı + Build-up Birikim + Burn Tool Yakma Aracı Centimeters Santimetre - Choke Şok + Choke Daraltma Classic Klasik Click to add text Metin eklemek için tıklayın Click to fill similar colours Benzer renkleri doldurmak için tıklayın Clone Stamp Tool Klonlama Damgası Aracı - Color Burn Renk Yanması + Color Burn Renk Yakma Color Dodge Renk Soldurma Color Dynamics Renk Dinamiği Color Overlay Renk Kaplaması Comic Book Çizgi Roman - Conform Uygun - Content-Aware Fill needs a selection on a pixel layer İçeriğe Uygun Dolgu, piksel katmanında seçim yapılmasını gerektirir + Conform Uyarla + Content-Aware Fill needs a selection on a pixel layer İçeriğe Duyarlı Dolgu, piksel katmanında seçim yapılmasını gerektirir Could not transform: the layer is empty Dönüştürülemedi: katman boş Could not use Perspective Warp: the layer is empty Perspektif Çarpıtma kullanılamadı: katman boş - Could not use Puppet Warp: no opaque pixels Puppet Warp kullanılamadı: opak piksel yok - Could not use Puppet Warp: the layer is empty Puppet Warp kullanılamadı: katman boş + Could not use Puppet Warp: no opaque pixels Kukla Çarpıtma kullanılamadı: opak piksel yok + Could not use Puppet Warp: the layer is empty Kukla Çarpıtma kullanılamadı: katman boş Crop Tool Kırpma Aracı Crosswise Çapraz olarak - Current Tool Güncel Araç + Current Tool Geçerli Araç Custom Shape Tool Özel Şekil Aracı Cyans Camgöbeği Default PhotoCraft Size Varsayılan PhotoCraft Boyutu Delete Workspace Çalışma Alanını Sil Diamond Elmas - Dimensions: Boyutlar: + Dimensions: Ölçüler: Direction Yön Distance Uzaklık - Document Dimensions Belge Boyutları + Document Dimensions Belge Ölçüleri Document Profile Belge Profili Document Sizes Belge Boyutları - Dodge Tool Açıklaştırma Aracı + Dodge Tool Soldurma Aracı Down Aşağı Drag to draw a gradient Degrade çizmek için sürükleyin Drag to move the active layer Etkin katmanı taşımak için sürükleyin @@ -1625,9 +1625,9 @@ Efficiency Verimlilik Efficiency: 100% Verimlilik: %100 Ellipse Tool Elips Aracı - Elliptical Marquee Tool Eliptik İşaretleme Aracı - Single Row Marquee Tool Tek Satır İşaretleme Aracı - Single Column Marquee Tool Tek Sütun İşaretleme Aracı + Elliptical Marquee Tool Eliptik Seçim Çerçevesi Aracı + Single Row Marquee Tool Tek Satır Seçim Çerçevesi Aracı + Single Column Marquee Tool Tek Sütun Seçim Çerçevesi Aracı Emboss Kabartma Eraser Silgi Eraser Tool Silgi Aracı @@ -1637,10 +1637,10 @@ Fade Solmaya Fill Opacity Dolgu Opaklığı Film & Video Film ve Video - From Gönderen + From Şuradan General Genel GitHub GitHub - Gradient Overlay Degrade Yerleşimi + Gradient Overlay Degrade Kaplaması Gradient Tool Degrade Aracı Greens Yeşiller Hand Tool El Aracı @@ -1652,7 +1652,7 @@ Horizontal Type Tool Yatay Metin Aracı Horizontal Type Mask Tool Yatay Metin Maskesi Aracı Vertical Type Mask Tool Dikey Metin Maskesi Aracı - Image Size: Resim Boyutu: + Image Size: Görüntü Boyutu: Inches İnç Indexed Dizine eklendi Initial Direction Başlangıç Yönü @@ -1666,21 +1666,21 @@ Lasso Kement Lasso Tool Kement Aracı Layer Count Katman Sayısı - Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Ortadan Kaldırır - Legal Yasal - Letter Mektup - Light Işık + Layer Knocks Out Drop Shadow Katman Düşen Gölgeyi Deler + Legal Legal + Letter Letter + Light Hafif Line Tool Çizgi Aracı Linear Doğrusal - Linear Burn Doğrusal Yanma + Linear Burn Doğrusal Yakma Linear Height Doğrusal Yükseklik Link with Layer Katmanla Bağlantı - Lorem Ipsum Lorem İpsum + Lorem Ipsum Lorem Ipsum Magentas Macentalar Magic Eraser Tool Sihirli Silgi Aracı Magic Wand Tool Sihirli Değnek Aracı Marquee Seçim Çerçevesi - Mean: Anlamı: + Mean: Ortalama: Measurement Scale Ölçüm Ölçeği Measurement Scale Marker Ölçüm Ölçeği İşaretleyicisi Millimeters Milimetre @@ -1700,17 +1700,17 @@ Original Ratio Orijinal Oran Out Of Gamut Gamut Dışı Outer Bevel Dış Eğim - Outer Glow Dış Parıltı + Outer Glow Dış Işıma Overlay Kaplama Paint Bucket Tool Boya Kovası Aracı - Paints from the document's opening state Belgenin açılış durumundaki boyalar + Paints from the document's opening state Belgenin açılış durumundan boyar Path Selection Tool Yol Seçimi Aracı Direct Selection Tool Doğrudan Seçim Aracı - Add Anchor Point Tool Bağlantı Noktası Ekleme Aracı - Delete Anchor Point Tool Bağlantı Noktası Silme Aracı - Click a segment to add an anchor point · {alt}-click an anchor to delete it Bağlantı noktası eklemek için bir segmenti tıklayın · {alt}-silmek için bir bağlantıyı tıklayın - Click an anchor point to delete it · {alt}-click a segment to add one Silmek için bir bağlantı noktasını tıklayın · {alt}-bir parça eklemek için bir parçayı tıklayın - Pattern Overlay Desen Yerleşimi + Add Anchor Point Tool Çapa Noktası Ekleme Aracı + Delete Anchor Point Tool Çapa Noktası Silme Aracı + Click a segment to add an anchor point · {alt}-click an anchor to delete it Çapa noktası eklemek için bir parçaya tıklayın · silmek için bir çapa noktasına {alt}-tıklayın + Click an anchor point to delete it · {alt}-click a segment to add one Silmek için bir çapa noktasına tıklayın · eklemek için bir parçaya {alt}-tıklayın + Pattern Overlay Desen Kaplaması Pen Pressure Kalem Basıncı Pen Tilt Kalem Eğimi Pen Tool Kalem Aracı @@ -1721,19 +1721,19 @@ Pillow Emboss Yastık Kabartma Pixel Replacement Piksel Değiştirme Pixels Piksel - Points Puanlar + Points Punto Polygon Tool Çokgen Aracı Polygonal Lasso Tool Çokgen Kement Aracı - Portrait, 4 x 6 Portre, 4 x 6 - Portrait, 5 x 7 Portre, 5 x 7 - PostScript (72 points/inch) PostScript (72 nokta/inç) + Portrait, 4 x 6 Dikey, 4 x 6 + Portrait, 5 x 7 Dikey, 5 x 7 + PostScript (72 points/inch) PostScript (72 punto/inç) Postcard Kartpostal Poster Poster Pro (Dark) Pro (Koyu) - Pro (Light) Pro (Hafif) + Pro (Light) Pro (Açık) Pro (Light Gray) Pro (Açık Gri) Pro (Medium Gray) Pro (Orta Gri) - Protect Texture Dokuyu Koruyun + Protect Texture Dokuyu Koru Purple Mor Quick Mask Hızlı Maske Quick Selection Tool Hızlı Seçim Aracı @@ -1743,7 +1743,7 @@ Ratio Oran Recent Son Rectangle Tool Dikdörtgen Aracı - Rectangular Marquee Tool Dikdörtgen İşaretleme Aracı + Rectangular Marquee Tool Dikdörtgen Seçim Çerçevesi Aracı Reds Kırmızılar Reflected Yansıyan Sample: foreground colour Örnek: ön plan rengi @@ -1751,7 +1751,7 @@ Satin Saten Save Pixel Aspect Ratio Piksel En Boy Oranını Kaydet Scattering Saçılma - Scratch Sizes Çizik Boyutları + Scratch Sizes Karalama Boyutları Select Data Points Veri Noktalarını Seçin Selection Seçim SemiBold Yarı Kalın @@ -1770,22 +1770,22 @@ Sponge Tool Sünger Aracı Spot Healing Brush Tool Nokta Düzeltme Fırçası Aracı Spread Yayılma - Square, 12 x 12 Kare, 12x12 - Square, 5 x 5 Kare, 5x5 - Std Dev: Standart Geliştirme: - Studio (Dark) Stüdyo (Karanlık) - Studio (Light) Stüdyo (Işık) + Square, 12 x 12 Kare, 12 x 12 + Square, 5 x 5 Kare, 5 x 5 + Std Dev: Standart Sapma: + Studio (Dark) Stüdyo (Koyu) + Studio (Light) Stüdyo (Açık) Stylus Wheel Kalem Tekerleği Subtract Çıkar Tabloid Tabloid Text Replacement Metin Değiştirme Thin İnce - Traditional (72.27 points/inch) Geleneksel (72,27 nokta/inç) + Traditional (72.27 points/inch) Geleneksel (72,27 punto/inç) Transfer Aktarım Triangle Tool Üçgen Aracı Unsaved changes Kaydedilmemiş değişiklikler Uses the foreground and background colours. Ön plan ve arka plan renklerini kullanır. - Web ağ + Web Web Web Large Web Büyük Web Minimum Web Minimumu Web Most Common Web En Yaygın @@ -1793,20 +1793,20 @@ Wet Edges Islak Kenarlar Yellows Sarılar Zoom Tool Yakınlaştırma Aracı - {name} Properties {name} Özellikler + {name} Properties {name} Özellikleri Revert “{name}” to the last saved version? Your changes will be lost. “{name}” son kaydedilen sürüme döndürülsün mü? Değişiklikleriniz kaybolacak. Do you want to save the changes you made to “{name}” before quitting? Çıkmadan önce “{name}” öğesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? Do you want to save the changes you made to “{name}” before closing? Kapatmadan önce “{name}” üzerinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? - Doc: {flat}/{layered} Doküman: {flat}/{layered} - Scratch: {size} Çizik: {size} + Doc: {flat}/{layered} Belge: {flat}/{layered} + Scratch: {size} Karalama: {size} Untagged {mode} Etiketlenmemiş {mode} Invalid {mode} profile Geçersiz {mode} profili Unnamed {mode} profile Adsız {mode} profili {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Çözünürlük: {dpi} piksel/inç - Doc: {w} × {h} px Doküman: {w} × {h} piksel - Frame {current} / {total} Çerçeve {current} / {total} + Doc: {w} × {h} px Belge: {w} × {h} piksel + Frame {current} / {total} Kare {current} / {total} Count: {n} Sayım: {n} BLANK DOCUMENT PRESETS ({n}) BOŞ BELGE ÖN AYARLARI ({n}) Constraints ({n}) Kısıtlamalar ({n}) @@ -1816,7 +1816,7 @@ @plural Group · {n} layer|Group · {n} layers Grup · {n} katman Additional plugins folder Ek eklentiler klasörü Adobe Adobe - Adobe rgb Adobe rgb + Adobe rgb Adobe RGB Allow agent control Aracı kontrolüne izin ver Allow scripts to connect Komut dosyalarının bağlanmasına izin ver Always Her zaman @@ -1839,31 +1839,31 @@ Cache levels Önbellek düzeyleri Cache tile size Önbellek kutucuğu boyutu Camera Raw Defaults Camera Raw Varsayılanları - Canvas border Kanvas kenarlığı - Show canvas dots Kanvas noktalarını göster - Canvas color Kanvas rengi - Canvas custom color Kanvas özel rengi + Canvas border Tuval kenarlığı + Show canvas dots Tuval noktalarını göster + Canvas color Tuval rengi + Canvas custom color Tuval özel rengi Color picker Renk seçici Color space Renk alanı Column width Sütun genişliği Concise Kısa Control port Kontrol portu - Convert to srgb srgb'ye dönüştür + Convert to srgb sRGB'ye dönüştür Cursors İmleçler Custom dark Özel koyu - Custom light Özel ışık - Dark Karanlık + Custom light Özel açık + Dark Koyu Dark gray Koyu gri Dashed lines Kesikli çizgiler Destination Hedef - Detailed Detaylı + Detailed Ayrıntılı Disks Diskler - Display p3 Ekran p3 + Display p3 Display P3 Double click layer mask launches select and mask Çift tıklama katman maskesi seçme ve maskelemeyi başlatır Drop shadow Alt gölge Dynamic color sliders Dinamik renk kaydırıcıları East asian Doğu Asya - Effect cache mb Efekt önbelleği mb + Effect cache mb Efekt önbelleği (MB) Enable flick panning Hızlı kaydırmayı etkinleştir Enable floating document window docking Kayan belge penceresi yerleştirmeyi etkinleştir Enable narrow options bar Dar seçenekler çubuğunu etkinleştir @@ -1873,13 +1873,13 @@ Extra large Ekstra büyük File Handling Dosya İşleme File path Dosya yolu - Fill new type layers with placeholder Yeni yazı katmanlarını yer tutucuyla doldurma + Fill new type layers with placeholder Yeni metin katmanlarını yer tutucuyla doldur Font preview Yazı tipi önizlemesi Full size tip Tam boyutlu uç Gamut warning color Gamut uyarı rengi Gamut warning opacity Gamut uyarı opaklığı - Generator enabled Jeneratör etkin - Gif Gif + Generator enabled Oluşturucu etkin + Gif GIF Glossy parlak Grid color Izgara rengi Grid colors Izgara renkleri @@ -1892,27 +1892,27 @@ Guides, Grid & Slices Kılavuzlar, Izgaralar ve Dilimler Gutter Oluk History Log Geçmiş Günlüğü - History states Tarih durumları + History states Geçmiş durumları Ignore exif profile tag Exif profil etiketini yoksay Image interpolation Görüntü enterpolasyonu - Image previews Resim önizlemeleri + Image previews Görüntü önizlemeleri Integrations Entegrasyonlar Interface Arayüz Jpeg quality JPEG kalitesi - Jpg Jpg + Jpg JPG Language Dil Large tabs Büyük sekmeler Legacy compositing Eski birleştirme Light gray Açık gri Line Hat - Lines çizgiler + Lines Çizgiler Lossless Kayıpsız Lowercase extension Küçük harf uzantısı - Maximize psd compatibility PSD uyumluluğunu en üst düzeye çıkarın + Maximize psd compatibility PSD uyumluluğunu en üst düzeye çıkar Medium gray Orta gri - Memory usage mb Bellek kullanımı mb + Memory usage mb Bellek kullanımı (MB) Low resolution previews Düşük çözünürlüklü önizlemeler - Missing glyph protection Glif koruması eksik + Missing glyph protection Eksik glif koruması Modern Çağdaş Nearest neighbor En yakın komşu Never Asla @@ -1922,36 +1922,36 @@ Open documents as tabs Belgeleri sekmeler halinde aç Overscroll Aşırı kaydırma Performance Performans - Png png + Png PNG Point size Nokta boyutu Precise Hassas Preserve details Ayrıntıları koru Print resolution Yazdırma çözünürlüğü - Pro Profesyonel - Pro light Profesyonel ışık + Pro Pro + Pro light Pro açık Pro light gray Pro açık gri - Pro medium Profesyonel orta - Pro photo Profesyonel fotoğraf + Pro medium Pro orta + Pro photo ProPhoto Quick export format Hızlı dışa aktarma formatı Quick export location Hızlı dışa aktarma konumu Recent file count Son dosya sayısı Recent files Son dosyalar - Recent fonts En yeni yazı tipleri + Recent fonts Son kullanılan yazı tipleri Recover on launch Başlatıldığında kurtar Remember workspace changes Çalışma alanı değişikliklerini hatırla - Resize image during place Yerleştirme sırasında resmi yeniden boyutlandır - Rotate view with trackpad İzleme dörtgeniyle görünümü döndürme + Resize image during place Yerleştirme sırasında görüntüyü yeniden boyutlandır + Rotate view with trackpad Görünümü dokunmatik yüzeyle döndür Same folder Aynı klasör Save in background Arka planda kaydet - Scratch Disks Kazı Diskleri + Scratch Disks Karalama Diskleri Screen Ekran Screen resolution Ekran çözünürlüğü - Scrubby slider acceleration Temiz kaydırıcı hızlandırması + Scrubby slider acceleration Kaydırıcı sürükleme hızlanması Sessions only Yalnızca oturumlar Sharpen for Şunun için keskinleştir: Show channels in color Kanalları renkli göster Show crosshair in brush tip Fırça ucunda artı işaretini göster - Show extension panels Uzatma panellerini göster + Show extension panels Uzantı panellerini göster Show font names in english İngilizce yazı tipi adlarını göster Show menu colors Menü renklerini göster Show only crosshair while painting Boyama sırasında yalnızca artı işaretini göster @@ -1964,34 +1964,34 @@ Slice color Dilim rengi Smart guide color Akıllı kılavuz rengi Smart quotes Akıllı tırnaklar - Snap vector tools and transforms to pixel grid Vektör araçlarını tutturun ve piksel ızgarasına dönüştürün - Srgb Srgb + Snap vector tools and transforms to pixel grid Vektör araçlarını ve dönüşümleri piksel ızgarasına yapıştır + Srgb sRGB Standard Standart Studio Stüdyo - Studio light Stüdyo ışığı + Studio light Stüdyo açık Subdivisions Alt bölümler System Sistem Text engine Metin motoru Text file Metin dosyası - Tiny minik + Tiny Minik Touch gestures Dokunma hareketleri - Transparency & Gamut Şeffaflık ve Gamut - Type units Tip birimleri - UI font size kullanıcı arayüzü yazı tipi boyutu + Transparency & Gamut Saydamlık ve Gamut + Type units Metin birimleri + UI font size Arayüz yazı tipi boyutu UI scale Kullanıcı arayüzü ölçeği Units & Rulers Birimler ve Cetveller Use additional plugins folder Ek eklentiler klasörünü kullan - Use esc to commit Taahhüt etmek için esc kullanın + Use esc to commit Onaylamak için Esc kullan Use gpu GPU'yu kullan - Use legacy free transform Eski ücretsiz dönüşümü kullanın - Use shift key for tool switch Araç anahtarı için Shift tuşunu kullanın - Vary round brush hardness on hud Hud üzerinde yuvarlak fırça sertliğini değiştirin - Webp Webp - World ready Dünya hazır + Use legacy free transform Eski serbest dönüştürmeyi kullan + Use shift key for tool switch Araç değiştirmek için Shift tuşunu kullan + Vary round brush hardness on hud HUD'da yuvarlak fırça sertliğini değiştir + Webp WebP + World ready Dünya dili uyumlu (World-Ready) Zoom clicked point to center Tıklanan noktayı merkeze yakınlaştır Zoom resizes windows Yakınlaştırma pencereleri yeniden boyutlandırır Zoom with scroll wheel Kaydırma tekerleği ile yakınlaştırma - Zoom with trackpad pinch İzleme dörtgeni tutamıyla yakınlaştırma + Zoom with trackpad pinch Dokunmatik yüzeyde sıkıştırarak yakınlaştır Pass Through Geçirgen Dissolve Eritme Darker Color Daha Koyu Renk @@ -2007,18 +2007,18 @@ Luminosity Parlaklık @plural {n} sample|{n} samples {n} örnek {added}, {removed} subtracted {added}, {removed} çıkarıldı - Adjustment Preview Ayar Önizlemesi + Adjustment Preview Ayarlama Önizlemesi Airbrush: keeps depositing paint while the pointer rests. Airbrush: İşaretçi dururken boyayı bırakmaya devam eder. Angle: Açı: - Blend If Eğer karıştır + Blend If Koşullu Karıştırma Close Tab Group Sekme Grubunu Kapat Collapse Daralt Collapse All Groups Tüm Grupları Daralt Collapse Panel Group Panel Grubunu Daralt Collapse group ({key}-click: all groups) Grubu daralt ({key}-tıklama: tüm gruplar) - Create new brush from the current settings Mevcut ayarlardan yeni fırça oluştur - Create new brush from these settings Bu ayarlardan yeni fırça oluşturun - Update the current brush with these settings Mevcut fırçayı bu ayarlarla güncelleyin + Create new brush from the current settings Geçerli ayarlardan yeni fırça oluştur + Create new brush from these settings Bu ayarlardan yeni fırça oluştur + Update the current brush with these settings Geçerli fırçayı bu ayarlarla güncelle Delete brush Fırçayı sil Drag the arrow to set the angle, the dots to set the roundness Açıyı ayarlamak için oku, yuvarlaklığı ayarlamak için noktaları sürükleyin Expand Genişlet @@ -2029,7 +2029,7 @@ Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) Move Group Down Grubu Aşağı Taşı Move Group Up Grubu Yukarı Taşı - Reset the brush to the defaults Fırçayı varsayılanlara sıfırlayın + Reset the brush to the defaults Fırçayı varsayılanlara sıfırla Restore original size Orijinal boyutu geri yükle Roundness: Yuvarlaklık: Search Brushes Fırçalarda Ara @@ -2043,20 +2043,20 @@ More documents Daha fazla belge These settings aren't available in PhotoCraft yet. Bu ayarlar henüz PhotoCraft'ta mevcut değildir. Graphics Grafik - Reset GPU Backend GPU Arka Uçunu Sıfırla + Reset GPU Backend GPU Arka Ucunu Sıfırla Applies at next launch. Bir sonraki açılışta geçerlidir. - Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · {key}-arka plan için tıklayın + Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın {key}-click to set the source {key}-kaynağı ayarlamak için tıklayın {key} to subtract {key} çıkarmak için - Clone source {n} ({key}-click with Clone Stamp to set) Kaynağı kopyala {n} ({key}-ayarlamak için Klonlama Damgası ile tıklayın) + Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın) Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın) Pixel Layer Piksel Katmanı - Type Layer Tip Katmanı + Type Layer Metin Katmanı Shape Layer Şekil Katmanı Group Layer Grup Katmanı Fill Layer Dolgu Katmanı Smart Object Layer Akıllı Nesne Katmanı - Create Warped Text Çarpık Metin Oluştur + Create Warped Text Çarpıtılmış Metin Oluştur Rasterize Smart Object Akıllı Nesneyi Rasterleştir Rasterize Fill Content Dolgu İçeriğini Rasterleştir Rasterize Layer Style Katman Stilini Rasterleştir @@ -2065,8 +2065,8 @@ Diameter: Çap: Hardness: Sertlik: No brush presets Fırça ön ayarı yok - Toggle the Brush Settings panel (F5) Fırça Ayarları panelini açın/kapatın (F5) - Create new brush preset from the current settings Geçerli ayarlardan yeni fırça hazır ayarı oluşturun + Toggle the Brush Settings panel (F5) Fırça Ayarları panelini aç/kapat (F5) + Create new brush preset from the current settings Geçerli ayarlardan yeni fırça ön ayarı oluştur Brush Settings… (F5): every option of this brush Fırça Ayarları… (F5): bu fırçanın tüm seçenekleri Rename Brush… Fırçayı Yeniden Adlandır… Delete Brush Fırçayı Sil @@ -2089,34 +2089,34 @@ Color Replacement Tool Renk Değiştirme Aracı Wet Islaklık Mix Karışım - Wetness Jitter Islaklık Titreme + Wetness Jitter Islaklık Titreşimi Mix Jitter Karıştırma Titreşimi Show the layer mask as an overlay Katman maskesini kaplama olarak göster Toggle visibility Görünürlüğü aç/kapat Unlink the mask from the layer Maskenin katmanla bağlantısını kaldırın - Link the mask to the layer Maskeyi katmana bağlama - Gradient Gradyan + Link the mask to the layer Maskeyi katmana bağla + Gradient Degrade Gradient Options Degrade Seçenekleri Align with layer Katmanla hizala Reset Alignment Hizalamayı Sıfırla Centre the gradient (offset 0, 0) Degradeyi ortalayın (kaydırma 0, 0) Gradient Editor Degrade Düzenleyici Gradient picker Degrade seçici - Gradient stops Gradyan durakları + Gradient stops Degrade durakları Save As Preset Ön Ayar Olarak Kaydet The gradient the Gradient tool paints with Degrade aracının boyadığı degrade Adds the current gradient to the Gradients panel Geçerli degradeyi Degradeler paneline ekler The current gradient (pick one in Window › Gradients) Geçerli degrade (Pencere › Degradeler'den birini seçin) - Select a type layer to edit its character and paragraph settings. Karakter ve paragraf ayarlarını düzenlemek için bir yazı katmanı seçin. + Select a type layer to edit its character and paragraph settings. Karakter ve paragraf ayarlarını düzenlemek için bir metin katmanı seçin. Text color Metin rengi - Type Options Tip Seçenekleri + Type Options Metin Seçenekleri Anti-aliasing method Kenar yumuşatma yöntemi - Horizontal type Yatay tip - Vertical type Dikey tip + Horizontal type Yatay metin + Vertical type Dikey metin Fill path with foreground color Yolu ön plan rengiyle doldur Stroke path with brush Fırçayla kontur yolu Load path as a selection Yolu seçim olarak yükle - Make work path from selection Seçimden iş yolunu yap + Make work path from selection Seçimden çalışma yolu oluştur Add vector mask Vektör maskesi ekle Create new path Yeni yol oluştur Delete path Yolu sil @@ -2135,13 +2135,13 @@ Cancel opening Açılışı iptal et Cancelled opening {name} {name} açılışı iptal edildi The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. - The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. “{name}” belgesinin gömülü renk profili geçerli {mode} çalışma renk uzayıyla eşleşmiyor. Embedded: {embedded} Gömülü: {embedded} - Working: {working} Çalışma alanı: {working} + Working: {working} Çalışma renk uzayı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi Esc to cancel İptal etmek için Esc - Opened {name} {name} tarihinde açıldı + Opened {name} {name} açıldı Opening {name} {name} açılıyor Saved {name} {name} kaydedildi Saving {name} {name} kaydediliyor @@ -2158,7 +2158,7 @@ Chrome Krom Conté Crayon Conte Crayon Graphic Pen Grafik Kalemi - Halftone Pattern Noktalı Resim Deseni + Halftone Pattern Yarım Ton Deseni Note Paper Not Kağıdı Photocopy Fotokopi Plaster Alçı @@ -2168,26 +2168,26 @@ Water Paper Su Kağıdı Move Taşı Pencil Kalem - Clone Stamp Klon Damgası - Dodge Dodge - Burn Yanık + Clone Stamp Klonlama Damgası + Dodge Soldurma + Burn Yakma Quick Selection Hızlı Seçim - History Brush Tarih Fırçası + History Brush Geçmiş Fırçası Color Replacement Renk Değiştirme New Group Yeni Grup Layer Comp Katman Kompozisyonu Alpha Alfa - Make Yap - Model Modeli + Make Üretici + Model Model F F Artist Sanatçı Spot Color Nokta Rengi - Roman Roma + Roman Roman Bold Italic Kalın İtalik - Justification Gerekçe + Justification İki Yana Yaslama Symbols Semboller Punctuation Noktalama işaretleri - Make Work Path İş Yolu Yap + Make Work Path Çalışma Yolu Oluştur Make Selection Seçim Yap Fill Path Yolu Doldur Stroke Path Kontur Yolu @@ -2196,8 +2196,8 @@ H H Ruler Cetvel New Layer Comp Yeni Katman Kompozisyonu - Duplicate Layer Comp Yinelenen Katman Kompozisyonu - Rename Layer Comp Katman Kompozisyonunu Yeniden Adlandırın + Duplicate Layer Comp Katman Kompozisyonunu Çoğalt + Rename Layer Comp Katman Kompozisyonunu Yeniden Adlandır Apply Layer Comp Katman Kompozisyonunu Uygula Update Layer Comp Katman Kompozisyonunu Güncelle Method Yöntem @@ -2207,18 +2207,18 @@ Pixel Piksel Adjustment Ayarlama Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Noktaları sürükleyin (kement) veya tıklayın (çokgen) · {add} ekle · {sub} çıkar - 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 Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarları yeniden boyutlandırın ({ratio} oranı, {centre} merkez) · Çizim sırasında boşluk hareket eder · oklar iter · X yönünü değiştirir · {commit} gerçekleştirir · Esc iptal eder - {mode} Color · {bits} bit {mode} Renkli · {bits} bit - Scrubby Zoom Süpürge Yakınlaştırma + 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 Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarlar yeniden boyutlandırır ({ratio} oran, {centre} merkez) · çizerken Space taşır · ok tuşları hafifçe kaydırır · X yönü değiştirir · {commit} onaylar · Esc iptal eder + {mode} Color · {bits} bit {mode} Renk · {bits} bit + Scrubby Zoom Sürüklemeli Yakınlaştırma Auto Erase Otomatik Silme Color Adaptation Renk Uyarlaması - Preserve Transparency Şeffaflığı Koruyun + Preserve Transparency Saydamlığı Koru New Style… Yeni Stil… shortcut kısayol Saved Kaydedildi Arc Yay Arch Kemer - Bulge çıkıntı + Bulge Şişkinlik Shell Lower Kabuk Alt Kısmı Shell Upper Kabuk Üstü Flag Bayrak @@ -2233,35 +2233,35 @@ Rectangle Dikdörtgen New Channel Yeni Kanal Save Selection Seçimi Kaydet - Load Selection Yük Seçimi + Load Selection Seçimi Yükle @id edit.purge.undo Geri Al - Rendering Mode İşleme Modu + Rendering Mode Oluşturma Modu Automatic (recommended) Otomatik (önerilen) CPU / Compatibility CPU / Uyumluluk Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Otomatik, mümkün olduğunda GPU hızlandırmayı kullanır ve hatalarda CPU oluşturmaya geri döner. Switched to CPU rendering CPU oluşturmaya geçildi GPU acceleration could not continue. Your documents are unchanged. GPU hızlandırması devam edemedi. Belgeleriniz değişmedi. - PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü düzenleyicisini kullanıyor. Pencere hala grafik bağdaştırıcınızı kullanıyor olabilir. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü birleştiricisini kullanıyor. Pencere yine de grafik bağdaştırıcınızı kullanıyor olabilir. Retry GPU requires a restart. Save your work first. GPU'yu yeniden dene, yeniden başlatma gerektirir. Öncelikle çalışmanızı kaydedin. - Keep Using CPU CPU'yu Kullanmaya Devam Edin + Keep Using CPU CPU'yu Kullanmaya Devam Et Retry GPU GPU'yu yeniden dene Advanced Gelişmiş GPU Backend GPU Arka Ucu Details Ayrıntılar Rendering mode Oluşturma modu - Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kementi Dondur: Bir alanı dondurmak için sürükleyin, buzunu çözmek için Alt tuşuna basarak sürükleyin (L) + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kementi Dondur: bir alanı dondurmak için sürükleyin, çözmek için Alt tuşuyla sürükleyin (L) 3 x 3 3 x 3 4 x 4 4 x 4 5 x 5 5 x 5 Camera Raw Filter ({layer}) Camera Raw Filtresi ({layer}) Camera Raw ({name}) Camera Raw ({name}) - Edit Type Türü Düzenle + Edit Type Metni Düzenle Grid: Izgara: - Highlight Clipping Warning (O) Kırpılma Uyarısını Vurgula (O) + Highlight Clipping Warning (O) Parlak Ton Kırpma Uyarısı (O) Lasso around an area, then drag the selection Bir alanı kementle çevreleyin, ardından seçimi sürükleyin Patch: Yama: Shadow Clipping Warning (U) Gölge Kırpılması Uyarısı (U) - Show Lab Color Readouts Laboratuvar Renk Okumalarını Göster + Show Lab Color Readouts Lab Renk Okumalarını Göster Show Red at 3 o'clock Kırmızıyı saat 3 yönünde göster Show Selected Region Seçilen Bölgeyi Göster Show Skin Tone Indicator Cilt Tonu Göstergesini Göster @@ -2280,16 +2280,16 @@ No patterns Desen yok Enable {name} {name}'yi etkinleştir Hold Alt while drawing for straight segments Düz parçalar çizerken Alt tuşunu basılı tutun - Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çokgen noktalarını sürükleyin veya tıklayın · düz parçalar için kement sırasında Alt tuşunu basılı tutun · {add} ekleyin · çizim çıkarmadan önce {sub} + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çokgen noktalarını sürükleyin veya tıklayın · düz parçalar için kement sırasında Alt tuşunu basılı tutun · {add} ekler · çizimden önce {sub} çıkarır Extend Uzat Structure: Yapı: - Content-Aware Move Tool İçeriğe Uygun Taşıma Aracı + Content-Aware Move Tool İçeriğe Duyarlı Taşıma Aracı TIFF Options TIFF Seçenekleri Targa Options Targa Seçenekleri 24 bits/pixel 24 bit/piksel 32 bits/pixel 32 bit/piksel Including layers will increase file size. Katmanların eklenmesi dosya boyutunu artıracaktır. - Discard Layers and Save a Copy Katmanları Atın ve Bir Kopyasını Kaydedin + Discard Layers and Save a Copy Katmanları At ve Kopyasını Kaydet Don't show again Bir daha gösterme Save failed: {error} Kaydetme başarısız oldu: {error} Save cancelled Kaydetme iptal edildi @@ -2307,47 +2307,47 @@ Sort Sırala Username Kullanıcı adı Display name Görünen ad - Real name Gerçek isim + Real name Gerçek ad Reverse the order Sırayı tersine çevir - Grab bag Çantayı al + Grab bag Çeşitli Table Tablo Name (A–Z) Ad (A–Z) Merged PRs Birleştirilmiş PR'ler - Commits Taahhütler + Commits Commit'ler Lines added Satırlar eklendi Lines deleted Satırlar silindi - Line delta Çizgi deltası + Line delta Satır farkı Binary assets added İkili varlıklar eklendi Binary assets removed İkili varlıklar kaldırıldı - First commit İlk taahhüt + First commit İlk commit First İlk - Last commit Son taahhüt + Last commit Son commit Last Son - Contributors: {contributors} · Commits: {commits} Katkıda Bulunanlar: {contributors} · Taahhütler: {commits} + Contributors: {contributors} · Commits: {commits} Katkıda Bulunanlar: {contributors} · Commit'ler: {commits} No contributor data was built into this copy. Bu kopyaya hiçbir katkıda bulunan verisi eklenmedi. No model credits were built into this copy. Bu kopyaya hiçbir model kredisi eklenmemiştir. Company Şirket Version Sürüm - % of all commits Tüm taahhütlerin yüzdesi - Lines +/− Çizgiler +/− - @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR'ler, {commits} taahhütler, +{added} / −{deleted} satırlar (Δ {delta}), +{binAdded} / −{binDeleted} ikili varlıklar, {first} – {last} + % of all commits Tüm commit'lerin yüzdesi + Lines +/− Satırlar +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{binAdded} / −{binDeleted} ikili varlık, {first} – {last} Create new swatch Yeni renk örneği oluştur - New Swatch… Yeni Swatch… - New Swatch Group… Yeni Swatch Grubu… + New Swatch… Yeni Renk Örneği… + New Swatch Group… Yeni Renk Örneği Grubu… Import Swatches… Renk Örneklerini İçe Aktar… - Replace Swatches… Renk Örneklerini Değiştirin… + Replace Swatches… Renk Örneklerini Değiştir… Export Swatches… Renk Örneklerini Dışa Aktar… Export Swatches for Exchange… Değişim için Renk Örneklerini Dışa Aktar… Reset Swatches Renk Örneklerini Sıfırla Add to Swatches Renk Örneklerine Ekle - Blend If: Eğer Karışım: + Blend If: Koşullu Karıştırma: Channels: Kanallar: - Knockout Nakavt - Apply At Canvas Bounds Kanvas Sınırlarında Uygula + Knockout Delme + Apply At Canvas Bounds Tuval Sınırlarında Uygula Color Picker (Text Color) Renk Seçici (Metin Rengi) Color Picker ({name}) Renk Seçici ({name}) Color Picker (Fill Color) Renk Seçici (Dolgu Rengi) - Fill color Rengi doldur + Fill color Dolgu rengi Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) Stroke color Kontur rengi Pick screen color Ekran rengini seç @@ -2365,9 +2365,9 @@ Foreground Color Ön Plan Rengi Color… Renk… 50% Gray %50 Gri - {size} (was {old}) {size} ({old} idi) + {size} (was {old}) {size} (önceki: {old}) Cone Koni - Cone (Inverted) Koni (Ters Çevrilmiş) + Cone (Inverted) Koni (Ters) Domed Kubbeli Domed (Inverted) Kubbeli (Ters) Diagonal (Descending) Çapraz (Azalan) @@ -2380,13 +2380,13 @@ Cove - Deep Koy - Derin Cove - Shallow Koy - Sığ Half Round Yarım Yuvarlak - Ring - Double Yüzük - Çift Kişilik + Ring - Double Halka - Çift Rolling Slope - Descending Yuvarlanan Eğim - Azalan Rounded Steps Yuvarlak Adımlar - Sawtooth 1 Testere dişi 1 + Sawtooth 1 Testere Dişi 1 Red Eye Tool Kırmızı Göz Aracı Red Eye Kırmızı Göz - Pupil Size Öğrenci Boyutu + Pupil Size Göz Bebeği Boyutu Darken Amount Koyulaştırma Miktarı Click a red pupil to neutralize it Nötrleştirmek için kırmızı bir gözbebeğine tıklayın open source açık kaynak @@ -2400,18 +2400,18 @@ Black Matte Siyah Mat White Matte Beyaz Mat layerLabel No Color Renk Yok - Seafoam Deniz köpüğü - Indigo Çivit mavisi + Seafoam Deniz Köpüğü + Indigo Çivit Mavisi Fuchsia Fuşya Violet Menekşe Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine uymuyor. Tercihler'de Bellek kullanımını artırma › Performans, bütçeyi bu bilgisayarın belleğinin dörtte birine kadar artırır. - The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason} + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine sığmıyor. Tercihler › Performans bölümünde Bellek kullanımını artırmak bütçeyi bu bilgisayarın belleğinin dörtte birine kadar yükseltir. + The GPU compositor wasn't used: {reason} GPU birleştiricisi kullanılmadı: {reason} cameraRaw Light Açık Tonlar fontWeight Thin İnce -fontWeight ExtraLight Ekstra İnce +fontWeight ExtraLight Ekstra Hafif fontWeight Light Hafif fontWeight Regular Normal fontWeight Medium Orta @@ -2426,7 +2426,7 @@ fontWeight Italic İtalik Rotate View Görünümü Döndür Rotate View Tool Görünümü Döndürme Aracı Reset View Görünümü Sıfırla - Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkezin etrafında sürükleyin · Kaydırma 15° ile sınırlandırılır + Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkez etrafında sürükleyin · Shift 15° ile sınırlar @id select.all Tümünü Seç @id edit.stroke Kontur… @id edit.transform.scale Ölçekle @@ -2434,11 +2434,11 @@ cameraRaw Red Kırmızı cameraRaw Orange Turuncu cameraRaw Yellow Sarı cameraRaw Green Yeşil -cameraRaw Aqua Camgöbeği +cameraRaw Aqua Turkuaz cameraRaw Blue Mavi cameraRaw Purple Mor cameraRaw Magenta Macenta -cameraRaw Blending Karışım +cameraRaw Blending Karıştırma cameraRaw Luminance Parlaklık cameraRaw Temperature Sıcaklık cameraRaw Color Renk @@ -2454,12 +2454,12 @@ cameraRaw Color Renk Color Picker Renk Seçici Color Swatches Renk Örnekleri Linear Dodge Doğrusal Soldurma - Preview Image Resmin Önizlemesi + Preview Image Görüntü Önizlemesi Automatic Otomatik Save As Farklı Kaydet - Import… İçe aktar… + Import… İçe Aktar… Remove All Tümünü Kaldır - Unlock Kilidi aç + Unlock Kilidi Aç Enable Etkinleştir Enable animations Animasyonları etkinleştir Disable Devre dışı bırak @@ -2468,14 +2468,14 @@ cameraRaw Color Renk Clear Selection Seçimi Temizle Invert Selection Seçimi Ters Çevir Select Inverse Tersini Seç - Contract Sözleşme + Contract Daralt Sample Size Örnek Boyutu Strength Güç Gamma Gama Input Giriş Output Çıkış - Input Levels Giriş Seviyeleri - Output Levels Çıkış Seviyeleri + Input Levels Giriş Düzeyleri + Output Levels Çıkış Düzeyleri Layer Styles Katman Stilleri Blending Mode Karıştırma Modu Fill Color Dolgu Rengi @@ -2489,8 +2489,8 @@ cameraRaw Color Renk File Name Dosya Adı File Format Dosya Formatı Compression Sıkıştırma - Artboards Çalışma yüzeyleri - Snap to Grid Izgaraya Yasla + Artboards Çalışma Yüzeyleri + Snap to Grid Izgaraya Yapış Full Screen Tam Ekran Fullscreen Tam ekran Workspaces Çalışma alanları @@ -2502,9 +2502,9 @@ cameraRaw Color Renk New Layer Yeni Katman Duplicate Layer Katmanı Çoğalt Ungroup Grubu çöz - Lock Transparent Pixels Şeffaf Pikselleri Kilitle + Lock Transparent Pixels Saydam Pikselleri Kilitle Lock Image Pixels Görüntü Piksellerini Kilitle - Lock Position Kilit Konumu + Lock Position Konumu Kilitle Lock All Tümünü Kilitle Show Rulers Cetvelleri Göster Show Grid Izgarayı Göster @@ -2512,7 +2512,7 @@ cameraRaw Color Renk Page Setup… Sayfa Yapısı… Reset Preferences Tercihleri Sıfırla Documentation Dokümantasyon - Check for Updates Güncellemeleri Kontrol Et + Check for Updates Güncellemeleri Denetle Loading… Yükleniyor… Processing… İşleniyor… Saving… Kaydediliyor… @@ -2521,14 +2521,14 @@ cameraRaw Color Renk No document open Hiçbir belge açık değil No selection Seçim yok No layers Katman yok - No recent files Yeni dosya yok + No recent files Son dosya yok Unsupported file format Desteklenmeyen dosya biçimi Failed to open file Dosya açılamadı Failed to save file Dosya kaydedilemedi Operation canceled İşlem iptal edildi Changes have not been saved. Değişiklikler kaydedilmedi. - Are you sure? Emin misin? - Discard Changes Değişiklikleri İptal Et + Are you sure? Emin misiniz? + Discard Changes Değişiklikleri At Recent Files Son Dosyalar Open File Dosyayı Aç Save File Dosyayı Kaydet @@ -2544,12 +2544,12 @@ cameraRaw Color Renk Continue Devam et Skip Atla Stop Durdur - Resume Devam et + Resume Sürdür Reset All Tümünü Sıfırla Defaults Varsayılanlar Apply to All Tümüne Uygula Confirm Onayla - Finish Bitiş + Finish Bitir Back Geri Next Sonraki Previous Önceki @@ -2565,7 +2565,7 @@ cameraRaw Color Renk Change… Değiştir… Transformation Dönüşüm Scaling Ölçeklendirme - Canvas Size Kanvas Boyutu + Canvas Size Tuval Boyutu Percentage Yüzde Degrees Dereceler Middle Orta @@ -2575,7 +2575,7 @@ cameraRaw Color Renk Intensity Yoğunluk Sensitivity Hassasiyet Softness Yumuşaklık - Feather Radius Tüy Yarıçapı + Feather Radius Geçiş Yumuşatma Yarıçapı Color Profile Renk Profili Library Kütüphane Libraries Kütüphaneler @@ -2587,36 +2587,36 @@ cameraRaw Color Renk Invert Mask Maskeyi Ters Çevir Enable Mask Maskeyi Etkinleştir Disable Mask Maskeyi Devre Dışı Bırak - Apply Mask Maske Uygula - Import Settings Ayarları İçe Aktarma - Export Settings Ayarları Dışa Aktarma + Apply Mask Maskeyi Uygula + Import Settings Ayarları İçe Aktar + Export Settings Ayarları Dışa Aktar Shortcut Kısayol Shortcuts Kısayollar Keyboard Klavye Mouse Fare - Tablet tablet + Tablet Tablet Display Ekran - Panel Paneli + Panel Panel Hide Grid Izgarayı Gizle Fit to Window Pencereye Sığdır Dark Theme Koyu Tema - Light Theme Işık Teması + Light Theme Açık Tema Live Preview Canlı Önizleme - Before önce + Before Önce After Sonra Compare Karşılaştır File Size Dosya Boyutu Filename Dosya adı Folder Klasör Open Folder Klasörü Aç - Choose Folder Klasör Seçin - Browse Göz at + Choose Folder Klasör Seç + Browse Gözat Find Bul Replace Değiştir Category Kategori Date Tarih Time Zaman - Learn More Daha Fazla Bilgi Edinin + Learn More Daha Fazla Bilgi Al Report a Bug Hata Bildir Updates Güncellemeler Update Güncelleme @@ -2626,8 +2626,8 @@ cameraRaw Color Renk Quit PhotoCraft PhotoCraft'tan Çık Close Window Pencereyi Kapat New Window Yeni Pencere - Open Image Resmi Aç - Save Image Resmi Kaydet + Open Image Görüntüyü Aç + Save Image Görüntüyü Kaydet Export Image Görüntüyü Dışa Aktar Import Image Görüntüyü İçe Aktar Export As Farklı Dışa Aktar @@ -2636,16 +2636,16 @@ cameraRaw Color Renk Import Options İçe Aktarma Seçenekleri No Selection Seçim Yok No Document Belge Yok - No Image Resim Yok + No Image Görüntü Yok No Layers Katman Yok No Results Sonuç Yok No Results Found Sonuç Bulunamadı Nothing to Undo Geri Alınacak Bir Şey Yok - Nothing to Redo Tekrarlanacak bir şey yok + Nothing to Redo Yinelenecek Bir Şey Yok Changes Saved Değişiklikler Kaydedildi Unsaved Changes Kaydedilmemiş Değişiklikler Save before closing? Kapatmadan önce kaydet? - Are you sure you want to quit? Vazgeçmek istediğinden emin misin? + Are you sure you want to quit? Çıkmak istediğinizden emin misiniz? The file could not be opened. Dosya açılamadı. The file could not be saved. Dosya kaydedilemedi. Unsupported format Desteklenmeyen biçim @@ -2658,20 +2658,20 @@ cameraRaw Color Renk Camera Calibration Kamera Kalibrasyonu Snapshots Anlık görüntüler Recovery Kurtarma - Fill Light Işık Doldur + Fill Light Dolgu Işığı Convert to Grayscale Gri Tonlamaya Dönüştür Sharpness Keskinlik - Color Noise Reduction Renk Gürültü Azaltma - Luminance Smoothness Parlaklık Pürüzsüzlüğü - Focal Length35mm Odak Uzunluğu35mm + Color Noise Reduction Renk Gürültüsü Azaltma + Luminance Smoothness Lüminans Yumuşaklığı + Focal Length35mm Odak Uzunluğu (35 mm) Aspect Ratio En Boy Oranı Enter description… Açıklamayı girin… Industry Standard Endüstri Standardı Uncoated CMYK Kaplamasız CMYK - Working Spaces Çalışma Alanları + Working Spaces Çalışma Renk Uzayları Color Management Policies Renk Yönetimi Politikaları Detect Faces Yüzleri Algıla - Localize Color Clusters Renk Kümelerini Yerelleştirin + Localize Color Clusters Renk Kümelerini Yerelleştir Duplicated selection name Yinelenen seçim adı New channel only Yalnızca yeni kanal Masked Areas Maskelenmiş Alanlar @@ -2691,12 +2691,12 @@ cameraRaw Color Renk View menu Menüyü görüntüle Keep view in sync Görünümü senkronize tutun Mouse Controls Fare Kontrolleri - Initial Direction of Stroke Başlangıç Strok Yönü - Direction of Stroke İnme Yönü + Initial Direction of Stroke Başlangıç Vuruş Yönü + Direction of Stroke Vuruş Yönü Humidity Nem Auto Clean Brush Otomatik Temizleme Fırçası Brush Tip Fırça Ucu - New Layer from Brush Brush'tan Yeni Katman + New Layer from Brush Fırçadan Yeni Katman New Brush Yeni Fırça New Tool Preset Yeni Araç Ön Ayarı Auto-Leading Otomatik Satır Aralığı @@ -2704,39 +2704,39 @@ cameraRaw Color Renk Scale Vertically Dikey Ölçeklendir Baseline Shift Taban Çizgisi Kayması Superscript Üst simge - Subscript Abonelik - Ligature Ligatür + Subscript Alt simge + Ligature Bitişik Harf Alternative Alternatif - Extra Light Ekstra Işık + Extra Light Ekstra Hafif Extra Bold Ekstra Kalın Sharpen Amount Keskinleştirme Miktarı - Sharpen Radius Yarıçapı Keskinleştir - Sharpen Detail Detayı Keskinleştir - Sharpen Masking Maskelemeyi Keskinleştir - Noise Luminance Gürültü Parlaklığı - Noise Luminance Detail Gürültü Parlaklığı Ayrıntısı + Sharpen Radius Keskinleştirme Yarıçapı + Sharpen Detail Keskinleştirme Ayrıntısı + Sharpen Masking Keskinleştirme Maskelemesi + Noise Luminance Gürültü Lüminansı + Noise Luminance Detail Gürültü Lüminansı Ayrıntısı Noise Color Gürültü Rengi Noise Color Detail Gürültü Rengi Ayrıntısı Grain Amount Tane Miktarı Grain Size Tane Boyutu - Grain Roughness Tahıl Pürüzlülüğü + Grain Roughness Tane Pürüzlülüğü Vignette Roundness Vinyet Yuvarlaklığı - Vignette Feather Vinyet Tüyü - Vignette Highlights Vinyet Öne Çıkanlar + Vignette Feather Vinyet Geçiş Yumuşatma + Vignette Highlights Vinyet Parlak Tonlar Vignette Style Vinyet Stili - Planes Uçaklar + Planes Düzlemler Corners Köşeler - Plane Uçak + Plane Düzlem Shadow Amount Gölge Miktarı Shadow Tone Gölge Tonu Shadow Radius Gölge Yarıçapı - Highlight Amount Vurgu Miktarı - Highlight Tone Vurgu Tonu - Highlight Radius Yarıçapı Vurgula + Highlight Amount Parlak Ton Miktarı + Highlight Tone Parlak Ton Tonu + Highlight Radius Parlak Ton Yarıçapı Midtone Orta ton Black Clip Siyah Klip White Clip Beyaz Klip - Fuzziness bulanıklık + Fuzziness Bulanıklık Source Layer Kaynak Katmanı Neutralize Nötrleştir Use Selection In Source Kaynaktaki Seçimi Kullan @@ -2764,18 +2764,18 @@ cameraRaw Color Renk Leaves Size Yaprak Boyutu Branches Height Dal Yüksekliği Branches Thickness Dal Kalınlığı - Default Leaves Varsayılan İzinler + Default Leaves Varsayılan Yapraklar Leaves Color Yaprak Rengi - Custom Branch Color Özel Şube Rengi + Custom Branch Color Özel Dal Rengi Branches Color Dal Rengi Flat Shading Düz Gölgeleme Enable Timeline Quick Config Zaman Çizelgesi Hızlı Yapılandırmasını Etkinleştir Set Start Timecode… Başlangıç Zaman Kodunu Ayarla… Timecode Zaman kodu - Current Time Güncel Saat + Current Time Geçerli Zaman Duration Süre Repeat Tekrarla - Play Once Bir Kez Oyna + Play Once Bir Kez Oynat Info Options… Bilgi Seçenekleri… First Color Readout İlk Renk Okuması Second Color Readout İkinci Renk Okuması @@ -2791,7 +2791,7 @@ cameraRaw Color Renk Ruler Units Cetvel Birimleri Restore last viewed comp Son görüntülenen kompozisyonu geri yükle Select All Layers Tüm Katmanları Seç - Keep proportions Oranları koruyun + Keep proportions Oranları koru Interpolation Enterpolasyon Caption Başlık Caption Credits Altyazı Kredisi @@ -2812,8 +2812,8 @@ cameraRaw Color Renk City Şehir Province / State İl / Eyalet Country Ülke - Byline Yazar yazısı - Byline Title Yazar Başlığı + Byline Yazar Satırı + Byline Title Yazar Satırı Unvanı Phone Telefon Email E-posta Last Modified Son Değiştirilme @@ -2821,13 +2821,13 @@ cameraRaw Color Renk Camera Model Kamera Modeli Camera Make Kamera Markası EXIF Version EXIF Sürümü - Exposure Program Maruziyet Programı + Exposure Program Pozlama Programı Aperture Diyafram Shutter Speed Enstantane Hızı Metering Mode Ölçüm Modu Light Source Işık Kaynağı Flash Flaş - Create Multiple Picture Packages Çoklu Resim Paketleri Oluşturun + Create Multiple Picture Packages Çoklu Resim Paketi Oluştur Choose… Seç… Select Folder… Klasör Seç… Override Action "Save" Commands Eylem "Kaydet" Komutlarını Geçersiz Kıl @@ -2838,15 +2838,15 @@ cameraRaw Color Renk Stop For Errors Hatalar İçin Durdur Log Errors To File Hataları Dosyaya Günlüğe Kaydet Settings: Ayarlar: - Working Space Çalışma Alanı + Working Space Çalışma Renk Uzayı Advanced Controls Gelişmiş Kontroller Desaturate Monitor Colors By Monitör Renklerinin Doygunluğunu Azaltma - Blend RGB Colors Using Gamma 1.0 Gama 1.0 Kullanarak RGB Renklerini Karıştırın + Blend RGB Colors Using Gamma 1.0 Gama 1.0 Kullanarak RGB Renklerini Karıştır Profile Mismatches: Ask When Opening Profil Uyuşmazlıkları: Açarken Sor Ask When Pasting Yapıştırırken Sor Missing Profiles: Ask When Opening Eksik Profiller: Açarken Sor - Embed profiles Profilleri yerleştir - Coerce to sRGB sRGB'ye baskı yapın + Embed profiles Profilleri göm + Coerce to sRGB sRGB'ye zorla Graphic and web Grafik ve web Minimalistic Minimalist Expand All Groups Tüm Grupları Genişlet @@ -2859,30 +2859,30 @@ cameraRaw Color Renk Create new count group Yeni sayım grubu oluştur Drag to move the layer Katmanı taşımak için sürükleyin Go to Layer Katmana Git - Current Mevcut + Current Geçerli Reference point location Referans noktası konumu Toggle reference point Referans noktasını değiştir - Heal Off İyileşme - Heal On İyileşme - Heal Auto Otomatik İyileştirme + Heal Off Düzeltme Kapalı + Heal On Düzeltme Açık + Heal Auto Otomatik Düzeltme Brush Size Fırça Boyutu - Heal Color Rengi İyileştir + Heal Color Rengi Düzelt Result Sonuç Fast Hızlı - Accurate Doğru - Add to Ekle + Accurate Kesin + Add to Şuna ekle: Subtract from Şundan çıkar: - Intersect with İle kesiştir - Capture Comp Comp'ı Yakala + Intersect with Şununla kesiştir: + Capture Comp Kompozisyonu Yakala No Layer Comps Katman Kompozisyonu Yok Add to selection (⇧) Seçime ekle (⇧) Click to zoom in · ⌥-click to zoom out Yakınlaştırmak için tıklayın · Uzaklaştırmak için ⌥-tıklayın - Commit Perspective Warp (↩) Perspektif Çarpıtma İşlemi (↩) - Commit Puppet Warp (↩) Kukla Çarpıtma İşlemi (↩) - Commit any current edits (⌘↩) Mevcut düzenlemeleri taahhüt et (⌘↩) + Commit Perspective Warp (↩) Perspektif Çarpıtmayı Uygula (↩) + Commit Puppet Warp (↩) Kukla Çarpıtmayı Uygula (↩) + Commit any current edits (⌘↩) Geçerli düzenlemeleri onayla (⌘↩) Commit current crop operation (↵) Mevcut kırpma işlemini gerçekleştir (↵) - Commit transform (↩) Dönüşümü kaydet (↩) - Commit warp (↩) Çözgü gerçekleştir (↩) + Commit transform (↩) Dönüşümü onayla (↩) + Commit warp (↩) Çarpıtmayı onayla (↩) Create a new layer (⇧⌘N) Yeni bir katman oluştur (⇧⌘N) Custom pattern Özel desen General Brushes Genel Fırçalar @@ -2907,7 +2907,7 @@ cameraRaw Color Renk In Black Siyah In White Beyaz Out Black Siyah - Out White Beyaz Dışı + Out White Çıkış Beyazı Tint Color Ton Rengi Stops Duraklar Center X Merkez X @@ -2915,11 +2915,11 @@ cameraRaw Color Renk Distribution Dağıtım Monochromatic Tek renkli Cell Size Hücre Boyutu - Generators Jeneratörler - Wavelength Min Dalga Boyu Min + Generators Oluşturucular + Wavelength Min Minimum Dalga Boyu Wavelength Max Maksimum Dalga Boyu - Amplitude Min Genlik Min - Amplitude Max Genlik Maksimum + Amplitude Min Minimum Genlik + Amplitude Max Maksimum Genlik Undefined Areas Tanımsız Alanlar Preserve Koru Max Radius Maksimum Yarıçap @@ -2928,10 +2928,10 @@ cameraRaw Color Renk Mask Incomplete Blocks Tamamlanmamış Blokları Maskele Stylization Stilizasyon Cleanliness Temizlik - Bristle Detail Kıl Detayı + Bristle Detail Kıl Ayrıntısı Lighting Aydınlatma Shine Parıltı - Max Offset Maksimum Ofset + Max Offset Maksimum Kaydırma Map Document Harita Belgesi Map Path Harita Yolu Map Layer Harita Katmanı @@ -2939,20 +2939,20 @@ cameraRaw Color Renk Light Type Işık Tipi Light X Işık X Light Y Işık Y - Light Z Açık Z + Light Z Işık Z Target X Hedef X Target Y Hedef Y Hotspot Sıcak nokta Elevation Yükseklik Ambient Ortam - Warmth sıcaklık - Gloss parlaklık - Metallic metalik - Ambience ortam + Warmth Sıcaklık + Gloss Parlama + Metallic Metalik + Ambience Ortam White Is High Beyaz Yüksek - Reduce Color Noise Renk Gürültüsünü Azaltın + Reduce Color Noise Renk Gürültüsünü Azalt Sharpen Details Ayrıntıları Keskinleştir - Remove Jpeg Artifact Jpeg Yapısını Kaldır + Remove Jpeg Artifact JPEG Bozulmasını Kaldır Remove Tool Aracı Kaldır Blade Curvature Bıçak Eğrisi Depth Map Derinlik Haritası @@ -2960,42 +2960,42 @@ cameraRaw Color Renk Focus Odaklanma Transition Geçiş Radius X Yarıçap X - Radius Y Y Yarıçapı + Radius Y Yarıçap Y Pins Pimler Blur Angle Bulanıklık Açısı Speed Hız Taper Konik - Start X X'i başlat - Start Y Y'yi başlat - End X X'in sonu - End Y Y Sonu - Kernel çekirdek + Start X Başlangıç X + Start Y Başlangıç Y + End X Bitiş X + End Y Bitiş Y + Kernel Çekirdek Input Mode Giriş Modu Row Order Satır Sırası Eliminate Ortadan kaldır Create By Oluşturan List Liste Pencil Width Kalem Genişliği - Stroke Pressure Strok Basıncı + Stroke Pressure Vuruş Basıncı Paper Brightness Kağıt Parlaklığı - Number Of Levels Seviye Sayısı + Number Of Levels Düzey Sayısı Edge Simplicity Kenar Sadeliği Edge Fidelity Kenar Doğruluğu - Brush Detail Fırça Detayı - Highlight Area Alanı Vurgula - Glow Size Parıltı Boyutu - Glow Brightness Parıltı Parlaklığı + Brush Detail Fırça Ayrıntısı + Highlight Area Vurgu Alanı + Glow Size Işıma Boyutu + Glow Brightness Işıma Parlaklığı Brush Type Fırça Tipi - Stroke Size Kontur Boyutu - Stroke Detail Kontur Detayı - Highlight Strength Gücü Vurgula + Stroke Size Vuruş Boyutu + Stroke Detail Vuruş Ayrıntısı + Highlight Strength Vurgu Gücü Smoothness Pürüzsüzlük Edge Thickness Kenar Kalınlığı Edge Intensity Kenar Yoğunluğu Posterization Posterleştirme - Stroke Length Strok Uzunluğu - Relief Rahatlama - Definition Tanım + Stroke Length Vuruş Uzunluğu + Relief Rölyef + Definition Belirginlik Texture Coverage Doku Kapsamı Shadow Intensity Gölge Yoğunluğu Edge Width Kenar Genişliği @@ -3003,18 +3003,18 @@ cameraRaw Color Renk Direction Balance Yön Dengesi Black Intensity Siyah Yoğunluğu White Intensity Beyaz Yoğunluğu - Dark Intensity Karanlık Yoğunluk + Dark Intensity Koyu Yoğunluğu Custom Pixel Aspect Ratio Özel Piksel En Boy Oranı 32-bit Preview Options 32 bit Önizleme Seçenekleri New Guide Yeni Kılavuz - Join the ArtCraft Discord ArtCraft Discord'una katılın + Join the ArtCraft Discord ArtCraft Discord'una Katıl Report an Issue Sorun Bildir Load Files into Stack Dosyaları Yığına Yükle Statistics İstatistikler Duplicate Layers Katmanları Çoğalt Edit Toolbar Araç Çubuğunu Düzenle Import Brushes Fırçaları İçe Aktar - Load 3D LUT 3D LUT'u yükleyin + Load 3D LUT 3D LUT'u Yükle New document Yeni belge Press keys Tuşlara basın Rename Group Grubu Yeniden Adlandır @@ -3037,7 +3037,7 @@ cameraRaw Color Renk Bleach Bypass Ağartma Baypası Blocks Bloklar Brick Tuğla - Brush Strokes Fırça Darbeleri + Brush Strokes Fırça Vuruşları Burlap Çuval bezi Candle Light Mum Işığı Circle daire @@ -3045,23 +3045,23 @@ cameraRaw Color Renk Coarse Dots Kaba Noktalar Colored Pencil Renkli Kalem Contrasty Kontrastlı - Cool Harika + Cool Soğuk Craquelure Küçük çatlak Cross Çapraz - Cross Process Çapraz Süreç + Cross Process Çapraz İşleme Crosshatch Çapraz tarama Cutout Kesim Daisies Papatyalar - Dark Rough Karanlık Sert + Dark Rough Koyu Kaba Dark Strokes Karanlık Vuruşlar Darken Only Yalnızca Koyulaştır Day For Night Gündüz Gece - Deep Blue Derin Mavi - Deep Emerald Derin Zümrüt + Deep Blue Koyu Mavi + Deep Emerald Koyu Zümrüt Deep Red Koyu Kırmızı Deep Yellow Koyu Sarı Dot Nokta - Double Line Çift Hat + Double Line Çift Çizgi Draft Taslak Dry Brush Kuru Fırça Duplication Çoğaltma @@ -3070,15 +3070,15 @@ cameraRaw Color Renk Enlarged Büyütülmüş Even Fields Çift Alanlar Faded Film Solmuş Film - Film Grain Film Tahıl - Fine iyi + Film Grain Film Tanesi + Fine İnce Fine Dots İnce Noktalar Flat Düz Fresco Fresk From Left Soldan From Right Sağdan Frosted Buzlu - Generic jenerik + Generic Genel Glass Cam Glowing Edges Parlayan Kenarlar Grainy Dots Grenli Noktalar @@ -3090,7 +3090,7 @@ cameraRaw Color Renk Ink Outlines Mürekkep Ana Hatları Ivy sarmaşık Left Diagonal Sol Çapraz - Level Based Seviyeye Dayalı + Level Based Düzeye Dayalı Light Rough Hafif Kaba Lighten Only Yalnızca Açıklaştır Long Lines Uzun Çizgiler @@ -3114,14 +3114,14 @@ cameraRaw Color Renk Palette Knife Palet Bıçağı Parallel paralel Patchwork yama işi - Pentagon Pentagon + Pentagon Beşgen Point Nokta Pointed Sivri Pond Ripples Gölet Dalgaları Pyramids Piramitler Random Rastgele - Right Diagonal Sağ Diyagonal - Ring Yüzük + Right Diagonal Sağ Çapraz + Ring Halka Rope Halat Roses Güller Sandstone Kumtaşı @@ -3132,7 +3132,7 @@ cameraRaw Color Renk Simple Basit Light Intensity Işık Yoğunluğu Spray Radius Püskürtme Yarıçapı - Stroke Direction Strok Yönü + Stroke Direction Vuruş Yönü Graininess Taneciklilik Glow Amount Işıma Miktarı Clear Amount Temizleme Miktarı @@ -3143,48 +3143,48 @@ cameraRaw Color Renk Chalk Area Tebeşir Alanı Charcoal Thickness Kömür Kalınlığı Light Dark Balance Açık Karanlık Dengesi - Foreground Level Ön Plan Seviyesi + Foreground Level Ön Plan Düzeyi Background Level Arka Plan Düzeyi Pattern Type Desen Türü Image Balance Görüntü Dengesi Darkness Karanlık - Fiber Length Elyaf Uzunluğu + Fiber Length Lif Uzunluğu Crack Spacing Çatlak Aralığı Crack Depth Çatlak Derinliği Crack Brightness Çatlak Parlaklığı - Grain Type Tahıl Türü + Grain Type Tane Türü Tile Size Döşeme Boyutu Grout Width Derz Genişliği - Lighten Grout Harcı hafifletmek + Lighten Grout Derzi Açıklaştır Square Size Kare Boyutu - Border Thickness Kenar Kalınlığı - Correct Distortion Bozulmanın Düzeltilmesi - Correct Vignette Doğru Vinyet - Correct C A Doğru CA + Border Thickness Kenarlık Kalınlığı + Correct Distortion Bozulmayı Düzelt + Correct Vignette Vinyeti Düzelt + Correct C A Kromatik Sapmayı Düzelt Auto Scale Otomatik Ölçeklendirme Red Cyan Kırmızı Camgöbeği Blue Yellow Mavi Sarı Vignette Amount Vinyet Miktarı Vignette Midpoint Vinyet Orta Noktası Constraints Kısıtlamalar - Curve Highlights Eğrinin Öne Çıkan Noktaları - Curve Lights Eğri Işıkları - Curve Darks Eğri Koyuları + Curve Highlights Eğri Parlak Tonlar + Curve Lights Eğri Açık Tonlar + Curve Darks Eğri Koyu Tonlar Curve Shadows Eğri Gölgeleri Curve Splits Eğri Bölmeleri Point Curve Nokta Eğrisi Point Curve Red Nokta Eğrisi Kırmızı Point Curve Green Nokta Eğrisi Yeşil Point Curve Blue Nokta Eğrisi Mavi - Hsl Hue Hsl Ton - Hsl Sat HSL Cumartesi - Hsl Lum Hsl Lum - Grade Shadows Sınıf Gölgeleri - Grade Midtones Derece Orta Tonları - Grade Highlights Notun Öne Çıkan Noktaları - Grade Global Küresel Sınıf - Grade Blending Sınıf Harmanlama - Grade Balance Not Dengesi + Hsl Hue HSL Ton + Hsl Sat HSL Doygunluk + Hsl Lum HSL Lüminans + Grade Shadows Derecelendirme Gölgeleri + Grade Midtones Derecelendirme Orta Tonları + Grade Highlights Derecelendirme Parlak Tonları + Grade Global Genel Derecelendirme + Grade Blending Derecelendirme Karıştırma + Grade Balance Derecelendirme Dengesi Sine sinüs Sketch Eskiz Snowflakes Kar taneleri @@ -3199,15 +3199,15 @@ cameraRaw Color Renk Star Yıldız Stars Yıldızlar Stippled noktalanmış - Stretch Streç - Teal Orange turkuaz turuncu + Stretch Esnet + Teal Orange Turkuaz Turuncu Tiny Lens Minik Mercek To Center Merkeze Triangle Üçgen Unaltered değiştirilmemiş Underpainting Alt boyama Underwater Sualtı - Uniform Üniforma + Uniform Tekdüze Upper Üst Vertical Only Yalnızca Dikey Vine With Flowers Çiçekli Asma @@ -3237,26 +3237,26 @@ cameraRaw Color Renk Picture Package… Resim Paketi… Web Photo Gallery… Web Fotoğraf Galerisi… Gray: Gri: - Spot: Nokta: + Spot: Spot: Technology Previews… Teknoloji Önizlemeleri… - 3D… 3 boyutlu… + 3D… 3B… AI Integrations Yapay Zeka Entegrasyonları Add Noise Gürültü Ekle - Add Pins Pin Ekle + Add Pins Pim Ekle Add a mask (from the selection; ⌥ inverts) Bir maske ekleyin (seçimden; ⌥ tersine çevirir) Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler. - Adobe PDF Presets… Adobe PDF Hazır Ayarları… - Advanced Blending Gelişmiş Harmanlama + Adobe PDF Presets… Adobe PDF Ön Ayarları… + Advanced Blending Gelişmiş Karıştırma After (X) (X)'ten sonra Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder. - Alternates for Current Selection Mevcut Seçimin Alternatifleri + Alternates for Current Selection Geçerli Seçimin Alternatifleri Apply Data Set Veri Kümesini Uygula - Apply Image Resmi Uygula + Apply Image Görüntüyü Uygula Arbitrary keyfi Artboard from Group Gruptan çalışma yüzeyi Artboard from Layers Katmanlardan Çalışma Yüzeyi Artboards to Files Çalışma Yüzeylerinden Dosyalara - Artboards to PDF Çalışma yüzeylerini PDF'ye dönüştürme + Artboards to PDF Çalışma Yüzeylerinden PDF'ye Ask Before Saving Saved Embedded Kaydedilen Gömülü Kaydetmeden Önce Sor Assign Profile Profil Ata At Şu tarihte: @@ -3265,56 +3265,56 @@ cameraRaw Color Renk Auto-Blend Layers Katmanları Otomatik Karıştır Auto-Hide Otomatik Gizle Auto-leading % Otomatik satır aralığı % - Avoid Widows/Orphans Dul/Yetimlerden Kaçının + Avoid Widows/Orphans Dul/Yetim Satırlardan Kaçın Background from Layer Katmandan Arka Plan Batch Toplu Blend Karışım - Border Sınır + Border Kenarlık Box Blur Kutu Bulanıklığı Calculations Hesaplamalar Camera Raw Camera Raw Camera Raw Filter Camera Raw Filtresi - Canvas rotation Kanvas döndürme - Channel1 Kanal1 - Channel2 Kanal2 - Channel3 Kanal3 - Channel4 Kanal4 + Canvas rotation Tuval döndürme + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 Clipping Path Kırpma Yolu Clone Klon Color Decontaminate Renkli Dekontaminasyon - Color Halftone Renk Noktalı Resim + Color Halftone Renk Yarım Tonu Color Lookup Tables Renk Arama Tabloları Color Settings Renk Ayarları Color Table Renk Tablosu Compose Oluştur - Condensed Yoğunlaştırılmış + Condensed Dar Conditional Mode Change Koşullu Mod Değişikliği - Content-Aware Fill İçeriğe Uygun Dolgu - Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları + Content-Aware Fill İçeriğe Duyarlı Dolgu + Convert Frames > Video Layers Kareleri Dönüştür > Video Katmanları Convert to Linked Bağlantılıya Dönüştür Convert to Profile Profile Dönüştür - Create Date Tarih Oluştur + Create Date Oluşturma Tarihi Create Plane Düzlem Oluştur Create new layer Yeni katman oluştur Create new set Yeni grup oluştur Crystallize Kristalleş Data Veri Data Sets as Files Dosya Olarak Veri Kümeleri - DateTime Original TarihSaat Orijinal + DateTime Original Orijinal Tarih/Saat De-Interlace Taramasızlaştırma Defaults restored. Varsayılanlar geri yüklendi. Define Brush Preset Fırça Ön Ayarını Tanımla Define Custom Shape Özel Şekli Tanımla Define Pattern Deseni Tanımla - Defringe Bordürleri kaldır - Delete Pins Pinleri Sil - Diffuse Yaygın - Diffuse Glow Yaygın Parıltı + Defringe Kenar Halesini Kaldır + Delete Pins Pimleri Sil + Diffuse Yay + Diffuse Glow Dağınık Işıma Digits Rakamlar - Displace Yer değiştir - Do you want to save you made to “{name}” before quitting? Çıkmadan önce yaptığınız işi “{name}” konumuna kaydetmek istiyor musunuz? + Displace Yer Değiştir + Do you want to save you made to “{name}” before quitting? Çıkmadan önce “{name}” belgesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? Dodge/Burn Soldurma/Yakma - Duplicate Path Yinelenen Yol + Duplicate Path Yolu Çoğalt Dust & Scratches Toz ve Çizikler Dynamics Dinamik Edit Plane Düzlemi Düzenle @@ -3329,28 +3329,28 @@ cameraRaw Color Renk Filter Gallery Filtre Galerisi Find and Replace Text Metni Bul ve Değiştir First Line Indent İlk Satır Girintisi - Fit Image Resmi Sığdır + Fit Image Görüntüyü Sığdır Flame Alev - Flatten Frames Into Clips Çerçeveleri Klipsler halinde Düzleştirin - Flatten Frames Into Layers Çerçeveleri Katmanlara Düzleştirin + Flatten Frames Into Clips Kareleri Kliplere Düzleştir + Flatten Frames Into Layers Kareleri Katmanlara Düzleştir Focus Area Odak Alanı Follows only edges this close to the pointer ([ and ] change it) Yalnızca işaretçiye bu kadar yakın olan kenarları takip eder ([ ve ] bunu değiştirir) Frame Duration Kare Süresi - Frame Sync Çerçeve Senkronizasyonu - Gaussian Gaussian + Frame Sync Kare Senkronizasyonu + Gaussian Gauss Gaussian Blur Gauss Bulanıklığı General Blend Genel Karışım - Global Light Küresel Işık + Global Light Genel Işık Glyph Glif Glyph Scaling Glif Ölçekleme - Group from Layers Katmanlardan Gruplandır + Group from Layers Katmanlardan Grup Group · {n} layer Grup · {n} katman HDR Toning HDR Tonlama - HSB Color HSB Rengi - HSL/Grayscale HSL/Gri tonlamalı + HSB Color HSB Renk + HSL/Grayscale HSL/Gri Tonlamalı Hanging Punctuation Asılı Noktalama İşaretleri - Heal İyileşmek - Healing Şifa + Heal Düzelt + Healing Düzeltme Hide the layer's effects (⌥-click: all layers) Katmanın efektlerini gizleyin (⌥ tuşuna basarak tıklayın: tüm katmanlar) High Pass Yüksek Geçiş Higher values follow only edges that contrast sharply with their surroundings Daha yüksek değerler yalnızca çevreleriyle keskin bir kontrast oluşturan kenarları takip eder @@ -3364,14 +3364,14 @@ cameraRaw Color Renk Intersect with selection (⇧⌥) Seçimle kesiştir (⇧⌥) Iris Blur İris Bulanıklığı Jag Jag - Justify Gerekçelendir - Keeps the current texture when switching presets. Ön ayarları değiştirirken mevcut dokuyu korur. + Justify İki Yana Yasla + Keeps the current texture when switching presets. Ön ayarları değiştirirken geçerli dokuyu korur. Label Etiket - Layer Comps to Files Dosyalara Katman Kompozisyonları + Layer Comps to Files Katman Kompozisyonlarından Dosyalara Layer Content Options Katman İçeriği Seçenekleri Layer From Background Arka Plandan Katman Layer from Background Arka Plandan Katman - Layers to Files Dosyalara Katmanlar + Layers to Files Katmanlardan Dosyalara Legacy Eski Lens Objektif Lens Blur Mercek Bulanıklığı @@ -3385,16 +3385,16 @@ cameraRaw Color Renk Liquify Sıvılaştır Lock Layers Katmanları Kilitle Loop Döngü - Lum Lum + Lum Lüminans Match Color Rengi Eşleştir Measure Ölçü Merge to HDR Pro HDR Pro'ya Birleştir Mezzotint Mezotint - Modify Date Tarihi Değiştir + Modify Date Değiştirilme Tarihi Mosaic Mozaik Motion Blur Hareket Bulanıklığı Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosya › Aç'ı kullanın veya PhotoCraft'ı XWayland altında `WAYLAND_DISPLAY= photocraft` ile çalıştırın. - Neon Glow Neon Parıltı + Neon Glow Neon Işıma New Guide Layout Yeni Kılavuz Düzeni New Path Yeni Yol New Video Group Yeni Video Grubu @@ -3409,47 +3409,47 @@ cameraRaw Color Renk Package Paket Paint Daubs Boya Lekeleri Path Blur Yol Bulanıklığı - Path to Illustrator… İllüstratöre Giden Yol… + Path to Illustrator… Yoldan Illustrator'a… Path: Yol: - Paths to Illustrator Illustrator'a Giden Yollar + Paths to Illustrator Yollardan Illustrator'a Photomerge Fotoğraf Birleştir Picture Frame Resim Çerçevesi - Pinch Tutam - Place Embedded Gömülü Yer - Place Linked Bağlantılı Yer - Place Scale Marker Ölçek İşaretçisini Yerleştirin + Pinch Sıkıştır + Place Embedded Gömülü Olarak Yerleştir + Place Linked Bağlantılı Olarak Yerleştir + Place Scale Marker Ölçek İşaretçisini Yerleştir Plastic Wrap Plastik Sarma Pointillize Noktalaştır Polar Coordinates Kutupsal Koordinatlar - Polar To Rectangular Polar'dan Dikdörtgen'e + Polar To Rectangular Kutupsaldan Dikdörtgene Poster Edges Poster Kenarları Preset Manager Ön Ayar Yöneticisi Profile Profil Quick Sync Hızlı Senkronizasyon - Radial Blur Radyal Bulanıklık + Radial Blur Radyal Bulanıklığı Raw Data Ham Veri Raw Defaults Ham Varsayılanlar - Rectangular To Polar Dikdörtgenden Polar'a - Reduce Noise Gürültüyü Azaltın - Relink to File Dosyaya Yeniden Bağlan + Rectangular To Polar Dikdörtgenden Kutupsala + Reduce Noise Gürültüyü Azalt + Relink to File Dosyaya Yeniden Bağla Render Video Videoyu Oluştur Replace Color Rengi Değiştir Replace Contents İçeriği Değiştir Replace Footage Görüntüyü Değiştir - Resolve Missing Fonts Eksik Yazı Tiplerini Çözün + Resolve Missing Fonts Eksik Yazı Tiplerini Çöz Ripple Dalgalanma - Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Roman Hanging Punctuation Roman Asılı Noktalama İşaretleri Rotate Auto Otomatik Döndür Rotate Fixed Sabit Döndür Rough Pastels Kaba Pastel - Sat Cumartesi + Sat Doygunluk Save a Copy Bir Kopyasını Kaydet Save for Web (Legacy) Web için Kaydet (Eski) Saved Multichannel Çok Kanallı Kaydedildi - Scale Effects Ölçek Efektleri + Scale Effects Efektleri Ölçekle Script Events Manager Komut Dosyası Olay Yöneticisi Search menus Arama menüleri - Seed tohum + Seed Tohum Select Plane Düzlem Seç Select and Mask Seç ve Maskele Semi Bold Yarı Kalın @@ -3458,7 +3458,7 @@ cameraRaw Color Renk Shape Blur Şekil Bulanıklığı Shear Kesme Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) - Single Word Justification Tek Kelime Gerekçesi + Single Word Justification Tek Sözcüğü İki Yana Yaslama Smart Blur Akıllı Bulanıklaştırma Smart Sharpen Akıllı Keskinleştirme Smudge Stick Leke Çubuğu @@ -3468,7 +3468,7 @@ cameraRaw Color Renk Spin Blur Döndürme Bulanıklığı Split Toning Bölünmüş Tonlama Split at Playhead Oynatma Kafasında Böl - Spot nokta + Spot Spot Spot Channel Spot Kanal Sprayed Strokes Püskürtülmüş Vuruşlar Strokes Vuruşlar @@ -3489,42 +3489,42 @@ cameraRaw Color Renk Tree Ağaç Trim Kırpma Turbulent Çalkantılı - Twirl Burgu + Twirl Girdap Ultra Bold Ultra Kalın Ultra Light Ultra Hafif Unsharp Mask Keskinliği Azaltma Maskesi - Use File › Open to open an image. Bir resmi açmak için Dosya › Aç'ı kullanın. + Use File › Open to open an image. Bir görüntüyü açmak için Dosya › Aç'ı kullanın. Vanishing Point Ufuk Noktası - Variable Data Sets Değişken Veri Setleri + Variable Data Sets Değişken Veri Kümeleri Video Frames to Layers Video Karelerinden Katmanlara WIA Support WIA Desteği - Warp Text Metni Çözgü - Watercolour look: lighter interior, darker rims. Suluboya görünümü: daha açık iç kısım, daha koyu jantlar. + Warp Text Metni Çarpıt + Watercolour look: lighter interior, darker rims. Suluboya görünümü: daha açık iç kısım, daha koyu kenarlar. Wind Rüzgâr Word Spacing Kelime Aralığı - Working Gray Plate Gri Çalışma Plakası - ZigZag ZigZag + Working Gray Plate Çalışma Gri Plakası + ZigZag Zikzak ↑↓ navigate ↵ run esc close ↑↓ gezin ↵ çalıştır esc kapat cameraRaw Amount Miktar cameraRaw Blacks Siyahlar -cameraRaw Color Detail Renk Detayı +cameraRaw Color Detail Renk Ayrıntısı cameraRaw Color Grading Renk Derecelendirme -cameraRaw Darks Koyular +cameraRaw Darks Koyu Tonlar cameraRaw Global Genel cameraRaw Hue Ton -cameraRaw Lights Açıklar -cameraRaw Luminance Detail Parlaklık Detayı +cameraRaw Lights Açık Tonlar +cameraRaw Luminance Detail Lüminans Ayrıntısı cameraRaw Masking Maskeleme cameraRaw Roughness Pürüzlülük cameraRaw Tint Renk Tonu -cameraRaw Vibrance Titreşim +cameraRaw Vibrance Canlılık cameraRaw Whites Beyazlar Wand Değnek Note tool Not aracı Magnetic Lasso Manyetik Kement - Art History Brush Sanat Tarihi Fırçası - Spot Healing Nokta Şifası - Content-Aware Move İçeriğe Uygun Taşıma + Art History Brush Sanat Geçmişi Fırçası + Spot Healing Nokta Düzeltme + Content-Aware Move İçeriğe Duyarlı Taşıma Vertical Type Tool Dikey Metin Aracı Click the first point or press {key} to close · {del} removes a point · Esc cancels İlk noktayı tıklayın veya kapatmak için {key} tuşuna basın · {del} bir noktayı kaldırır · Esc iptal eder Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Tıklayın, ardından bir kenar boyunca hareket edin · bir noktayı sabitlemek için tıklayın · düz bir parça için {alt}-tıklayın · kapatmak için çift tıklayın @@ -3533,7 +3533,7 @@ cameraRaw Whites Beyazlar Quit {app} {app}'dan çık Services Hizmetler Hide Others Diğerlerini Gizle - 3D 3D + 3D 3B Pen Kalem Hand El Sampler Örnekleyici @@ -3541,18 +3541,18 @@ cameraRaw Whites Beyazlar Perspective Crop Perspektif Kırpma RGB RGB ISO ISO - ColorSync Renk Senkronizasyonu - ProPhoto ProFotoğraf + ColorSync ColorSync + ProPhoto ProPhoto sRGB sRGB ACES ACES {zoom}% %{zoom} - Mac LCD Mac LCD'si + Mac LCD Mac LCD Mac Sharp Mac Keskin - Mac Crisp Mac Çıtır + Mac Crisp Mac Net Mac Strong Mac Güçlü - Mac Smooth Mac Pürüzsüz - Adobe Single-line Composer Adobe Tek Satır Oluşturucu - Adobe Every-line Composer Adobe Her Satır Oluşturucusu + Mac Smooth Mac Yumuşak + Adobe Single-line Composer Adobe Tek Satırlı Dizgi Motoru + Adobe Every-line Composer Adobe Tüm Satırlı Dizgi Motoru X X Y Y 30 fps 30 fps @@ -3560,7 +3560,7 @@ cameraRaw Whites Beyazlar 24 fps 24 fps 15 fps 15 fps 10 fps 10 fps - URLs URLs + URLs URL'ler EXIF EXIF GPS GPS Mac OS Mac OS @@ -3576,7 +3576,7 @@ cameraRaw Whites Beyazlar NTSC (1953) NTSC (1953) PAL/SECAM PAL/SECAM SMPTE C SMPTE-C - HDTV (Rec. 709) HDTV (Kayıt 709) + HDTV (Rec. 709) HDTV (Rec. 709) CIE RGB CIE RGB Photo (2 × 3 in) Fotoğraf (2 × 3 inç) Photo (4 × 6 in) Fotoğraf (4 × 6 inç) @@ -3584,11 +3584,11 @@ cameraRaw Whites Beyazlar Photo (8 × 10 in) Fotoğraf (8 × 10 inç) Lut Lut Cooling L B B Soğutma L B B - Cooling80 Soğutma80 - Cooling82 Soğutma82 + Cooling80 Soğutma 80 + Cooling82 Soğutma 82 Hsb HSB Hsl HSL - Movie Prime Film Başbakanı + Movie Prime Film Prime Prime105 Prime105 Prime35 Prime35 Rgb RGB @@ -3602,12 +3602,12 @@ cameraRaw Whites Beyazlar Out of gamut for printing: click for the nearest printable colour Yazdırma için gamın dışında: yazdırılabilir en yakın renk için tıklayın Not a web colour: click for the nearest web colour Web rengi değil: en yakın web rengi için tıklayın Adjustment Properties Ayarlama Özellikleri - Brush Stroke Fırça Darbesi + Brush Stroke Fırça Vuruşu Default Foreground and Background Colors Varsayılan Ön Plan ve Arka Plan Renkleri Move Layer Katmanı Taşı Rectangular Selection Dikdörtgen Seçimi Reorder Layer Katmanı Yeniden Sırala - Select Layer Katman Seç + Select Layer Katmanı Seç Set Colors Renkleri Ayarla Switch Foreground and Background Colors Ön Plan ve Arka Plan Renklerini Değiştir Target Hedef @@ -3625,20 +3625,20 @@ cameraRaw Whites Beyazlar Sample Size: Örnek Boyutu: Show Sampling Ring Örnekleme Halkasını Göster Dual Axis Çift Eksen - Diagonal diyagonal + Diagonal Çapraz Transform Symmetry Simetriyi Dönüştür Rasterize svg groups deeper than Svg gruplarını şundan daha derin bir şekilde rasterleştirin: 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 bu eşiği kapatır; 100 düzeyden daha derine yerleştirilmiş SVG grupları her zaman rasterleştirilir. Rule of Thirds Üçte Bir Kuralı Golden Ratio Altın Oran Golden Spiral Altın Spiral - Auto Show Overlay Otomatik Göster Yer Paylaşımı + Auto Show Overlay Kaplamayı Otomatik Göster Always Show Overlay Kaplamayı Her Zaman Göster Never Show Overlay Kaplamayı Hiçbir Zaman Gösterme - Cycle Overlay Döngü Yerleşimi - Cycle Orientation Döngü Yönü - Set the overlay options for the Crop tool Kırpma aracının kaplama seçeneklerini ayarlama - Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt tuşuna basıp masaüstüne giderek pencereleri Alt tuşuyla sürükleyerek hareket ettirin. Pencere taşıma tuşunu Süper olarak ayarlayın veya Pencere › Değiştirici Tuşlar'da Alt'ı mandallayın. + Cycle Overlay Kaplamalar Arasında Geç + Cycle Orientation Yönler Arasında Geç + Set the overlay options for the Crop tool Kırpma aracının kaplama seçeneklerini ayarla + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-tıklama masaüstüne gitti; masaüstü pencereleri Alt-sürükleyerek taşıyor. Pencere taşıma tuşunu Super olarak ayarlayın veya Pencere › Değiştirici Tuşlar'da Alt'ı sabitleyin. Color Picker (Layer Style Color) Renk Seçici (Katman Stili Rengi) Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) @@ -3646,18 +3646,18 @@ cameraRaw Whites Beyazlar Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu Sync with system Sistemle senkronizasyon - Active Aktif + Active Etkin Color Picker (Solid Color) Renk Seçici (Düz Renk) - Straighten the image by drawing a line on it Üzerine bir çizgi çizerek görüntüyü düzeltin + Straighten the image by drawing a line on it Üzerine bir çizgi çizerek görüntüyü doğrultun Set additional Crop options Ek Kırpma seçeneklerini ayarlayın Show Cropped Area Kırpılmış Alanı Göster Enable Crop Shield Kırpma Kalkanını Etkinleştir - Match Canvas Maç Kanvası + Match Canvas Tuvalle Eşleştir Auto Adjust Opacity Opaklığı Otomatik Ayarla Crop Shield color Kırpma Kalkanı rengi Color Picker (Crop Shield Color) Renk Seçici (Kırpma Kalkanı Rengi) From Center Merkezden - Link radii Bağlantı yarıçapları + Link radii Yarıçapları bağla Star Ratio: Yıldız Oranı: Create Rectangle Dikdörtgen Oluştur Create Ellipse Elips Oluştur @@ -3667,37 +3667,37 @@ cameraRaw Whites Beyazlar Click to add a point, right-click to remove Nokta eklemek için tıklayın, kaldırmak için sağ tıklayın Some brush presets could not be loaded Bazı fırça ön ayarları yüklenemedi Retry saving presets Ön ayarları kaydetmeyi yeniden deneyin - Brush preset changes are not saved: {error} Fırça hazır ayarı değişiklikleri kaydedilmiyor: {error} + Brush preset changes are not saved: {error} Fırça ön ayarı değişiklikleri kaydedilmedi: {error} Keep this tab open to retain your brushes. Fırçalarınızı korumak için bu sekmeyi açık tutun. Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Tarayıcı depolama alanı kullanılamıyor; fırçalar yalnızca oturum içindir. Bunları korumak için bu sekmeyi açık tutun. Saving brush presets… Keep this tab open until saving finishes. Fırça ön ayarları kaydediliyor… Kaydetme bitene kadar bu sekmeyi açık tutun. Reflect Yansıt - Glow Parıltı + Glow Işıma Negation Olumsuzluk XOR XOR - Color Burn (Paint.NET) Renk Yanması (Paint.NET) + Color Burn (Paint.NET) Renk Yakma (Paint.NET) Color Dodge (Paint.NET) Renk Soldurma (Paint.NET) - Pro Medium Profesyonel Orta - Studio Light Stüdyo Işığı + Pro Medium Pro Orta + Studio Light Stüdyo Açık Stroke Options Kontur Seçenekleri - Solid Katı + Solid Sürekli Dashed Kesikli Dotted Noktalı - Caps Kapaklar - Butt / Flat Popo / Düz + Caps Uçlar + Butt / Flat Düz Uç / Düz Miter Gönye Round Yuvarlak Bevel Eğim Miter limit Gönye sınırı Dashed line Kesikli çizgi - Dash Çizgi + Dash Tire Gap Boşluk - Dash / gap lengths × stroke width Çizgi / boşluk uzunlukları × kontur genişliği + Dash / gap lengths × stroke width Tire / boşluk uzunlukları × kontur genişliği Add pair Çift ekle Remove pair Çifti kaldır - Dash offset Çizgi ofseti + Dash offset Tire kaydırması Open paths use centered strokes Açık yollar ortalanmış konturlar kullanır - Use finite lengths and a positive pattern total Sonlu uzunluklar ve pozitif bir model toplamı kullanın + Use finite lengths and a positive pattern total Sonlu uzunluklar ve pozitif bir desen toplamı kullanın No subject found — try Object Selection or the Lasso Konu bulunamadı — Nesne Seçimi'ni veya Kement'i deneyin Quick Selection found nothing there Hızlı Seçim orada hiçbir şey bulamadı Object Selection found nothing there — try a tighter rectangle Nesne Seçimi burada hiçbir şey bulamadı; daha dar bir dikdörtgen deneyin @@ -3711,15 +3711,15 @@ cameraData Date Taken Çekim Tarihi cameraData Exposure Pozlama cameraData F-Stop Diyafram cameraData ISO Speed ISO Hızı -cameraData Focal Length Odak Uzaklığı -cameraData Focal Length (35 mm) Odak Uzaklığı (35 mm) +cameraData Focal Length Odak Uzunluğu +cameraData Focal Length (35 mm) Odak Uzunluğu (35 mm) cameraData Lens Lens Activate Panel Paneli Etkinleştir Change Panel Group Panel Grubunu Değiştir Change Panel Layout Panel Düzenini Değiştir Close Panel Paneli Kapat - Dock Panel Bağlantı İstasyonu Paneli - Float Panel Şamandıra Paneli + Dock Panel Paneli Sabitle + Float Panel Paneli Yüzdür Move Panel Paneli Taşı Group with Şununla grupla: Panel menu Panel menüsü @@ -3730,16 +3730,16 @@ cameraData Lens Lens Next Document Sonraki Belge Previous Document Önceki Belge W x H x Resolution G x Y x Çözünürlük - Front Image Ön Resim + Front Image Ön Görüntü Perspective Crop Tool Perspektif Kırpma Aracı - Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Bir kutuyu sürükleyin veya dört köşeyi tıklayın · köşeleri çarpık bir şeklin kenarlarına sürükleyin · {commit} taahhütleri · Esc iptalleri + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Bir kutuyu sürükleyin veya dört köşeye tıklayın · köşeleri çarpık bir şeklin kenarlarına sürükleyin · {commit} onaylar · Esc iptal eder Artboard Tool Çalışma Yüzeyi Aracı - Make Portrait Portre Yap - Make Landscape Manzara Yap + Make Portrait Dikey Yap + Make Landscape Yatay Yap Add New Artboard Yeni Çalışma Yüzeyi Ekle Freeform Pen Tool Serbest Kalem Aracı Curvature Pen Tool Eğrilik Kalemi Aracı - Curve Fit: Eğri Fitili: + Curve Fit: Eğri Uyumu: Drag: draw freehand · Release on the start point: close Sürükleyin: serbest çizim yapın · Başlangıç noktasında bırakın: kapat Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Tıklama: düzgün nokta · Çift tıklama: köşe · Bir noktayı sürükleyin: taşıma · İlk noktayı tıklayın: kapat · {key} bitiş New Crop Preset… Yeni Kırpma Ön Ayarı… @@ -3759,12 +3759,12 @@ cameraData Lens Lens On Layers Katmanlarda Show Edge Kenarı Göster Show Original Orijinali Göster - F cycles the views; hold X to see the image F görünümleri döndürür; resmi görmek için X'i basılı tutun - Refine Edge Brush (R) Kenar Fırçasını İncelt (R) + F cycles the views; hold X to see the image F görünümleri döndürür; görüntüyü görmek için X'i basılı tutun + Refine Edge Brush (R) Kenarı Hassaslaştırma Fırçası (R) Expand Detection Area Algılama Alanını Genişlet Restore Original Edge Orijinal Kenarı Geri Yükle - Paint over hair or fur on the image; Alt paints the other mode Görüntünün üzerine saç veya kürkün üzerini boyayın; Alt diğer modu boyar - Clear Brush Strokes Fırça Darbelerini Temizle + Paint over hair or fur on the image; Alt paints the other mode Görüntüde saç veya kürk üzerine boyayın; Alt diğer modu boyar + Clear Brush Strokes Fırça Vuruşlarını Temizle Show rulers Cetvelleri göster Frame Tool Çerçeve Aracı Create a new rectangular frame Yeni bir dikdörtgen çerçeve oluşturun @@ -3780,7 +3780,7 @@ cameraData Lens Lens Transparency Threshold Saydamlık Eşiği Opacity Threshold Opaklık Eşiği (No neutral color exists for {mode} mode.) ({mode} modu için nötr renk bulunmuyor.) - Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Sürükle: serbest çiz · {alt}-tıkla: düz segment · Başlangıç noktasında bırak: kapat + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Sürükle: serbest çiz · {alt}-tıkla: düz parça · Başlangıç noktasında bırak: kapat Fill with {mode}-neutral color (50% gray) {mode} nötr rengiyle doldur (%50 gri) Fill with {mode}-neutral color (black) {mode} nötr rengiyle doldur (siyah) Fill with {mode}-neutral color (white) {mode} nötr rengiyle doldur (beyaz) @@ -3798,7 +3798,7 @@ cameraData Lens Lens Light Red Açık Kırmızı Medium Blue Orta Mavi Merge Group Grubu Birleştir - Protect skin tones Ten tonlarını koru + Protect skin tones Cilt tonlarını koru Protect: Koru: Remove after each stroke Her vuruştan sonra kaldır Saving {percent} Kaydediliyor {percent} @@ -3851,9 +3851,9 @@ cameraData Lens Lens Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın - Gradient Preset Options Gradyan Ön Ayarı Seçenekleri + Gradient Preset Options Degrade Ön Ayarı Seçenekleri Import .GRD .GRD İçe Aktar - Choose a gradient file (.grd). Bir gradyan dosyası (.grd) seçin. + Choose a gradient file (.grd). Bir degrade dosyası (.grd) seçin. Save Settings… Ayarları Kaydet… Load Settings… Ayarları Yükle… Save these settings as a Camera Raw .xmp file Bu ayarları Camera Raw .xmp dosyası olarak kaydet From 097848ddb59cec2e0571e6dca3f40e30eaa8dee2 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 15:50:36 -0400 Subject: [PATCH 0136/1452] =?UTF-8?q?Move=20tool:=20=CE=94X/=CE=94Y=20read?= =?UTF-8?q?out=20beside=20the=20pointer=20while=20dragging=20(#5727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Free Transform drew a readout of how far the layer moved (its Hit::Inside offset), so a Move-tool drag inside the transform controls, or without them, showed nothing. Photoshop shows ΔX/ΔY for a Move drag. The drag preview now draws the offset the release will commit (move_ui::drag_offset, whole pixels), for a layer drag and a drag of selected pixels, once the pointer has moved. It goes through draw_value_readout, so Preferences > Tools > Show Transformation Values hides it like the other readouts. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 +++++ crates/ui-egui/src/move_ui.rs | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a9592b692..498e0528f7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4695,6 +4695,11 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View app.trail = None; return; }; + // The Move tool's ΔX/ΔY beside the pointer, for layers and selected pixels alike (#5727). + if let Some(values) = crate::move_ui::offset_readout(app) { + let at = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + draw_value_readout(app, painter.ctx(), "move-readout", xf.to_screen(at[0] as f32, at[1] as f32), ["ΔX:", "ΔY:"], values); + } // Dragging the selection: its own ants follow the pointer (`selection_shown_offset`). if d.sel_move.is_some() { return; diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index fb49b02474..22c43d41f2 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -96,6 +96,15 @@ fn drag_offset(app: &PhotocraftApp) -> Option<(i32, i32)> { Some((dx, dy)) } +/// The ΔX/ΔY values shown beside the pointer while the Move tool drags layers or selected pixels, +/// as in Photoshop (#5727): the whole pixels the release will move them. Free Transform shows its +/// own (`transform_tool::live_readout`). `None` until the pointer moves. +pub(crate) fn offset_readout(app: &PhotocraftApp) -> Option<[String; 2]> { + app.drag.as_ref().filter(|d| d.points.len() > 1)?; + let (dx, dy) = drag_offset(app)?; + Some([dx, dy].map(|v| format!("{v} px"))) +} + /// The document to show while a Move drag is under way on document `idx`: the moving layers at /// the pointer. `None` without a drag (or when the layers can't move: locked, say; the drag then /// shows its arrow and the release reports why). @@ -309,6 +318,33 @@ mod tests { assert_eq!(r, Rect::new(0, 0, 64, 64)); } + /// The Move tool shows how far it has moved the layer, ΔX/ΔY in whole pixels, as Photoshop + /// and Free Transform do (#5727): once the pointer moves, until the release. + #[test] + fn a_move_drag_reads_out_the_offset_it_will_commit() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(16, 16, 32, 32), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let m = egui::Modifiers::NONE; + assert_eq!(super::offset_readout(&app), None, "no drag"); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, m); + assert_eq!(super::offset_readout(&app), None, "pressed, not moved yet"); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 42.4, y: 28.8, pressure: 1.0 }, m); + assert_eq!(super::offset_readout(&app), Some(["10 px".to_string(), "-3 px".to_string()])); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 42.4, y: 28.8 }, m); + assert_eq!(super::offset_readout(&app), None, "released"); + } + /// A shape reaching past the canvas edge renders cut at the canvas, but its path goes on: a /// drag frame must redraw where the rest of it was, or the cut edge stays behind (#3946). #[test] From f7109db763abc5d494c0ec10903ea6fece55d6e2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:24 +0300 Subject: [PATCH 0137/1452] Affinity import: budget decoded pixels across the document (#5599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Affinity container with many embedded images decoded (and kept) one surface each with no cumulative cap, so a few-megabyte file could exhaust memory: each image decodes up to 256 MB and resamples into another surface of the same order, and nothing tracked the total. The import now shares a 1 GiB decoded-pixel budget: an embedded image is charged its decoded w×h×4 bytes, resampling is charged its staging buffer, and an image the budget cannot cover is left out with a warning, the same shape as the existing per-image caps. ORA and KRA budget their decoded pixels the same way. Regression tests cover the budget arithmetic and both charge sites (an embedded PNG decode and an RGBA resample) with tiny in-memory fixtures. --- crates/io/src/affinity.rs | 100 +++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..bd82fc3d8c 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -28,6 +28,10 @@ pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"]; const MAX_PIXELS: u64 = 64 << 20; /// Canvas side in pixels. const MAX_SIDE: f64 = 300_000.0; +/// Decoded pixel bytes allowed across the whole import: the caps above are per call, so a small +/// container of embedded images could still decode (and keep) a surface each until the process +/// dies. ORA and KRA budget their decoded pixels the same way. +const MAX_PIXEL_BYTES: u64 = 1 << 30; pub fn has_extension(name: &str) -> bool { name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) @@ -82,6 +86,20 @@ struct Builder { /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset. format: PixelFormat, canvas: Rect, + /// Decoded pixel bytes left for embedded images and their resampled surfaces. + pixel_budget: u64, +} + +/// Deduct `cost` bytes from a decoded-pixel budget; `false` (leaving the budget alone) when it +/// cannot cover them. +fn charge(budget: &mut u64, cost: u64) -> bool { + match budget.checked_sub(cost) { + Some(rest) => { + *budget = rest; + true + } + None => false, + } } fn affine(a: af::Affine) -> Affine { @@ -108,7 +126,7 @@ impl Builder { let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = d.dpi as f32; let canvas = Rect::new(0, 0, size.width as i32, size.height as i32); - Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas }) + Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas, pixel_budget: MAX_PIXEL_BYTES }) } fn warn(&mut self, w: &str) { @@ -494,6 +512,10 @@ impl Builder { match photocraft_codecs::decode_with(bytes, &opts) { Ok(d) => { let (w, h) = d.dimensions(); + if !charge(&mut self.pixel_budget, u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4)) { + self.warn("the document's embedded images need more pixel memory than the import allows; the rest were left out"); + return None; + } Some((w, h, d.to_rgba8())) } Err(_) => { @@ -548,6 +570,10 @@ impl Builder { let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0; let inv = m.inverse()?; let (rw, rh) = (r.width() as usize, r.height() as usize); + if !charge(&mut self.pixel_budget, (rw as u64).saturating_mul(rh as u64).saturating_mul(channels as u64)) { + self.warn("the document's images need more pixel memory than the import allows; the rest were left out"); + return None; + } let mut buf = vec![0u8; rw * rh * channels]; let at = |x: i64, y: i64, ch: usize| -> f64 { if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) { @@ -619,3 +645,75 @@ fn color(c: ap::Color) -> Color { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A transparent 64×64 page holding one image node per entry; the identity transform maps + /// each image onto the page's top-left corner. + fn affinity_document(images: Vec<(u32, u32, Pixels)>) -> af::Document { + let nodes = images + .into_iter() + .enumerate() + .map(|(i, (width, height, pixels))| af::Node { + class: photocraft_affinity::synth::tag(b"ImgN"), + name: format!("Image {i}"), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Image(af::Image { width, height, pixels, transform: af::Affine::IDENTITY }), + mask: None, + pixel_mask: None, + children: Vec::new(), + }) + .collect(); + af::Document { + dpi: 72.0, + spreads: vec![af::Spread { bounds: af::Rect { x0: 0.0, y0: 0.0, x1: 64.0, y1: 64.0 }, pages: Vec::new(), transparent: true, nodes }], + saved_by: None, + warnings: Vec::new(), + } + } + + #[test] + fn the_pixel_budget_refuses_what_does_not_fit() { + let mut budget = 100u64; + assert!(charge(&mut budget, 60)); + assert!(!charge(&mut budget, 60), "a later image cannot overdraw the import's budget"); + assert_eq!(budget, 40, "a refused charge leaves the budget alone"); + } + + #[test] + fn images_past_the_pixel_budget_are_left_out() { + // Two 64×64 RGBA images cost 16 KiB each when resampled; a 16 KiB budget admits one and + // the second is dropped with a warning instead of being staged anyway. + let d = affinity_document(vec![(64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4])), (64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4]))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 64 * 64 * 4; + b.document(&d).unwrap(); + assert_eq!(b.doc.layers.len(), 1, "the image past the budget must not import"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + } + + #[test] + fn embedded_images_past_the_pixel_budget_are_not_decoded_into_it() { + // An 8×8 embedded PNG costs 256 bytes decoded and 256 more resampled. + let png = { + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + let d = affinity_document(vec![(8, 8, Pixels::Encoded(png))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 8 * 8 * 4 - 1; + b.document(&d).unwrap(); + assert!(b.doc.layers.is_empty(), "the embedded image must not import past the budget"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + // The same image imports when the budget covers the decode and the resample. + let mut ok = Builder::new("room", &d).unwrap(); + ok.pixel_budget = 8 * 8 * 4 * 2; + ok.document(&d).unwrap(); + assert_eq!(ok.doc.layers.len(), 1, "within the budget the image imports as a smart object"); + } +} From bb7c84f997f64ea37c3e84f79ae48b95d1302a24 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 22:34:51 +0200 Subject: [PATCH 0138/1452] Fill layers: reject non-finite gradient angles and pattern angle/phase (#5752) layer.newFillLayer.gradient and gradient.presets.apply cast `angle` to f32 unchecked, so 1e39 stored an infinite angle (NaN render, .pcraft save refused) and 450 stayed 450. They now share gradient.fill.set's parsing: non-finite is BadParams, the angle is normalised to (-180, 180]. Pattern placement (Pattern Fill layer, Edit > Fill, Paint Bucket) does the same for its angle and rejects a phase outside +/-1e7 like pattern.fill.set. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 5 +-- crates/engine/src/fill_cmds.rs | 2 +- crates/engine/src/gradient_fill_cmds.rs | 15 +++++---- crates/engine/src/paint_cmds.rs | 2 +- crates/engine/src/pattern_cmds.rs | 42 ++++++++++++++++++++----- crates/engine/src/pattern_fill_cmds.rs | 2 +- crates/engine/src/presets/gradients.rs | 2 +- crates/engine/src/tests.rs | 27 ++++++++++++++++ 8 files changed, 75 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 68d00a7da3..452cd97b65 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -229,9 +229,6 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result f32 { - p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) -} pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { @@ -909,7 +906,7 @@ fn build() -> Vec { |s, p| { let a = color_param(p, "from", s.tools.foreground); let b = color_param(p, "to", s.tools.background); - let angle = f32_or(p, "angle", 90.0); + let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?; let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 2aa0a02bdc..808caf4069 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -112,7 +112,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { "white" => Source::Color([1.0; 4]), "pattern" => { let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; - let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); + let (scale, angle, _, phase) = crate::pattern_cmds::placement(CMD, p)?; let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?; // Photoshop's Fill uses the canvas origin. let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle); diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..14e03baf31 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -36,6 +36,14 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// The `angle` param (degrees) normalised to (-180, 180], or `default` when absent. Rejects +/// a non-number or one beyond f32 range: an infinite angle renders NaN and can't be saved. +pub(crate) fn angle_param(cmd: &str, p: &Value, default: f32) -> Result { + let Some(a) = p.get("angle") else { return Ok(default) }; + let a = a.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`angle` is a number (degrees)"))?.rem_euclid(360.0); + Ok(if a > 180.0 { a - 360.0 } else { a }) +} + fn num(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } @@ -176,12 +184,7 @@ pub fn apply_set(layer: &Layer, f: &Fill, canvas: Rect, p: &Value, fg: [f32; 4], } let new_style = style_param(CMD, p)?; let explicit_geometry = ["angle", "scale", "offset"].iter().any(|k| p.get(*k).is_some()); - if let Some(a) = p.get("angle") { - *angle = a.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`angle` is a number (degrees)"))?.rem_euclid(360.0); - if *angle > 180.0 { - *angle -= 360.0; - } - } + *angle = angle_param(CMD, p, *angle)?; if p.get("scale").is_some() { let v = num(p, "scale").filter(|v| *v > 0.0).ok_or_else(|| bad(CMD, "`scale` is a positive percentage"))?; *scale = clamp_scale(v / 100.0); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 497d7f4fb1..4255d796c4 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -63,7 +63,7 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?; let tile = photocraft_compose::pattern::Tile::new(&pat) .ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty or too large to paint".into() })?; - let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); + let (scale, angle, _, phase) = crate::pattern_cmds::placement("paint.bucket", p)?; Some((tile, scale, angle, phase)) } else { None diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 0cb9052ceb..43e102c917 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -300,19 +300,25 @@ fn export(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "count": pats.len(), "bytes": bytes.len()})) } -pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { - let num = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32; - let phase = p - .get("phase") - .and_then(Value::as_array) - .map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32)); - ((num("scale", 100.0) / 100.0).clamp(0.01, 10.0), num("angle", 0.0), p.get("link").and_then(Value::as_bool).unwrap_or(true), phase) +/// A pattern's scale, angle, link and phase from `p`. The angle is normalised to (-180, 180]; +/// a non-finite angle or a phase beyond ±`MAX_PHASE` is an error (it would render NaN). +pub(crate) fn placement(cmd: &str, p: &Value) -> Result<(f32, f32, bool, (f32, f32))> { + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32; + let max = crate::pattern_fill_cmds::MAX_PHASE; + let a = p.get("phase").and_then(Value::as_array); + let c = |i: usize| { + let v = a.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(0.0); + (v.is_finite() && v.abs() <= max).then_some(v as f32).ok_or_else(|| bad(cmd, format!("`phase` needs finite coordinates within ±{max}"))) + }; + let phase = (c(0)?, c(1)?); + let angle = crate::gradient_fill_cmds::angle_param(cmd, p, 0.0)?; + Ok(((scale / 100.0).clamp(0.01, 10.0), angle, p.get("link").and_then(Value::as_bool).unwrap_or(true), phase)) } /// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks, /// above `active`. The Pattern Fill dialog previews it on a copy of the document. pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option, pat: &Pattern, p: &Value) -> Result { - let (scale, angle, link, phase) = placement(p); + let (scale, angle, link, phase) = placement("layer.newFillLayer.pattern", p)?; ensure_in_doc(doc, pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); @@ -535,6 +541,26 @@ mod tests { } } + #[test] + fn pattern_placement_rejects_non_finite_angle_and_phase() { + // 1e39 overflowed f32 to infinity: a NaN render, and a fill layer .pcraft refused to save. + let mut s = session(8); + let n = s.active().unwrap().doc.layers.len(); + for cmd in ["layer.newFillLayer.pattern", "edit.fill", "paint.bucket"] { + for bad in [json!({"angle": 1e39}), json!({"angle": "x"}), json!({"phase": [1e39, 0]}), json!({"phase": [0, -1e300]})] { + let mut p = json!({"pattern": "Checkerboard", "contents": "pattern", "x": 1, "y": 1}); + p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(s.execute(cmd, p).is_err(), "{cmd} {bad}"); + } + } + assert_eq!(s.active().unwrap().doc.layers.len(), n); + // The angle is normalised to (-180, 180] like the Pattern Fill dialog's. + let r = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard", "angle": 270, "phase": [3, -4]})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + let Some(LayerContent::Fill(Fill::Pattern { angle, phase, .. })) = s.active().unwrap().doc.layer(id).map(|l| &l.content) else { panic!() }; + assert_eq!((*angle, *phase), (-90.0, (3.0, -4.0))); + } + #[test] fn fill_with_pattern_respects_selection() { for depth in [8u64, 16, 32] { diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index b9d75e6cb5..781b4cfef8 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -14,7 +14,7 @@ pub const CREATE: &str = "layer.newFillLayer.pattern"; /// Photoshop's Scale range in the Pattern Fill dialog (percent). pub const SCALE_RANGE: (f64, f64) = (1.0, 1000.0); /// Largest phase (origin offset) accepted, in pixels. -const MAX_PHASE: f64 = 1.0e7; +pub(crate) const MAX_PHASE: f64 = 1.0e7; fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: SET.into(), msg: msg.into() } diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..d7ab21926b 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -603,7 +603,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { const CMD: &str = "gradient.presets.apply"; let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); - let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; + let angle = crate::gradient_fill_cmds::angle_param(CMD, p, 90.0)?; let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..fc72bb7758 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -635,6 +635,33 @@ fn fill_layers_and_colors() { assert!(l < r); } +#[test] +fn gradient_fill_layer_angles_are_finite_and_normalised() { + // `angle` 1e39 overflowed f32 to infinity: the render went NaN and .pcraft refused to save. + let mut s = session_with_doc(); + let angle = |s: &Session| { + let st = s.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Gradient { angle, .. }) => *angle, + _ => panic!("not a gradient fill"), + } + }; + for cmd in ["layer.newFillLayer.gradient", "gradient.presets.apply"] { + let layers = s.active().unwrap().doc.layers.len(); + for bad in [json!(1e39), json!(-1e300), json!("90")] { + assert!(s.execute(cmd, json!({"angle": bad})).is_err(), "{cmd} {bad}"); + } + assert_eq!(s.active().unwrap().doc.layers.len(), layers, "{cmd}: nothing added"); + // Normalised to (-180, 180] like gradient.fill.set. + for (a, want) in [(450.0, 90.0), (-90.0, -90.0), (-180.0, 180.0)] { + s.execute(cmd, json!({"angle": a})).unwrap(); + assert_eq!(angle(&s), want, "{cmd} {a}"); + } + s.execute(cmd, json!({})).unwrap(); + assert_eq!(angle(&s), 90.0, "{cmd}: default"); + } +} + #[test] fn default_colors_with_mask_targeted_resets_to_mask_colors() { let mut s = Session::new(); From 06d38bce12ff5e80eb77bcc0fe3cbd64fd48052c Mon Sep 17 00:00:00 2001 From: timothydaly1 Date: Wed, 14 Oct 2026 16:36:27 -0400 Subject: [PATCH 0139/1452] Lab: adjustment results are whole samples, as Photoshop stores them The native Lab path (compose::native_lab) worked a stack out on the stored L, a and b but kept each adjustment layer's result unrounded, so the screen showed values between two samples where Photoshop, which stores every result at the document's depth, has whole bytes (or 1/32768 steps). On saturated colours half a byte of L is a couple of RGB levels. Each adjustment's result is now rounded to the depth's samples (adjustment_quantum: 8 and 16 bits; 32-bit stays unrounded), as the RGB compositor already does. photoshop lab8 curves.psd goes from 0.0084 to exact (PASS); Photoshop oracles 185 -> 186, floor 186. lab8 brightness-contrast (modern) stays DIFF: our modern curve is within half a byte of Photoshop's on L, not exact, so rounding now shows its misses (0.02 % -> 1.05 % of pixels). Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/native_lab.rs | 8 +++++++- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 4 ++-- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs index 0db59499de..771240a46b 100644 --- a/crates/compose/src/native_lab.rs +++ b/crates/compose/src/native_lab.rs @@ -89,6 +89,9 @@ fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: /// A Lab document's visible layers, prepared for compositing on their stored L, a and b. pub struct LabStack<'a> { items: Vec>, + /// Steps per unit of the document's samples (`None` at 32 bits): adjustment results are + /// whole samples, as Photoshop stores them. + quantum: Option, } impl<'a> LabStack<'a> { @@ -101,7 +104,7 @@ impl<'a> LabStack<'a> { } let mut items = Vec::new(); collect(&doc.layers, doc.depth, true, &mut items, 0)?; - Some(LabStack { items }) + Some(LabStack { items, quantum: crate::adjustment_quantum(doc.depth) }) } /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). @@ -161,6 +164,9 @@ impl<'a> LabStack<'a> { op(&mut lab); for (c, v) in lab.iter().enumerate() { d[c] += (v - d[c]) * mix; + if let Some(q) = self.quantum { + d[c] = (d[c] * q + 0.5).floor() / q; + } } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d8842a24fd..24ffd8316d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2884,6 +2884,32 @@ fn lab_adjustments_work_on_the_stored_values() { near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); } +// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves +// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact +// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits +// round to 1/32768. +#[test] +fn lab_adjustment_results_are_whole_samples() { + let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::>(); + let curves = |l: &[(f32, f32)]| Adjustment::Curves { + master: Vec::new(), + per_channel: [pts(l), Vec::new(), Vec::new()], + space: ToneSpace::Lab, + black: Vec::new(), + pencil: false, + }; + // A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes. + let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]); + for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] { + let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]); + let p = px(&doc, 1, 1); + let stack = native_lab::LabStack::new(&doc); + assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path"); + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0; + assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q); + } +} + // Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB // compositor clipped them on the way through display RGB). #[test] diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index a8d81a1600..8c853f9d37 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 186, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..7d078bcd51 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (Lab adjustment results are whole samples; Photoshop-oracle floor 186/258; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 186/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..5ff91a77ff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: Lab adjustment results are whole samples, Photoshop-oracle floor 186/258; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 185/258 (72%; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% + oracles 186/258 (72%; Lab adjustment results on whole samples 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -263,6 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: Lab adjustment layers' results are whole samples (native Lab path); photoshop lab8 curves passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..56fa36340e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 186/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 185 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 186 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| From e41bc273fe669eb9d5b7bfe4dad0c390e42eeeda Mon Sep 17 00:00:00 2001 From: timothydaly1 Date: Wed, 14 Oct 2026 16:50:04 -0400 Subject: [PATCH 0140/1452] Hue/Saturation: a 32-bit Colorize scales its saturation by 1 - |Lightness| Photoshop's 32-bit Colorize builds its colour with less chroma than the 8/16-bit one: in the photoshop corpus's rgb32 hue-saturation-colorize (hue -160, saturation 40, lightness +5) every output has the same hue and lightness as ours but exactly 0.95 of the chroma, at every input lightness from black to white. Scaling the saturation by 1 - |Lightness| at 32 bits makes the file exact; 8 and 16 bits keep the full saturation (their oracles are exact without it, and scaling there breaks them). Only a raised Lightness is measured; a lowered one is taken to scale the same way. CPU (adjust::colorize_saturation) and the GPU planner alike. Photoshop oracles 185 -> 186, floor 186. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 27 ++++++++++++++++++++++++++- crates/gpu/src/plan.rs | 3 ++- crates/gpu/tests/parity.rs | 4 ++++ crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 4 ++-- 7 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index f91e7779d1..4db099f729 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -158,7 +158,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } None => (0.0, 0.0, 0.0), }; - let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize); + let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth); + let out = hue_saturation(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize); range_lightness(out, dl) }) } @@ -657,6 +658,16 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec; 3] { std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect()) } +/// Hue/Saturation's saturation `s` (-1..1) as [`hue_saturation`] takes it in a document of +/// `depth`. A 32-bit Colorize builds its colour with the saturation times `1 - |lightness|`: +/// the photoshop corpus's rgb32 hue-saturation-colorize.psd (Saturation 40, Lightness +5) has 0.95 +/// of the chroma at every lightness, and is exact so, where 8 and 16 bits keep the full saturation +/// (their oracles are exact without it). Only a raised Lightness is measured; a lowered one is +/// taken to scale the same way. +pub fn colorize_saturation(s: f32, lightness: f32, colorize: bool, depth: Option) -> f32 { + if colorize && depth == Some(SampleType::F32) { s * (1.0 - lightness.abs().min(1.0)) } else { s } +} + /// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1. /// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the /// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%. @@ -1494,6 +1505,20 @@ mod tone_tests { /// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast /// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the /// constant, and the Black & White tint keeps values above 1. + /// Photoshop's 32-bit Colorize (photoshop corpus rgb32 hue-saturation-colorize.psd: hue -160, + /// saturation 40, lightness +5): red becomes (0.3445, 0.5852, 0.7055), 0.95 of the chroma an + /// 8-bit Colorize gives at the same lightness, (0.335, 0.5883, 0.715). + #[test] + fn thirty_two_bit_colorize_scales_the_saturation() { + let adj = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; + let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 2e-3); + let red = hdr(&adj, [1.0, 0.0, 0.0]); + assert!(near(red, [0.3445, 0.5852, 0.7055]), "{red:?}"); + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16)); + assert!(near([b.px[0][0], b.px[0][1], b.px[0][2]], [0.335, 0.5883, 0.715]), "16-bit keeps the full saturation: {:?}", b.px[0]); + } + #[test] fn thirty_two_bit_adjustments_match_photoshop_unclipped() { let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..35d1b6398c 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1650,7 +1650,8 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { - p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; + let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); + p[0] = [*hue, sat, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..5f1b522f0e 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -671,6 +671,10 @@ fn adjustment_layers() { let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true }; d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf))); check(&mut g, &d, &format!("photo filter {depth:?}")); + // Colorize: the saturation scaled by 1 - |lightness| at 32 bits (compose::adjust::colorize_saturation). + let hs = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("colorize {depth:?}")); // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); check(&mut g, &d, &format!("threshold {depth:?}")); diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index a8d81a1600..8c853f9d37 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 186, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..99da09dabb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (32-bit Colorize scales its saturation as Photoshop does; Photoshop-oracle floor 186/258; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 186/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A 32-bit Hue/Saturation Colorize builds its colour with the saturation times 1 − \|Lightness\|, as Photoshop does (8 and 16 bits keep the full saturation): photoshop rgb32 hue-saturation-colorize passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..792b67d367 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: 32-bit Colorize saturation, Photoshop-oracle floor 186/258; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 185/258 (72%; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% + oracles 186/258 (72%; 32-bit Colorize 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -263,6 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: 32-bit Colorize scales its saturation by 1 − \|Lightness\|; photoshop rgb32 hue-saturation-colorize passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..56fa36340e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 186/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 185 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 186 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 2493eab205364f503ab2c452ef44fe8262aecb0f Mon Sep 17 00:00:00 2001 From: timothydaly1 Date: Wed, 14 Oct 2026 16:57:16 -0400 Subject: [PATCH 0141/1452] Hue/Saturation: 32-bit saturation isn't clipped, its slider on whole levels Photoshop's 32-bit Hue/Saturation raises the saturation by 1 / (1 - s) like 8/16 bits, but doesn't clip it at 1: saturated colours leave 0..1 (the photoshop corpus's rgb32 hue-saturation.psd, Hue +40, Saturation +25, Lightness -10, takes red to (1.0477, 0.649, -0.1477), where 8 bits clip it to (0.9, 0.6, 0)). Its slider is cut to whole 8-bit levels: 25 % raises by 1 / (1 - 63/255) = 1.328, not 1/0.75; in-gamut colours show the same factor. The file becomes exact (0.1477 -> 0.0002). hue_saturation_in carries the depth; the GPU kernel does the same under F_HDR. Photoshop oracles 186 -> 187 (with the Colorize change, 185 -> 187), floor 187. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 36 ++++++++++++++++++++++++++++++++++-- crates/gpu/src/compose.wgsl | 13 +++++++++---- crates/gpu/tests/parity.rs | 4 ++++ crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 6 +++--- docs/gaps.md | 6 +++--- docs/scorecard.md | 4 ++-- 7 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 4db099f729..ae411d0be8 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -159,7 +159,7 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth None => (0.0, 0.0, 0.0), }; let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth); - let out = hue_saturation(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize); + let out = hue_saturation_in(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize, depth); range_lightness(out, dl) }) } @@ -672,6 +672,16 @@ pub fn colorize_saturation(s: f32, lightness: f32, colorize: bool, depth: Option /// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the /// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%. pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] { + hue_saturation_in(c, hue, s, l, colorize, None) +} + +/// [`hue_saturation`] in a document of `depth`. At 32 bits Photoshop doesn't clip the raised +/// saturation, so saturated colours leave 0..1 (red with Hue +40, Saturation +25, Lightness -10 +/// becomes (1.0477, 0.649, -0.1477)), and it raises it by `1 / (1 - s)` with `s` cut to whole 8-bit +/// levels (25 % → 63/255, a factor of 1.328 where 1/0.75 is 1.333): the photoshop corpus's rgb32 +/// hue-saturation.psd is exact so. +pub fn hue_saturation_in(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool, depth: Option) -> [f32; 3] { + let hdr = depth == Some(SampleType::F32); let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) }; if colorize { // Colorize builds the colour at the lightened (or darkened) lightness, so black with @@ -686,7 +696,12 @@ pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> let hh = (hh + hue / 360.0).rem_euclid(1.0); // Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it // (measured in Photoshop 27.11); +100 % saturates any colour that has some. - let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0); + let ss = if hdr { + let s = s.signum() * (s.abs() * 255.0).floor() / 255.0; + (if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }).max(0.0) + } else { + if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0) + }; hsl_to_rgb(hh, ss, ll) } @@ -1508,6 +1523,23 @@ mod tone_tests { /// Photoshop's 32-bit Colorize (photoshop corpus rgb32 hue-saturation-colorize.psd: hue -160, /// saturation 40, lightness +5): red becomes (0.3445, 0.5852, 0.7055), 0.95 of the chroma an /// 8-bit Colorize gives at the same lightness, (0.335, 0.5883, 0.715). + /// Photoshop's 32-bit Hue/Saturation (photoshop corpus rgb32 hue-saturation.psd: hue +40, + /// saturation +25, lightness -10): saturated colours leave 0..1, and the saturation factor is + /// 1 / (1 - 63/255), so red becomes (1.0477, 0.649, -0.1477), where 8 bits clip to (0.9, 0.6, 0). + #[test] + fn thirty_two_bit_hue_saturation_is_unclipped() { + let adj = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }; + let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3); + for (c, want) in + [([1.0, 0.0, 0.0], [1.0477, 0.649, -0.1477]), ([0.9301, 0.6105, 0.2541], [0.7804, 0.9369, 0.1289]), ([0.0, 0.3864, 1.0], [0.1871, -0.1477, 1.0477])] + { + assert!(near(hdr(&adj, c), want), "{c:?}: {:?}", hdr(&adj, c)); + } + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16)); + assert!(b.px[0].iter().all(|v| (0.0..=1.0).contains(v)), "16 bits clip: {:?}", b.px[0]); + } + #[test] fn thirty_two_bit_colorize_scales_the_saturation() { let adj = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 2b9c1bbdf4..3253515594 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -649,12 +649,17 @@ fn adjust(c: vec3) -> vec3 { if (light > 0.0) { lc = c + (1.0 - c) * light; } let lh = rgb_to_hsl(lc); hh = rem_euclid(lh.x + (p0.x + dh) / 360.0, 1.0); - // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. - if (sat > 0.0) { - ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. At 32 bits the + // slider is cut to whole 8-bit levels and the result isn't clipped + // (compose::adjust::hue_saturation_in). + var sq = sat; + if (hdr) { sq = sign(sat) * floor(abs(sat) * 255.0) / 255.0; } + if (sq > 0.0) { + ss = lh.y / max(1.0 - sq, 1.0 / 512.0); } else { - ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + ss = lh.y * (1.0 + sq); } + if (hdr) { ss = max(ss, 0.0); } else { ss = clamp(ss, 0.0, 1.0); } var rgb = hsl_to_rgb(hh, ss, lh.z); // compose::adjust::range_lightness: towards the brightest channel when raised, the darkest when lowered. let rl = clamp(dl, -1.0, 1.0); diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 5f1b522f0e..00539516bd 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -675,6 +675,10 @@ fn adjustment_layers() { let hs = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); check(&mut g, &d, &format!("colorize {depth:?}")); + // Hue/Saturation: at 32 bits the raised saturation isn't clipped. + let hs = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs2", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("hue/saturation {depth:?}")); // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); check(&mut g, &d, &format!("threshold {depth:?}")); diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 8c853f9d37..4a50717a5c 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 186, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 187, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 99da09dabb..903050b2fe 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (32-bit Colorize scales its saturation as Photoshop does; Photoshop-oracle floor 186/258; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (32-bit Hue/Saturation: unclipped saturation and Colorize's scaled saturation, as Photoshop does; Photoshop-oracle floor 187/258; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 186/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 187/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,7 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | A 32-bit Hue/Saturation Colorize builds its colour with the saturation times 1 − \|Lightness\|, as Photoshop does (8 and 16 bits keep the full saturation): photoshop rgb32 hue-saturation-colorize passes; Photoshop-oracle floor 185 → 186 of 258 | +| 2026-10-14 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1) and the slider is cut to whole 8-bit levels, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 185 → 187 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index 792b67d367..0d59c6f903 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: 32-bit Colorize saturation, Photoshop-oracle floor 186/258; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: 32-bit Hue/Saturation unclipped, Colorize saturation, Photoshop-oracle floor 187/258; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 186/258 (72%; 32-bit Colorize 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% + oracles 187/258 (72%; 32-bit Hue/Saturation 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -263,7 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | G1: 32-bit Colorize scales its saturation by 1 − \|Lightness\|; photoshop rgb32 hue-saturation-colorize passes; Photoshop-oracle floor 185 → 186 of 258 | +| 2026-10-14 | minor | G1: 32-bit Hue/Saturation unclipped (slider on whole levels) and Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 185 → 187 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 56fa36340e..62e82c6858 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 186/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 187/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 186 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 187 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| From c71f951b903cafcc2f3bfbf1801470033aeada2e Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 00:06:16 +0300 Subject: [PATCH 0142/1452] =?UTF-8?q?Fix=20View=20=E2=80=BA=20Snap=20off?= =?UTF-8?q?=20still=20snapping=20via=20smart=20guides=20(storytold/photocr?= =?UTF-8?q?aft#5782).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smart-guide targets were built even when the master snap switch was off; gate them on snap_on like other targets. Co-authored-by: Cursor --- crates/ui-egui/src/snap_ui.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 56fecd01eb..b0cb95e2fc 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -39,7 +39,7 @@ pub struct ActiveSnap { pub gesture: Gesture, pub start: [f64; 2], pub targets: SnapTargets, - /// Layer alignments only (smart guides), used when View › Snap is off. + /// Layer alignments only (smart guides), when View › Snap and Smart Guides are on. pub smart: SnapTargets, pub disabled: bool, } @@ -228,7 +228,8 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } - let smart = if matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; + let smart = + if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } @@ -571,6 +572,19 @@ mod tests { } } + #[test] + fn view_snap_off_blocks_smart_guide_snapping() { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.ui.view.show.smart_guides = true; + app.ui.extras.snap = false; + app.ui.tool_options.move_auto_select = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + assert_eq!(mover_bounds(&app).x0, 303, "View › Snap off must not snap via smart guides"); + } + #[test] fn ctrl_or_snap_off_disables_snapping() { let mut app = app_with_box(); From 5f83b53a323db31de9ddc8edfaee106508a52c4b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 23:13:41 +0200 Subject: [PATCH 0143/1452] Type: keep superscript and subscript (FontBaseline) when a text layer is re-rendered (#5645) CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text.rs | 13 ++++++++++++ crates/engine/src/type_cmds.rs | 13 ++++++++++-- crates/engine/src/type_styles_cmds.rs | 3 ++- crates/text/src/layout.rs | 20 +++++++++++++++++- crates/text/src/psd.rs | 30 ++++++++++++++++++++++++++- crates/text/src/tests.rs | 24 +++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 6f7836fa46..900e487f07 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -32,6 +32,16 @@ pub enum Caps { AllCaps, } +/// Superscript / subscript (Photoshop's `FontBaseline`). The run is drawn smaller and raised or +/// lowered; `size_pt` and `baseline_shift_pt` keep their own values. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FontBaseline { + #[default] + Normal, + Superscript, + Subscript, +} + /// An OpenType feature setting, e.g. `{ tag: "ss01", value: 1 }`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FontFeature { @@ -79,6 +89,8 @@ pub struct CharStyle { /// per character run as `Kerning`). Added to the automatic kerning of the pair. pub kern: f32, pub caps: Caps, + /// Superscript or subscript. + pub baseline: FontBaseline, /// Standard ligatures (`liga`/`clig`). pub ligatures: bool, /// Discretionary ligatures (`dlig`). @@ -117,6 +129,7 @@ impl Default for CharStyle { kerning: Kerning::Metrics, kern: 0.0, caps: Caps::Normal, + baseline: FontBaseline::Normal, ligatures: true, discretionary_ligatures: false, features: Vec::new(), diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..692cd0e02f 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -10,7 +10,8 @@ use std::sync::Arc; use photocraft_color::Color; use photocraft_doc::text::{ - AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, + AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, + TextRun, TextShape, }; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer}; use serde_json::{Value, json}; @@ -336,6 +337,14 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { }; hit(true); } + if let Some(v) = p.get("baseline").and_then(Value::as_str) { + s.baseline = match v { + "superscript" => FontBaseline::Superscript, + "subscript" => FontBaseline::Subscript, + _ => FontBaseline::Normal, + }; + hit(true); + } if let Some(Value::Object(m)) = p.get("features") { for (tag, v) in m { let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1); @@ -785,7 +794,7 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Vec { vec![ diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 797dfe7f8a..93b4261a00 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result> { /// A character style with every field changed from the default, to see which fields a set of /// Character-panel keys touches. fn perturbed_char() -> CharStyle { - use photocraft_doc::text::{Caps, FontFeature, FontVariation, Kerning}; + use photocraft_doc::text::{Caps, FontBaseline, FontFeature, FontVariation, Kerning}; CharStyle { font_family: "\u{1}".into(), font_style: "\u{1}".into(), @@ -105,6 +105,7 @@ fn perturbed_char() -> CharStyle { kerning: Kerning::Off, kern: -12345.0, caps: Caps::AllCaps, + baseline: FontBaseline::Subscript, ligatures: false, discretionary_ligatures: true, features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }], diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..9ee29b9b19 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -22,7 +22,7 @@ use parley::{ PositionedLayoutItem, StyleProperty, }; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape}; +use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, TextAlign, TextDirection, TextShape}; use skrifa::raw::types::Tag; use crate::fonts::FontDb; @@ -31,6 +31,23 @@ use crate::fonts::FontDb; /// readable hierarchy for every font instead of silently rendering lowercase text unchanged. const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7; +/// Photoshop's default superscript / subscript size and position (Type preferences, stored in a +/// PSD's text resources as `SuperscriptSize` .583, `SuperscriptPosition` .333 and their +/// `Subscript` twins): the run is drawn at 58.3% of its size and moved by 33.3% of its size. +const SCRIPT_SIZE: f32 = 0.583; +const SCRIPT_POSITION: f32 = 0.333; + +/// The style a run is drawn with: superscript and subscript shrink it and shift its baseline. +fn drawn_style(s: &mut CharStyle) { + let dir = match s.baseline { + FontBaseline::Normal => return, + FontBaseline::Superscript => 1.0, + FontBaseline::Subscript => -1.0, + }; + s.baseline_shift_pt += dir * SCRIPT_POSITION * s.size_pt; + s.size_pt *= SCRIPT_SIZE; +} + /// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the /// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones. pub const DEFAULT_TAB_STOP_PT: f32 = 36.0; @@ -478,6 +495,7 @@ impl Layouter { crate::served::request(&s.font_family); } fonts.select_named_face(&mut s); + drawn_style(&mut s); out.styles.push(s); } let run_starts: Vec = runs diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..d83c04de69 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -11,7 +11,9 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp}; +use photocraft_doc::text::{ + AntiAlias, Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp, +}; use photocraft_geom::{Affine, Point}; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; @@ -623,6 +625,11 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C Some(2) => Caps::AllCaps, _ => Caps::Normal, }; + s.baseline = match g("FontBaseline").and_then(E::as_i64) { + Some(1) => FontBaseline::Superscript, + Some(2) => FontBaseline::Subscript, + _ => FontBaseline::Normal, + }; if let Some(c) = g("FillColor") { // Positions matter (alpha, then the channels): a non-finite entry voids the colour. let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); @@ -858,6 +865,14 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { Caps::AllCaps => 2, }), ), + ( + "FontBaseline".into(), + E::Int(match s.baseline { + FontBaseline::Normal => 0, + FontBaseline::Superscript => 1, + FontBaseline::Subscript => 2, + }), + ), ("Underline".into(), E::Bool(s.underline)), ("Strikethrough".into(), E::Bool(s.strikethrough)), ("Ligatures".into(), E::Bool(s.ligatures)), @@ -1362,6 +1377,19 @@ mod nonfinite_tests { assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); } + /// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back. + #[test] + fn font_baseline_round_trips() { + use photocraft_doc::text::FontBaseline; + let d = parse(b"<< /FontBaseline 2 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript); + let d = parse(b"<< /FontBaseline 1 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript); + let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() }; + let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0); + assert_eq!(back.baseline, FontBaseline::Subscript); + } + #[test] fn infinite_dpi_does_not_write_a_zero_size() { let layer = diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..f00a8e8862 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -434,6 +434,30 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #5645: superscript and subscript runs are drawn at 58.3% of their size, raised or lowered by +/// 33.3% of it, as Photoshop draws them; they used to be drawn as plain text. +#[test] +fn superscript_and_subscript_shrink_and_shift_the_run() { + use photocraft_doc::text::FontBaseline; + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 60.0, ..Default::default() }; + let rect = |e: &mut TextEngine, baseline: FontBaseline| { + let (_, r) = + e.render(&TextLayer { transform: Affine::translate(5.0, 100.0), ..styled("H", CharStyle { baseline, ..base.clone() }) }, 72.0, PixelFormat::RGBA8); + r.rect + }; + let plain = rect(&mut e, FontBaseline::Normal); + let sup = rect(&mut e, FontBaseline::Superscript); + let sub = rect(&mut e, FontBaseline::Subscript); + let ratio = |r: &photocraft_geom::Rect| r.height() as f32 / plain.height() as f32; + for r in [&sup, &sub] { + assert!((ratio(r) - 0.583).abs() < 0.06, "plain {plain:?}, scripted {r:?}"); + } + // The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below. + assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}"); + assert!((sub.y1 - plain.y1 - 20).abs() <= 2, "plain {plain:?}, subscript {sub:?}"); +} + /// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one /// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the /// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. From 5781847da3d51b07689005df2fc20898ee9efcf4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 23:15:43 +0200 Subject: [PATCH 0144/1452] Paint: Lock Transparency blends source-atop on semi-transparent pixels (#5783) With alpha kept (Lock Transparent Pixels, Fill's Preserve Transparency), the brush, mixer, Fill, Gradient and Paint Bucket composited source-over and then restored the old alpha, so a half-transparent pixel took the new colour by as / ao instead of as. They now blend source-atop, as Photoshop does: Co = (1-as)*Cb + as*B(Cb,Cs), alpha unchanged. New shared helper blend::composite_atop; over_native's lock branch lerps by the source alpha. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/algo/src/paint.rs | 21 +++++++++++++++++---- crates/color/src/blend.rs | 32 ++++++++++++++++++++++++++++++++ crates/engine/src/fill_cmds.rs | 28 ++++++++++++++++++++++++++-- crates/paint/src/lib.rs | 18 ++++++++++++++++++ crates/paint/src/render.rs | 9 +++++---- crates/paint/src/retouch.rs | 34 +++++++++++++++++++++++++++------- 6 files changed, 125 insertions(+), 17 deletions(-) diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index c32f28c648..8146af2582 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -89,10 +89,7 @@ fn composite_area( let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; let px = &mut region[i..i + n]; let d = photocraft_raster::to_rgba(&fmt, px); - let mut o = blend::composite(blend_mode, d, color(x, y), k); - if lock_transparency { - o[3] = d[3]; - } + let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) }; from_rgba_into(&fmt, o, &mut enc); // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. if let Some(i) = inks @@ -296,6 +293,22 @@ mod tests { assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection"); } + #[test] + fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() { + // Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783). + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for mode in [BlendMode::Normal, BlendMode::Multiply] { + let mut s = Surface::new(fmt); + let a = Rect::new(0, 0, 4, 1); + s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]); + let black = [0.0, 0.0, 0.0, 1.0]; + paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true); + let p = s.rgba(2, 0); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}"); + } + } + } + #[test] fn bucket_fills_contiguous_region() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index cd8f6eac36..5cf5fbd691 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -460,6 +460,22 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: out } +/// [`composite`] with the backdrop's alpha kept (Lock Transparent Pixels, Fill's Preserve +/// Transparency): "source atop", `Co = (1−αs)·Cb + αs·B(Cb,Cs)`, `αo = αb`. Unlike source-over +/// followed by restoring the alpha, a half-transparent backdrop takes the new colour by `αs` alone. +pub fn composite_atop(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let b = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]); + let mut out = backdrop; + for i in 0..3 { + out[i] = (1.0 - as_) * backdrop[i] + as_ * b[i]; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -816,4 +832,20 @@ mod tests { let r = composite(BlendMode::Screen, [0.0, 0.0, 0.0, 0.5], [0.0, 0.0, 0.0, 0.5], 1.0); assert!(close(r[3], 0.75)); } + + #[test] + fn composite_atop_keeps_alpha_and_moves_colour_by_source_alpha() { + let bg = [1.0, 1.0, 1.0, 0.5]; + // Half-transparent white, black at 50%: halfway, alpha kept (#5783). + let r = composite_atop(BlendMode::Normal, bg, [0.0, 0.0, 0.0, 1.0], 0.5); + assert!(close(r[0], 0.5) && close(r[2], 0.5) && r[3] == 0.5, "{r:?}"); + // Non-Normal modes blend against the backdrop as it is: Multiply by black at 50% halves it. + let r = composite_atop(BlendMode::Multiply, bg, [0.0, 0.0, 0.0, 1.0], 0.5); + assert!(close(r[0], 0.5) && r[3] == 0.5, "{r:?}"); + // Opaque backdrops agree with source-over. + let opaque = [0.2, 0.4, 0.6, 1.0]; + assert_eq!(composite_atop(BlendMode::Screen, opaque, [0.5, 0.5, 0.5, 0.7], 0.8), composite(BlendMode::Screen, opaque, [0.5, 0.5, 0.5, 0.7], 0.8)); + // A transparent source leaves the backdrop. + assert_eq!(composite_atop(BlendMode::Normal, bg, [0.0, 0.0, 0.0, 0.0], 1.0), bg); + } } diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 2aa0a02bdc..6a2ca83548 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -518,8 +518,17 @@ fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rec continue; } } - let mut o = if restore && blend == BlendMode::Normal { lerp(d, c, k) } else { photocraft_color::blend::composite(blend, d, c, k) }; - if keep_alpha || !fmt.alpha { + let keep = keep_alpha || !fmt.alpha; + let mut o = if restore && blend == BlendMode::Normal { + lerp(d, c, k) + } else if keep { + // Alpha kept: source atop, so a half-transparent pixel takes the new colour by the + // source alpha alone (#5783). + photocraft_color::blend::composite_atop(blend, d, c, k) + } else { + photocraft_color::blend::composite(blend, d, c, k) + }; + if keep { o[3] = d[3]; } let m = from_rgba_into(fmt, o, &mut enc); @@ -616,6 +625,21 @@ mod tests { })); } + #[test] + fn preserve_transparency_blends_source_atop_on_semi_transparent_pixels() { + // White at 50%, then black at 50% keeping alpha: Photoshop gives (127,127,127,128), the + // colour moved halfway, at every depth and in non-Normal modes too (#5783). + for depth in [8, 16, 32] { + for mode in ["normal", "multiply"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap(); + s.execute(CMD, json!({"contents": "white", "opacity": 50})).unwrap(); + s.execute(CMD, json!({"contents": "black", "opacity": 50, "mode": mode, "preserveTransparency": true})).unwrap(); + assert!(close(px(&s, 3, 3), [0.5, 0.5, 0.5, 0.5]), "{depth} {mode} {:?}", px(&s, 3, 3)); + } + } + } + #[test] fn history_pattern_and_content_aware() { let mut s = session(); diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 2f65fc1a0b..74d74be893 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -321,6 +321,24 @@ mod tests { assert_eq!(s.pixel(70, 10)[3], 0.0); } + #[test] + fn lock_transparency_blends_source_atop_on_semi_transparent_pixels() { + // Half-transparent white under a black stroke at 50%: the colour moves halfway (Photoshop's + // source-atop), not by the source-over weight 0.5 / 0.75 (#5783). + for f in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for mode in [photocraft_color::BlendMode::Normal, photocraft_color::BlendMode::Multiply] { + let mut s = Surface::new(f); + s.fill_rect(Rect::new(0, 0, 100, 20), &[1.0, 1.0, 1.0, 0.5]); + let mut st = stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0); + st.brush.opacity = 0.5; + st.brush.mode = mode; + apply_stroke(&mut s, &st, None, true); + let p = s.rgba(50, 10); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{f:?} {mode:?} {p:?}"); + } + } + } + #[test] fn pressure_changes_size() { let mut st = stroke(&[(0.0, 0.0), (100.0, 0.0)], 20.0); diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 5f59aafad1..0fbb353976 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -1043,10 +1043,11 @@ impl StrokeRenderer { } else { let d = to_rgba(&fmt, px); let sr = to_rgba(&fmt, &src[..n]); - let mut o = photocraft_color::blend::composite(b.mode, d, sr, k); - if lock_transparency { - o[3] = d[3]; - } + let o = if lock_transparency { + photocraft_color::blend::composite_atop(b.mode, d, sr, k) + } else { + photocraft_color::blend::composite(b.mode, d, sr, k) + }; let mut enc = [0.0f32; 8]; from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 9ef837c0c0..58fd4fa5bc 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -143,15 +143,20 @@ pub fn over_native(fmt: &PixelFormat, dst: &mut [f32], src: &[f32], k: f32, lock match alpha_index(fmt) { Some(a) => { let (da, sa) = (dst[a], src[a] * k); + if lock_transparency { + // Alpha kept: source atop, the colour moves by the source alpha alone (#5783). + for c in 0..a { + dst[c] += (src[c] - dst[c]) * sa; + } + return; + } let oa = sa + da * (1.0 - sa); if oa > 0.0 { for c in 0..a { dst[c] = (src[c] * sa + dst[c] * da * (1.0 - sa)) / oa; } } - if !lock_transparency { - dst[a] = oa; - } + dst[a] = oa; } None => { for c in 0..dst.len() { @@ -202,10 +207,8 @@ pub fn apply_coverage( } else { let d = to_rgba(&fmt, px); let s = to_rgba(&fmt, src); - let mut o = photocraft_color::blend::composite(blend, d, s, k); - if lock_transparency { - o[3] = d[3]; - } + let o = + if lock_transparency { photocraft_color::blend::composite_atop(blend, d, s, k) } else { photocraft_color::blend::composite(blend, d, s, k) }; let mut enc = [0.0f32; 8]; from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); @@ -463,6 +466,23 @@ mod tests { } } + #[test] + fn coverage_composite_under_lock_blends_source_atop() { + // Locked half-transparent white, black at coverage 0.5: colour halfway, alpha kept (#5783). + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for mode in [BlendMode::Normal, BlendMode::Multiply] { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 1.0, 1.0, 0.5]); + let r = Rect::new(0, 0, 4, 4); + let mut paint = Region::new(r, 4); + paint.data.chunks_mut(4).for_each(|p| p.copy_from_slice(&[0.0, 0.0, 0.0, 1.0])); + apply_coverage(&mut s, r, &[1.0; 16], 0.5, None, true, &paint, mode); + let p = s.rgba(1, 1); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}"); + } + } + } + #[test] fn dab_stroke_runs_sequentially_and_respects_selection() { let mut s = Surface::new(PixelFormat::GRAY8); From 667a14d9452786c566125060a32546c348ac5570 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 23:20:06 +0200 Subject: [PATCH 0145/1452] PSD Multichannel: alpha channels are stored as-is, not as ink (#5790) A Multichannel PSD stores ink planes as tone (0 = solid ink), but the planes DisplayInfo (1077) marks as alpha-type (kind 0/1) are stored as-is, like alpha channels in every other mode. Import inverted every plane and export wrote every channel inverted, so a white (selected) alpha imported as 0 and RGB -> Multichannel -> save wrote alpha channels black. Measured with a file Photoshop saved itself: a new 8x4 8-bit white RGB document plus a "selected areas" alpha channel, white left half / black right half, converted to Multichannel and saved as PSD. Merged planes: Cyan, Magenta, Yellow = 255 everywhere (ink-inverted: no ink on white paper); alpha = 255 255 255 255 0 0 0 0 per row (as-is, 255 = selected). DisplayInfo kinds: 2, 2, 2, 0. - io: after DisplayInfo, flip alpha-type channels back; on export invert only spot channels. - compose: an alpha-type channel prints as black ink of 1 - value, so the composite of a Multichannel file is unchanged. Tests: a kind-0 alpha imports as stored (white = 1.0) with the spot plane still read as ink, the composite and the byte-for-byte round trip hold (8 and 16 bit); a full alpha exports as a max plane next to a solid ink stored 0. The #4031 composite check now compares against a black ink of the inverted alpha, within f32 rounding; the compose unit test uses the as-is polarity for its alpha channel. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/multichannel.rs | 13 +++-- crates/io/src/multichannel_map.rs | 17 ++++-- crates/io/tests/multichannel.rs | 87 +++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 88c0b2f359..4eedc88619 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -5,6 +5,7 @@ //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is //! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document //! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. +//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. use photocraft_doc::Document; use photocraft_geom::Rect; @@ -16,8 +17,10 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); for ch in &doc.channels { + let alpha = ch.spot.is_none(); let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - if ch.surface.content_bounds().intersect(&rect).is_empty() { + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { continue; } let c = ink.to_rgb(); @@ -25,7 +28,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let n = ch.surface.channels(); ch.surface.read_region_into(rect, &mut vals); for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = v[0].clamp(0.0, 1.0); + let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); if v <= 0.0 { continue; } @@ -66,14 +69,16 @@ mod tests { assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); - // An alpha-type channel prints as a black ink. + // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is + // unselected, as Photoshop shows it. let mut k = Surface::new(fmt); - k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]); + k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); let r = crate::flatten(&doc); // 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step. assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]); assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); + assert_eq!(r.px[3], [0.0, 0.0, 0.0, 1.0]); // Other modes have no inks. doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index db31e743ef..0b2c7e6c63 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -3,8 +3,9 @@ //! A Multichannel document has no layers: its image is a set of ink (spot) channels, stored in //! [`Document::channels`] with ink density as the value (1 = solid ink, like every spot channel). //! A PSD stores the same planes the way Photoshop shows them in the Channels panel: 0 (black) = -//! solid ink, max = paper. Channel names come from resources 1006 / 1045 and ink colours from -//! DisplayInfo (1077), exactly as for spot channels in the other modes. +//! solid ink, max = paper. Alpha-type channels (DisplayInfo kind 0 / 1) are stored as-is, like the +//! alpha channels of every other mode (max = selected). Channel names come from resources 1006 / +//! 1045 and ink colours from DisplayInfo (1077), exactly as for spot channels in the other modes. use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; @@ -61,6 +62,14 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels); } + // Alpha-type planes are stored as-is, not as ink: undo the inversion for them. + for ch in doc.channels.iter_mut().filter(|c| c.spot.is_none()) { + let vals: Vec = ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()).map(|v| 1.0 - v[0]).collect(); + let mut s = Surface::new(fmt); + s.write_region(canvas, &vals); + s.prune(); + ch.surface = s; + } } /// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane). @@ -90,8 +99,10 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< } } for ch in &channels { + // Inks are stored as tone (0 = solid ink), alpha-type channels as-is. + let ink = ch.spot.is_some(); for v in ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()) { - encode_be(1.0 - v[0], sample, &mut planes); + encode_be(if ink { 1.0 - v[0] } else { v[0] }, sample, &mut planes); } } let big = doc.size.width > 30_000 || doc.size.height > 30_000; diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/multichannel.rs index d6016070d1..fd8f8448c3 100644 --- a/crates/io/tests/multichannel.rs +++ b/crates/io/tests/multichannel.rs @@ -46,10 +46,21 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { assert!((a.opacity - 0.5).abs() < 1e-6); assert_eq!(a.indicates, ColorIndicates::MaskedAreas); - // It still prints as black ink, so the composite is what a black spot channel gives. + // It still prints as black ink, so the composite is what a black spot channel gives. The + // alpha is stored as-is (1 = selected), so that ink is its inverse, as on the PSD plane. let mut as_ink = d.clone(); + let canvas = d.bounds(); + let ink: Vec = d.channels[1].surface.read_region(canvas).iter().map(|v| 1.0 - v).collect(); + let mut s = photocraft_raster::Surface::new(d.channels[1].surface.format()); + s.write_region(canvas, &ink); + as_ink.channels[1].surface = s; as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); - assert_eq!(photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + // Within f32 rounding: the inverted copy is re-quantised to the channel depth. + let (shown, inked) = (photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + assert_eq!(shown.len(), inked.len()); + for (a, b) in shown.iter().zip(&inked) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} vs {b:?} ({depth}-bit)"); + } // Saved back unchanged: same DisplayInfo, same planes, same channels on reopening. let (back, d2) = roundtrip(&d); @@ -62,6 +73,78 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { } } +/// Stored samples of one big-endian plane at `depth` (8 or 16), from 0..=1 tone values. +fn plane(vals: &[f32], depth: u16) -> Vec { + let max = if depth == 16 { 65535.0 } else { 255.0 }; + vals.iter() + .flat_map(|v| { + let s = (v * max).round() as u16; + if depth == 16 { s.to_be_bytes().to_vec() } else { vec![s as u8] } + }) + .collect() +} + +/// A 4×1 Multichannel file laid out like Photoshop writes one (measured with a file Photoshop saved +/// after RGB → Multichannel): a cyan spot plane stored as ink-inverted tone (0 = solid ink) and a +/// kind-0 alpha plane stored as-is (max = selected), selected in the left half. +fn ink_and_alpha_file(depth: u16) -> PsdFile { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 4, 1); + let mut planes = plane(&[0.0, 1.0, 0.0, 1.0], depth); + planes.extend(plane(&[1.0, 1.0, 0.0, 0.0], depth)); + f.image_data = photocraft_psd::ImageData::encode(Compression::Rle, &planes, &f.header).unwrap(); + let mut info = 1u32.to_be_bytes().to_vec(); + display_record(&mut info, [0, 65535, 65535], 0, 2); + display_record(&mut info, [65535, 0, 0], 50, 0); + f.resources.push(ImageResource::new(1077, info)); + f +} + +fn values(ch: &photocraft_doc::AlphaChannel) -> Vec { + ch.surface.read_region(photocraft_geom::Rect::new(0, 0, 4, 1)).chunks_exact(ch.surface.channels()).map(|v| v[0]).collect() +} + +#[test] +fn alpha_type_channels_are_stored_as_is_not_as_ink() { + for depth in [8, 16] { + let f = ink_and_alpha_file(depth); + let (d, _) = psd_to_document(&f); + // A white (selected) alpha plane imports as 1.0, like an alpha channel of any other mode; + // the spot plane is still read as ink density (stored 0 = solid ink). + assert_eq!(d.channels[1].spot, None); + assert_eq!(values(&d.channels[1]), [1.0, 1.0, 0.0, 0.0], "alpha channel ({depth}-bit)"); + assert_eq!(values(&d.channels[0]), [1.0, 0.0, 1.0, 0.0], "spot channel ({depth}-bit)"); + + // The composite is what Photoshop shows: the alpha prints as black ink where unselected. + let px = photocraft_compose::flatten(&d).px; + assert_eq!(px.iter().map(|p| [p[0], p[1], p[2]]).collect::>(), [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + + // Saved back byte for byte. + let (back, d2) = roundtrip(&d); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap(), "{depth}-bit"); + assert_eq!(values(&d2.channels[1]), [1.0, 1.0, 0.0, 0.0]); + } +} + +#[test] +fn a_full_alpha_channel_exports_as_a_full_plane() { + use photocraft_color::{Color, ColorMode as Mode, PixelFormat, SampleType}; + for (sample, depth) in [(SampleType::U8, 8u16), (SampleType::U16, 16)] { + let mut doc = photocraft_doc::Document::new("m", photocraft_doc::Size::new(4, 1), Mode::Multichannel, sample); + let fmt = PixelFormat::new(Mode::Grayscale, sample, false); + let surface = |v: f32| { + let mut s = photocraft_raster::Surface::new(fmt); + s.write_region(photocraft_geom::Rect::new(0, 0, 4, 1), &[v; 4]); + s + }; + doc.channels.push(photocraft_doc::AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..photocraft_doc::AlphaChannel::new("Black", surface(1.0)) }); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", surface(1.0))); + let (back, _) = roundtrip(&doc); + let mut want = plane(&[0.0; 4], depth); + want.extend(plane(&[1.0; 4], depth)); + assert_eq!(back.decode_merged().unwrap(), want, "solid ink is stored 0, a full alpha max ({depth}-bit)"); + } +} + #[test] fn resources_and_global_blocks_survive_a_save() { let mut f = file(8); From a43f8b5ece0022ad8d4baa2af0d39bbfb39811dd Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 17:25:06 -0400 Subject: [PATCH 0146/1452] Auto-Blend Layers: blend a layer the canvas crops to all-overlap (#5745) Panorama seams skipped an image lying wholly inside the composite, so after Auto-Align moved the second photo partly off the canvas it got an all-black mask and nothing changed. Such an image now takes the side of the seam it lies towards; blending covers the layers past the canvas; Seamless Tones and Colors evens out exposure; the Background becomes a layer to take its mask. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/panorama.rs | 48 +++++++++++++++++++++- crates/engine/src/align_cmds.rs | 71 +++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index fd7ccbc591..eaf2b5eb44 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -897,11 +897,31 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o label[i] = img as i32; } } - if no == 0 || nn == no { + if no == 0 { continue; } - let (ox, oy) = (sx / no as f64, sy / no as f64); let (cx, cy) = (nx / nn as f64, ny / nn as f64); + let (ox, oy) = if nn == no { + // Wholly inside the composite (#5745: two photos in one document, the second moved + // by Auto-Align so the canvas crops it to the part over the first). Its side of the + // seam is the one it lies towards from the composite's centre; an image centred on + // the composite has no side, and the earlier images keep it. + let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize); + for (i, l) in label.iter().enumerate() { + if *l >= 0 { + px += (i % w) as f64; + py += (i / w) as f64; + pn += 1; + } + } + let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64); + if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 { + continue; + } + (px, py) + } else { + (sx / no as f64, sy / no as f64) + }; let vertical = (cx - ox).abs() >= (cy - oy).abs(); // Image on the increasing side along the seam's cross axis? let positive = if vertical { cx > ox } else { cy > oy }; @@ -1430,4 +1450,28 @@ mod tests { assert!(max_jump < 0.1, "max step {max_jump}"); assert!(cov.iter().all(|c| *c == 1.0)); } + + #[test] + fn an_image_wholly_inside_the_composite_still_gets_its_side() { + // #5745: the first image covers the whole 120×40 grid, the second only its right part + // (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything. + let (w, h) = (120usize, 40usize); + let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]]; + let cover: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()]; + let labels = seam_labels(w, h, &luma, &cover, &[0, 1]); + for y in 0..h { + assert_eq!(labels[y * w + 5], 0, "row {y}"); + assert_eq!(labels[y * w + w - 1], 1, "row {y}"); + // The second image's region is one run at the right of its seam. + let row = &labels[y * w..(y + 1) * w]; + let first = row.iter().position(|l| *l == 1).unwrap(); + assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + // An image centred on the composite has no side: the first image keeps the overlap. + let centred: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()]; + assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0)); + // Two images covering the same cells, likewise. + let same = vec![vec![true; w * h]; 2]; + assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0)); + } } diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..05d367e14b 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -178,13 +178,21 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); - let mut area = Rect::EMPTY; + let mut union = Rect::EMPTY; + let mut content_px = 0u64; + let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height()); for id in &ids { if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) { - area = area.union(&b); + union = union.union(&b); + content_px = content_px.saturating_add(px_of(&b)); } } - let area = area.intersect(&doc.bounds()); + // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers + // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All + // shows the whole panorama and the seams know where each layer really extends the picture. + // Layers so far apart that their bounds are mostly empty keep to the canvas. + let canvas = doc.bounds(); + let area = if px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2) { union } else { union.intersect(&canvas) }; if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -225,7 +233,9 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); - let b = seam_blend(&surfs, area, &order, seamless, false, None); + // Seamless Tones and Colors also evens out exposure and colour between the layers (gain + // compensation over their overlaps), as in Photomerge. + let b = seam_blend(&surfs, area, &order, seamless, seamless, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) @@ -258,6 +268,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let label = "Auto-Blend Layers"; let (new_layer, depth_layer) = s.edit(label, |doc, active| { for (id, wt) in ids.iter().zip(&weights) { + // The Background can't have a mask: it becomes a normal layer first, as in Photoshop. + crate::extra_cmds::background_to_layer_for_mask(doc, *id); let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; let mut mask = LayerMask::hide_all(); mask.surface.write_region(area, wt); @@ -418,6 +430,57 @@ mod tests { } } + /// #5745: two photos in one document, the second moved by Auto-Align so it hangs off the + /// canvas's right edge, 10% brighter. The bottom (the Background) covers the whole canvas, + /// so cropped to the canvas the top layer was all overlap: it got an all-black mask and the + /// picture didn't change. Now the top layer shows on its side of the seam, off the canvas + /// too, and Seamless Tones and Colors evens out the exposure. + #[test] + fn auto_blend_panorama_of_a_layer_hanging_off_the_canvas() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "depth": depth, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.background().unwrap().id; + // The same scene in both layers, so they agree where they overlap (x 120..200). + let scene = |x: i32, gain: f32| [(0.2 + 0.6 * x as f32 / 320.0) * gain, 0.4 * gain, 0.5 * gain, 1.0]; + s.edit("left", |doc, _| { + let surf = doc.layer_mut(bg).unwrap().surface_mut().unwrap(); + for x in 0..200 { + surf.fill_rect(Rect::new(x, 0, x + 1, 60), &scene(x, 1.0)); + } + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("right", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for x in 120..320 { + surf.fill_rect(Rect::new(x, 0, x + 1, 60), &scene(x, 1.1)); + } + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[bg, right]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.is_background(bg), "depth {depth}: the Background became a layer to take its mask"); + let ml = doc.layer(bg).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0), "depth {depth}"); + assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "depth {depth}: off the canvas"); + for x in 0..320 { + assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "depth {depth}: x {x} shows one layer"); + } + // The merged layer covers the off-canvas part too, with the exposures matched: the + // flat blue channel reads the same on both sides (0.5 vs 0.55 before). + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + let (a, b) = (blended.pixel(40, 30), blended.pixel(300, 30)); + assert_eq!(b[3], 1.0, "depth {depth}"); + assert!((a[2] - b[2]).abs() < 0.015, "depth {depth}: {a:?} {b:?}"); + } + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); From 6bb3130a4cb851401536daaec9c7f3acfeae69cf Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Wed, 14 Oct 2026 18:34:13 -0300 Subject: [PATCH 0147/1452] fix: update zoom interruption for wheel navigation --- crates/ui-egui/src/canvas.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d9b69aef15..fec87a979e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2895,6 +2895,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let full = rect; let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect }; + // Both wheel reads share one owner so snap-zoom input is accumulated only once per frame. + let wheel_owner = ui.id().with(("canvas-wheel", idx)); // Direct navigation starts from the authoritative camera. This also means a click or drag // never maps through a camera that is still moving under the pointer. let (pointer_here, hover_here) = ctx.input(|i| { @@ -2909,6 +2911,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, + wheel_owner, + hover_here, ) .is_some()); if direct_input { @@ -3226,7 +3230,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, - response.id.with("wheel"), + wheel_owner, nav_hovered, ); if nav_hovered { From fbaf6795cfdc087b8343e7b1c351e7ddc8c8662d Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 22:50:34 +0100 Subject: [PATCH 0148/1452] refactor(engine): drop the second damage pass after single-edit duplicates and pastes Session::edit has reported a new layer's bounds as damage since #3847, so Duplicate Layer, Duplicate Layers and Paste no longer need to keep the document from before the edit and work the damage out again with note_insert. Paste Into still does: it is three edits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 -- crates/engine/src/edit_cmds.rs | 4 +--- crates/engine/src/layer_multi_cmds.rs | 18 +++++++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 68d00a7da3..924f6e5f8f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -648,7 +648,6 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id)?; if let Some(name) = &name { @@ -661,7 +660,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..1340c70946 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -390,7 +390,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -399,8 +398,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9a79a72050..31733f12eb 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -884,9 +884,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -1378,7 +1378,6 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); @@ -1400,7 +1399,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1807,8 +1805,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1831,6 +1829,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } From 8744820a279f9df7a8525252661af526212e9b2d Mon Sep 17 00:00:00 2001 From: bazmarth Date: Wed, 14 Oct 2026 22:07:03 +0000 Subject: [PATCH 0149/1452] Shapes: rounded corners on live triangles, polygons and stars (TOOL-214-12) As Photoshop's rounded triangles and polygons, a live polygon (the Triangle tool draws a 3-sided one, stars included) now has one corner radius: - doc: LiveShape::Polygon gains corner_radius (serde default, so older .pcraft files load sharp); - vector: shapes::fillet and shapes::round_corners round every vertex with a circular arc tangent to both edges, each capped so neighbouring arcs meet at most halfway along an edge; - engine: shape.create / shape.edit take "cornerRadius"; transforms scale it like a rectangle's radii; - ui: the Properties panel shows a Corner radius field for polygons, and the on-canvas corner widgets now cover every selected rectangle, triangle, polygon and star (Path Selection, the Rectangle/Triangle/Polygon tools, Move with Show Transform Controls). Dragging any widget gives every selected shape the same radius; Alt still changes one corner of a rectangle. The widgets take the unsnapped press: snapping used to pull a shape tool's press off the widget, so the tool drew a new shape instead. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/vector.rs | 13 ++ crates/engine/src/vector_cmds.rs | 24 +- crates/engine/src/vector_cmds/tests.rs | 32 +++ crates/io/src/vector_map.rs | 4 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/corner_radius.rs | 260 +++++++++++++++------- crates/ui-egui/src/corner_radius_tests.rs | 83 +++++++ crates/ui-egui/src/shape_dialog_tests.rs | 2 +- crates/ui-egui/src/vector_ui.rs | 9 +- crates/vector/src/shapes.rs | 152 ++++++++++++- docs/scorecard.md | 2 +- docs/ui-parity.md | 5 +- scorecard/tools.toml | 2 +- 13 files changed, 496 insertions(+), 98 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..6ccac60741 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -302,10 +302,14 @@ pub enum LiveShape { rect: [f64; 4], }, /// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon). + /// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the + /// Triangle tool draws a 3-sided one. Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, + #[serde(default)] + corner_radius: f64, }, /// Line from `from` to `to` drawn as a filled bar `weight` px thick. Line { @@ -352,6 +356,15 @@ mod tests { assert_eq!(t.control_bounds(), Some((2.0, 3.0, 6.0, 10.0))); } + #[test] + fn a_polygon_saved_before_corner_radii_loads_sharp() { + let old = r#"{"kind":"polygon","rect":[0.0,0.0,10.0,10.0],"sides":3,"star_ratio":1.0}"#; + let back: LiveShape = serde_json::from_str(old).unwrap(); + assert_eq!(back, LiveShape::Polygon { rect: [0.0, 0.0, 10.0, 10.0], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); + let round = LiveShape::Polygon { rect: [0.0; 4], sides: 6, star_ratio: 1.0, corner_radius: 4.5 }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&round).unwrap()).unwrap(), round); + } + #[test] fn serde_roundtrip() { let p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (3.0, 4.0)]).with_op(PathOp::Exclude)]); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..77849f7377 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -412,13 +412,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) } "ellipse" => LiveShape::Ellipse { rect: rect()? }, "polygon" | "star" => { - let (ps, pr) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }), + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } } + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } } "line" => { let (pf, pto, pw) = match prev { @@ -510,7 +516,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } } LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } LiveShape::Line { from, to, weight } => { // Live-shape edits regenerate the line's filled bar from its parameters. // Keep the weight in sync with a positive scale of the path, or an @@ -704,7 +712,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result { sh.live = None; sh.psd_raw = None; } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"]; + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; if geom_keys.iter().any(|k| p.get(*k).is_some()) { let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; @@ -1683,7 +1691,7 @@ pub fn specs() -> Vec { "New Shape Layer", ["Layer", "New"], leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## )), has_doc, shape_create @@ -1693,7 +1701,7 @@ pub fn specs() -> Vec { "Edit Shape", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## )), has_layer, shape_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..8f619c9ad8 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -948,3 +948,35 @@ fn combine_shapes_fails_gracefully() { assert!(s.execute("layer.combineShapes.unite", json!({})).is_err()); assert_eq!(*doc(&s), before); } + +#[test] +fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() { + let mut s = session(200, 200, 8); + let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.live.clone(), + _ => None, + }; + let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(), + _ => 0, + }; + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 6, "every vertex rounded"); + // Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again. + s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 12); + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap(); + assert_eq!(path_len(&s), 6); + // Hostile values never panic: negative and non-finite radii are clamped or ignored. + for bad in [json!(-5), json!(1e300), json!("x")] { + let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad})); + } + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0)); + // Scaling the layer by half halves the radius, as for a rectangle's radii. + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap(); + s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 3bd127445d..bb830dbd68 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -671,7 +671,7 @@ mod tests { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); } - assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none()); + assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none()); } #[test] @@ -699,7 +699,7 @@ mod tests { // Edit: path changes → vmsk regenerated, live dropped when polygon. let mut edited = sh.clone(); edited.path.subpaths.pop(); - edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 }); + edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); shape_blocks(&edited, &mut raw, 64, 64, 72.0); assert!(!raw.iter().any(|(k, _)| k == b"vogk")); let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a9592b692..b41f4f02df 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5005,8 +5005,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs). let ev = crate::move_mods::filter_event(app, ev, mods); - // A live rectangle's corner radius widgets (Path Selection, Rectangle tool). - if crate::corner_radius::pointer(app, ev, mods) { + // A live shape's corner radius widgets (Path Selection, the shape tools, Move with its + // transform controls). Unsnapped, as Free Transform's handles: snapping would pull the press + // off a widget onto a nearby vertex or guide, and the shape tool would draw a new shape. + if crate::corner_radius::pointer(app, raw, mods) { return; } // Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790). diff --git a/crates/ui-egui/src/corner_radius.rs b/crates/ui-egui/src/corner_radius.rs index b02e4d0eea..388ad60a96 100644 --- a/crates/ui-egui/src/corner_radius.rs +++ b/crates/ui-egui/src/corner_radius.rs @@ -1,14 +1,17 @@ -//! On-canvas corner radius widgets for live rectangles (TOOL-214-12), as in Photoshop: with Path -//! Selection, the Rectangle tool, or the Move tool showing its transform controls, every selected -//! shape layer that is a live rectangle shows a round widget inside each corner, at the centre of -//! its arc. Dragging one rounds every corner of every selected rectangle to the same radius; -//! ⌥/Alt changes that corner only, on each of them. The drag previews the new outlines and -//! commits one `shape.edit` with `radii` per changed layer on release, as one history step; a -//! click without movement changes nothing. +//! On-canvas corner radius widgets for live shapes (TOOL-214-12), as in Photoshop: with Path +//! Selection, a shape tool (Rectangle, Triangle, Polygon), or the Move tool showing its transform +//! controls, every selected shape layer that is a live rectangle, triangle, polygon or star shows +//! a round widget inside each corner, at the centre of its arc. Dragging one rounds every corner +//! of every selected shape to the same radius; ⌥/Alt changes that corner only on rectangles +//! (a triangle or polygon has one radius for all its corners, as in Photoshop). The drag previews +//! the new outlines and commits one `shape.edit` per changed layer on release (`radii` for a +//! rectangle, `cornerRadius` for a polygon), as one history step; a click without movement +//! changes nothing. use egui::{Color32, Modifiers, Pos2, Stroke}; use photocraft_doc::LayerContent; use photocraft_doc::vector::LiveShape; +use photocraft_vector::shapes; use crate::PhotocraftApp; use crate::canvas::ToolEvent; @@ -18,24 +21,124 @@ use crate::state::Tool; /// can still be grabbed. const MIN_INSET: f64 = 10.0; -/// A selected live rectangle: its layer, `[x, y, w, h]` with a positive size, and its radii. -#[derive(Clone, Copy, Debug, PartialEq)] +/// The live shape a target's corners belong to. +#[derive(Clone, Debug, PartialEq)] +enum Geom { + /// `[x, y, w, h]` with a positive size. + Rect([f64; 4]), + /// The polygon's parameters and its sharp vertices, in path order. + Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, vertices: Vec<[f64; 2]> }, +} + +/// A selected live shape with corners: its layer, its geometry, and its radii (a rectangle's four +/// `[tl, tr, br, bl]`; a polygon's one radius, repeated). +#[derive(Clone, Debug, PartialEq)] pub struct Target { layer: u64, - rect: [f64; 4], + geom: Geom, radii: [f64; 4], } +/// One corner: its vertex, and how far its arc's centre moves per unit of radius (the centre is +/// `vertex + step * r`: `(±1, ±1)` for a rectangle, `bisector / sin(half the angle)` for a +/// polygon vertex). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Corner { + vertex: [f64; 2], + step: [f64; 2], +} + +impl Target { + fn corners(&self) -> usize { + match &self.geom { + Geom::Rect(_) => 4, + Geom::Polygon { vertices, .. } => vertices.len(), + } + } + + fn is_rect(&self) -> bool { + matches!(self.geom, Geom::Rect(_)) + } + + /// Corner `i`, `None` when it is degenerate. + fn corner(&self, i: usize) -> Option { + match &self.geom { + Geom::Rect([x, y, w, h]) => { + let (vertex, d) = match i { + 0 => ([*x, *y], [1.0, 1.0]), + 1 => ([x + w, *y], [-1.0, 1.0]), + 2 => ([x + w, y + h], [-1.0, -1.0]), + _ => ([*x, y + h], [1.0, -1.0]), + }; + Some(Corner { vertex, step: d }) + } + Geom::Polygon { vertices, .. } => { + let f = self.fillet(i, 1.0)?; + let sin = f.half_angle.sin(); + (sin > 1e-9).then(|| Corner { vertex: vertices.get(i).copied().unwrap_or(f.centre), step: [f.bisector[0] / sin, f.bisector[1] / sin] }) + } + } + } + + /// A polygon vertex's rounding with radius `r`. + fn fillet(&self, i: usize, r: f64) -> Option { + let Geom::Polygon { vertices, .. } = &self.geom else { return None }; + let n = vertices.len(); + let at = |k: usize| vertices.get(k % n).copied(); + shapes::fillet(at(i + n - 1)?, at(i)?, at(i + 1)?, r) + } + + /// The largest radius every corner of the shape can take. + fn max_radius(&self) -> f64 { + match &self.geom { + Geom::Rect(r) => r[2].min(r[3]) / 2.0, + Geom::Polygon { vertices, .. } => (0..vertices.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min), + } + } + + /// The radius corner `i` is drawn with for `radii`: capped at what the shape allows there. + fn radius_at(&self, radii: [f64; 4], i: usize) -> f64 { + match &self.geom { + Geom::Rect(_) => radii.get(i).copied().unwrap_or(0.0).clamp(0.0, self.max_radius()), + Geom::Polygon { .. } => self.fillet(i, radii[0]).map_or(0.0, |f| f.radius), + } + } + + /// Where corner `i`'s widget is drawn for `radii`: the centre of its arc, or a few screen + /// points inside a sharper corner. + fn widget(&self, radii: [f64; 4], i: usize, zoom: f64) -> Option<[f64; 2]> { + let c = self.corner(i)?; + let most = match &self.geom { + Geom::Rect(_) => self.max_radius(), + Geom::Polygon { .. } => self.fillet(i, 1.0e12).map_or(0.0, |f| f.radius), + }; + // The inset is measured like a rectangle's: 10 points along each axis of a right angle. + let len = c.step[0].hypot(c.step[1]).max(1e-9); + let inset = MIN_INSET / zoom * std::f64::consts::SQRT_2 / len; + let r = self.radius_at(radii, i).max(inset).min(most); + Some([c.vertex[0] + c.step[0] * r, c.vertex[1] + c.step[1] * r]) + } + + /// The outline for `radii`. + fn path(&self, radii: [f64; 4]) -> photocraft_doc::Path { + match &self.geom { + Geom::Rect([x, y, w, h]) => shapes::rounded_rect(*x, *y, *w, *h, radii), + Geom::Polygon { rect, sides, star_ratio, .. } => { + shapes::live_path(&LiveShape::Polygon { rect: *rect, sides: *sides, star_ratio: *star_ratio, corner_radius: radii[0] }) + } + } + } +} + /// A corner radius drag in progress. #[derive(Clone, Debug, PartialEq)] pub struct Drag { - /// Every selected live rectangle, as at the press. + /// Every selected live shape with corners, as at the press. targets: Vec, - /// The rectangle whose widget was pressed (an index into `targets`) and its corner, - /// `[tl, tr, br, bl]` order. + /// The shape whose widget was pressed (an index into `targets`) and its corner. grabbed: usize, corner: usize, - /// Every corner (no ⌥) or the dragged one only. + /// Every corner (no ⌥) or the dragged one only (rectangles). all: bool, /// Where the press was. start: [f64; 2], @@ -47,13 +150,39 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } } -/// The selected layers that are live rectangles, front first. +/// A shape layer's live shape as a target, if it has corners. +fn target(layer: u64, live: &LiveShape) -> Option { + let finite = |v: &[f64]| v.iter().all(|v| v.is_finite()); + let positive = |[x, y, w, h]: [f64; 4]| { + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; + (w > 0.0 && h > 0.0 && finite(&[x, y, w, h])).then_some([x, y, w, h]) + }; + match *live { + LiveShape::Rect { rect, radii } => { + let rect = positive(rect)?; + finite(&radii).then_some(Target { layer, geom: Geom::Rect(rect), radii }) + } + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + positive(rect)?; + if !finite(&[star_ratio, corner_radius]) { + return None; + } + let sharp = shapes::polygon(rect[0], rect[1], rect[2], rect[3], sides, star_ratio); + let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] }) + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => None, + } +} + +/// The selected layers that are live shapes with corners, front first. fn targets(app: &PhotocraftApp) -> Vec { if !shows(app) { return Vec::new(); @@ -65,10 +194,7 @@ fn targets(app: &PhotocraftApp) -> Vec { .filter_map(|id| { let l = st.doc.layer(id)?; let LayerContent::Shape(sh) = &l.content else { return None }; - let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None }; - let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; - let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; - (w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii }) + target(l.id.0, sh.live.as_ref()?) }) .collect(); // `selected_layers` lists bottom to top; the topmost widget wins a press. @@ -76,58 +202,19 @@ fn targets(app: &PhotocraftApp) -> Vec { out } -/// The largest radius a corner of `rect` takes. -fn max_radius(rect: [f64; 4]) -> f64 { - rect[2].min(rect[3]) / 2.0 -} - -/// Corner `i` of `rect` and the unit-step direction pointing inside from it. -fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) { - let [x, y, w, h] = rect; - match i { - 0 => ([x, y], [1.0, 1.0]), - 1 => ([x + w, y], [-1.0, 1.0]), - 2 => ([x + w, y + h], [-1.0, -1.0]), - _ => ([x, y + h], [1.0, -1.0]), - } -} - -/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a -/// sharper corner. -fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] { - let (c, d) = corner(rect, i); - let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect)); - [c[0] + d[0] * r, c[1] + d[1] * r] -} - -/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its -/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more), -/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each -/// rectangle then caps it at half its own shorter side. -fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 { - let (_, d) = corner(rect, i); - let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0; - if r.is_finite() { r.max(0.0) } else { from } -} - fn zoom(app: &PhotocraftApp) -> f64 { f64::from(app.point_zoom().max(0.01)) } -/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an -/// index into `targets`) and its corner, the nearest when several are close. +/// The widget of `targets` within grabbing distance of document point `p`: the shape (an index +/// into `targets`) and its corner, the nearest when several are close. fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> { let z = zoom(app); let tol = 7.0 / z; targets .iter() .enumerate() - .flat_map(|(t, g)| { - (0..4).map(move |i| { - let w = widget(g.rect, g.radii, i, z); - (t, i, (w[0] - p[0]).hypot(w[1] - p[1])) - }) - }) + .flat_map(|(t, g)| (0..g.corners()).filter_map(move |i| g.widget(g.radii, i, z).map(|w| (t, i, (w[0] - p[0]).hypot(w[1] - p[1]))))) .filter(|(.., d)| *d <= tol) .min_by(|a, b| a.2.total_cmp(&b.2)) .map(|(t, i, _)| (t, i)) @@ -159,12 +246,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Move { x, y, .. } => { let Some(d) = app.ui.corner_radius.as_mut() else { return false }; let Some(g) = d.targets.get(d.grabbed) else { return true }; - let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect)); - let r = radius_after(g.rect, d.corner, from, d.start, [x, y]); - // Every selected rectangle takes the same radius, within its own size. + let Some(c) = g.corner(d.corner) else { return true }; + // The radius at the press plus the move projected onto the corner's step (the way its + // arc's centre moves as the radius grows). Relative, so a click on a sharp corner's + // inset widget changes nothing. + let from = g.radius_at(g.radii, d.corner); + let len2 = c.step[0] * c.step[0] + c.step[1] * c.step[1]; + let r = from + ((x - d.start[0]) * c.step[0] + (y - d.start[1]) * c.step[1]) / len2.max(1e-12); + let r = if r.is_finite() { r.max(0.0) } else { from }; + // Every selected shape takes the same radius, within its own size. for (t, now) in d.targets.iter().zip(d.now.iter_mut()) { - let r = r.min(max_radius(t.rect)); - if d.all { + let r = r.min(t.max_radius()); + if d.all || !t.is_rect() { *now = [r; 4]; } else { *now = t.radii; @@ -184,9 +277,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool // One history step for every layer changed. let key = format!("corner-radius-{}", app.ui.alloc_id()); for (t, now) in d.targets.iter().zip(&d.now) { - if *now != t.radii - && let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})) - { + if *now == t.radii { + continue; + } + let params = if t.is_rect() { + serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key}) + } else { + serde_json::json!({"layer": t.layer, "cornerRadius": now[0], "coalesce": key}) + }; + if let Err(e) = app.run("shape.edit", params) { app.ui.status = e; app.ui.status_error = true; break; @@ -199,11 +298,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// The radius the dragged corner shows in the readout. fn dragged_radius(d: &Drag) -> Option { - d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied() + let g = d.targets.get(d.grabbed)?; + let now = d.now.get(d.grabbed)?; + if g.is_rect() { now.get(d.corner).copied() } else { Some(g.radius_at(*now, d.corner)) } } -/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the -/// radius beside the pointer (`R: 25.0 px`), as in Photoshop. +/// The widgets of every selected live shape with corners; while one is dragged, the new outlines +/// and the radius beside the pointer (`R: 25.0 px`), as in Photoshop. pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { let drag = app.ui.corner_radius.as_ref(); if let Some(r) = drag.and_then(dragged_radius) @@ -216,13 +317,14 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i))); let radii = now.copied().unwrap_or(t.radii); if now.is_some() { - let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii); - crate::vector_ui::draw_outline(painter, &path, to_scr, accent); + crate::vector_ui::draw_outline(painter, &t.path(radii), to_scr, accent); } - for i in 0..4 { - let p = to_scr(widget(t.rect, radii, i, z)); - painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); - painter.circle_filled(p, 2.5, accent); + for i in 0..t.corners() { + if let Some(w) = t.widget(radii, i, z) { + let p = to_scr(w); + painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); + painter.circle_filled(p, 2.5, accent); + } } } } diff --git a/crates/ui-egui/src/corner_radius_tests.rs b/crates/ui-egui/src/corner_radius_tests.rs index b0b47c45be..252e9ce587 100644 --- a/crates/ui-egui/src/corner_radius_tests.rs +++ b/crates/ui-egui/src/corner_radius_tests.rs @@ -184,3 +184,86 @@ fn the_cursor_and_readout_follow_the_widgets() { tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE); assert!(!super::hovers(&app, [100.0, 80.0])); } + +/// The live polygon of layer `id`: its corner radius. +fn corner_radius_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> f64 { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Polygon { corner_radius, .. }) => corner_radius, + _ => panic!("live polygon"), + }, + _ => panic!("shape"), + } +} + +/// A live triangle (a 3-sided polygon) in (40, 40) 120 × 120, as the Triangle tool draws it. +fn triangle(tool: Tool, radius: f64) -> (PhotocraftApp, photocraft_doc::LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", json!({"kind": "polygon", "rect": [40, 40, 120, 120], "sides": 3, "cornerRadius": radius})).unwrap(); + app.ui.tool = tool; + let id = app.session.active().unwrap().active_layer.unwrap(); + (app, id) +} + +/// Where the widget of corner `i` of the first target is, and the way its arc's centre moves. +fn widget_of(app: &PhotocraftApp, i: usize) -> ([f64; 2], [f64; 2]) { + let t = &super::targets(app)[0]; + let z = f64::from(app.point_zoom()); + (t.widget(t.radii, i, z).unwrap(), t.corner(i).unwrap().step) +} + +#[test] +fn a_triangle_rounds_all_its_corners_from_any_widget_in_one_step() { + for tool in [Tool::Triangle, Tool::Polygon, Tool::PathSelection] { + let (mut app, id) = triangle(tool, 6.0); + assert_eq!(super::targets(&app)[0].corners(), 3, "{tool:?}"); + let n = steps(&app); + // Drag the bottom-right widget inwards by 4 radius units along its step: 6 + 4 = 10. + let (w, step) = widget_of(&app, 1); + let to = [w[0] + step[0] * 4.0, w[1] + step[1] * 4.0]; + drag(&mut app, w, to, Modifiers::NONE); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9, "{tool:?}: {}", corner_radius_of(&app, id)); + assert_eq!(steps(&app), n + 1, "one history step"); + // ⌥ makes no difference: a triangle has one radius for all its corners. + let (w, step) = widget_of(&app, 0); + drag(&mut app, w, [w[0] + step[0] * 2.0, w[1] + step[1] * 2.0], Modifiers::ALT); + assert!((corner_radius_of(&app, id) - 12.0).abs() < 1e-9); + assert!(app.session.undo()); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9); + } +} + +#[test] +fn a_triangle_radius_is_capped_and_a_click_on_a_sharp_corner_changes_nothing() { + let (mut app, id) = triangle(Tool::PathSelection, 0.0); + let n = steps(&app); + let (w, step) = widget_of(&app, 2); + drag(&mut app, w, w, Modifiers::NONE); + assert_eq!((corner_radius_of(&app, id), steps(&app)), (0.0, n), "a click commits nothing"); + // Dragged far in: the radius stops where the arcs meet halfway along the edges. + drag(&mut app, w, [w[0] + step[0] * 1000.0, w[1] + step[1] * 1000.0], Modifiers::NONE); + let max = super::targets(&app)[0].max_radius(); + assert!(max > 0.0 && (corner_radius_of(&app, id) - max).abs() < 1e-9, "{} vs {max}", corner_radius_of(&app, id)); +} + +#[test] +fn rectangles_and_triangles_selected_together_take_the_same_radius() { + let (mut app, tri) = triangle(Tool::PathSelection, 0.0); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 170, 100, 20], "radii": 0})).unwrap(); + let rect = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": tri.0, "mode": "add"})).unwrap(); + // The rectangle's top-left widget (10 px inside, at (20, 180)), 4 px in each direction: 4. + drag(&mut app, [20.0, 180.0], [24.0, 184.0], Modifiers::NONE); + assert_eq!(radii(&app), None, "the active layer is the triangle"); + let rr = match &app.session.active().unwrap().doc.layer(rect).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => radii, + _ => panic!(), + }, + _ => panic!(), + }; + assert_eq!(rr, [4.0; 4]); + assert!((corner_radius_of(&app, tri) - 4.0).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index a0b5c61596..d5dc01ebfc 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() { let id = only_dialog(&app); set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50})); crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 }); + assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0 }); press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]); let id = only_dialog(&app); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index fe6db74079..86b10dd929 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1555,7 +1555,14 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }); } } - photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => { + photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => { + // One radius rounds every vertex of a triangle, polygon or star, as in Photoshop. + ui.horizontal(|ui| { + let mut r = *corner_radius as f32; + if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") { + edit = Some(json!({"cornerRadius": r, "coalesce": key("corner-radius")})); + } + }); ui.horizontal(|ui| { let mut n = *sides as f32; if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index b154a47325..dfeae26e81 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -111,6 +111,101 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P Path::new(vec![Subpath::polygon(&pts)]) } +/// A rounded vertex: see [`fillet`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fillet { + /// The arc's centre and its tangent points on the edge to the previous and the next vertex. + pub centre: [f64; 2], + pub from: [f64; 2], + pub to: [f64; 2], + /// The radius used (the one asked for, capped at what the edges allow). + pub radius: f64, + /// Unit direction from the vertex towards the arc's centre, and half the vertex's angle. + pub bisector: [f64; 2], + pub half_angle: f64, +} + +/// Where a vertex's rounding sits: the vertex `v` between neighbours `prev` and `next`, rounded +/// with radius `r`, capped so its tangent points stay within half of each edge (two neighbouring +/// roundings never overlap). `None` for `r <= 0` or a degenerate vertex (a zero-length edge, or +/// a straight or folded angle). +pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option { + let unit = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy); + (l > 1e-9).then(|| ([dx / l, dy / l], l)) + }; + if r.is_nan() || r <= 0.0 { + return None; + } + let (u1, l1) = unit(v, prev)?; + let (u2, l2) = unit(v, next)?; + let cos = (u1[0] * u2[0] + u1[1] * u2[1]).clamp(-1.0, 1.0); + let half = cos.acos() / 2.0; + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-6 { + return None; + } + let r = r.min(l1.min(l2) / 2.0 * half.tan()); + let t = r / half.tan(); + let (bx, by) = (u1[0] + u2[0], u1[1] + u2[1]); + let bl = bx.hypot(by); + if bl < 1e-9 { + return None; + } + let bisector = [bx / bl, by / bl]; + let c = r / half.sin(); + Some(Fillet { + centre: [v[0] + bisector[0] * c, v[1] + bisector[1] * c], + from: [v[0] + u1[0] * t, v[1] + u1[1] * t], + to: [v[0] + u2[0] * t, v[1] + u2[1] * t], + radius: r, + bisector, + half_angle: half, + }) +} + +/// `path` (closed subpaths of corner knots, as [`polygon`] makes) with every vertex rounded by a +/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like +/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp. +pub fn round_corners(path: Path, r: f64) -> Path { + if r.is_nan() || r <= 0.0 { + return path; + } + let subpaths = path + .subpaths + .into_iter() + .map(|sp| { + let n = sp.knots.len(); + if n < 3 || !sp.closed { + return sp; + } + let pt = |i: usize| sp.knots.get(i % n).map_or([0.0; 2], |k| [k.anchor.x, k.anchor.y]); + let mut knots = Vec::with_capacity(n * 2); + for i in 0..n { + let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1)); + let Some(f) = fillet(prev, v, next, r) else { + knots.push(Knot::corner(v[0], v[1])); + continue; + }; + // The arc as one cubic: handles 4/3 tan(sweep/4) r long, along the tangents + // towards the vertex; the sweep is pi minus the vertex's angle. + let sweep = std::f64::consts::PI - 2.0 * f.half_angle; + let h = 4.0 / 3.0 * (sweep / 4.0).tan() * f.radius; + let toward = |a: [f64; 2]| { + let (dx, dy) = (v[0] - a[0], v[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + Point::new(a[0] + dx / l * h, a[1] + dy / l * h) + }; + let (from, to) = (Point::new(f.from[0], f.from[1]), Point::new(f.to[0], f.to[1])); + knots.push(Knot { anchor: from, in_ctrl: from, out_ctrl: toward(f.from), smooth: false }); + knots.push(Knot { anchor: to, in_ctrl: toward(f.to), out_ctrl: to, smooth: false }); + } + Subpath { knots, ..sp } + }) + .collect(); + Path { subpaths, ..path } +} + /// A line drawn as a filled bar of `weight` px from `a` to `b`. pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -125,7 +220,9 @@ pub fn live_path(s: &LiveShape) -> Path { match s { LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii), LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h), - LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio } => polygon(*x, *y, *w, *h, *sides, *star_ratio), + LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => { + round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius) + } LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight), } } @@ -134,6 +231,59 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + /// The point at `t` of the cubic leaving `a` towards `b`. + fn at(a: &Knot, b: &Knot, t: f64) -> [f64; 2] { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + let p = [a.anchor, a.out_ctrl, b.in_ctrl, b.anchor]; + [p.iter().zip(w).map(|(p, w)| p.x * w).sum(), p.iter().zip(w).map(|(p, w)| p.y * w).sum()] + } + + #[test] + fn a_rounded_triangle_has_one_arc_per_vertex_on_a_true_circle() { + let sharp = polygon(0.0, 0.0, 300.0, 300.0, 3, 1.0); + assert_eq!(sharp.subpaths[0].knots.len(), 3); + let p = round_corners(sharp.clone(), 20.0); + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 6, "two knots per rounded vertex"); + // Each arc stays 20 px from its centre along its length (one cubic, within 0.1 px). + let verts: Vec<[f64; 2]> = sharp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + for i in 0..3 { + let f = fillet(verts[(i + 2) % 3], verts[i], verts[(i + 1) % 3], 20.0).unwrap(); + assert!((f.radius - 20.0).abs() < 1e-9); + assert_eq!([k[2 * i].anchor.x, k[2 * i].anchor.y], f.from); + for t in [0.25, 0.5, 0.75] { + let q = at(&k[2 * i], &k[2 * i + 1], t); + let d = (q[0] - f.centre[0]).hypot(q[1] - f.centre[1]); + assert!((d - 20.0).abs() < 0.1, "vertex {i} t {t}: {d}"); + } + } + // The edges between the arcs stay straight. + assert_eq!((k[1].out_ctrl, k[2].in_ctrl), (k[1].anchor, k[2].anchor)); + } + + #[test] + fn huge_radii_are_capped_and_zero_stays_sharp() { + let sharp = polygon(0.0, 0.0, 100.0, 100.0, 6, 1.0); + assert_eq!(round_corners(sharp.clone(), 0.0), sharp); + assert_eq!(round_corners(sharp.clone(), f64::NAN), sharp); + let p = round_corners(sharp, 1.0e9); + // Capped: the tangent points of neighbouring vertices meet halfway along each edge. + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 12); + for w in k.windows(2).skip(1).step_by(2) { + let d = (w[0].anchor.x - w[1].anchor.x).hypot(w[0].anchor.y - w[1].anchor.y); + assert!(d < 1e-6, "no straight left between arcs: {d}"); + } + assert!(k.iter().all(|k| k.anchor.x.is_finite() && k.anchor.y.is_finite())); + } + + #[test] + fn a_star_rounds_its_inner_vertices_too() { + let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 }); + assert_eq!(p.subpaths[0].knots.len(), 20); + } + #[test] fn rounded_rect_knots() { let p = rounded_rect(0.0, 0.0, 100.0, 50.0, [10.0; 4]); diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..174ecf8100 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..23ef1452d0 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-14 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (rounded triangle, polygon and star corners; 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) · **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, @@ -123,7 +123,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | -| On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle, triangle, polygon and star shows a widget at each arc centre with Path Selection, the Rectangle, Triangle and Polygon tools and Move with Show Transform Controls; a drag rounds every corner of every selected shape to the same radius, ⌥ one corner of a rectangle (a triangle or polygon has one radius), with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -253,3 +253,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | +| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..9ed74a2fcc 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -192,7 +192,7 @@ id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" status = "partial" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" +note = "On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" [[item]] id = "TOOL-214-13" From 1d1cb4e4620a4c239d5c17e3d23a68fc4ba50982 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:34:47 +0000 Subject: [PATCH 0150/1452] Name layerFilter in the ui_set tool description main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not, so ui_set_description_names_every_accepted_field fails on ubuntu. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT --- crates/automation/src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..be0764e6d9 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,7 +325,9 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by (kind, name, mode), kinds + /// (a list of pixel, adjustment, type, shape, smart), name and mode, the Layers panel's filter). + /// Other fields are an error. pub fields: Value, } From 0e009c246f4e797e1beaf47ad6c388f4a7580993 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:34:47 +0000 Subject: [PATCH 0151/1452] Name layerFilter in the ui_set tool description main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not, so ui_set_description_names_every_accepted_field fails on ubuntu. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT (cherry picked from commit 1d1cb4e4620a4c239d5c17e3d23a68fc4ba50982) --- crates/automation/src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..be0764e6d9 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,7 +325,9 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by (kind, name, mode), kinds + /// (a list of pixel, adjustment, type, shape, smart), name and mode, the Layers panel's filter). + /// Other fields are an error. pub fields: Value, } From b54a4a8f864efdc5ec152c1173d11fab8d07a3d6 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 18:54:11 -0400 Subject: [PATCH 0152/1452] Displace: OK asks for the displacement map, as in Photoshop (#5806) Filter > Distort > Displace took its map only from an open document or a path typed into a text field; OK without either ran with no map, and no file picker ever appeared. Confirming the dialog with no map now opens the file picker and runs Displace with the chosen file (smart filters included); Cancel applies nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/filter_dialog.rs | 74 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2930889bab..444f3a0330 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -604,6 +604,10 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { app.filter_preview = None; return Err("dialog has no command".into()); }; + if crate::filter_dialog::needs_displace_map(&cmd, &crate::filter_dialog::params_of(&d.fields)) { + app.filter_preview = None; + return crate::filter_dialog::ask_displace_map(app, cmd, d.fields.clone()); + } let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields)); let result = app.run(&cmd, params.clone()); // A filter that runs as a background job keeps its preview on screen until the job diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 3da0735204..fa3336a6cf 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -846,6 +846,42 @@ pub fn params_of(f: &Map) -> Value { Value::Object(f.iter().filter(|(k, v)| !k.starts_with("__") && !unset(k, v)).map(|(k, v)| (k.clone(), v.clone())).collect()) } +/// Filter › Distort › Displace confirmed with no map: no open document picked and no path typed +/// (#5806). Photoshop asks for the displacement map file after OK; so does PhotoCraft. +pub(crate) fn needs_displace_map(command: &str, params: &Value) -> bool { + command == "filter.distort.displace" + && params.get("mapPath").and_then(Value::as_str).is_none_or(|p| p.trim().is_empty()) + && params.get("mapDocument").and_then(Value::as_i64).is_none_or(|i| i < 0) +} + +/// Asks for the displacement map, then runs Displace with the dialog's values and that file, as +/// OK in a dialog that had one would (smart filters included). Cancel applies nothing. In the +/// browser the picker gives contents, not a path the filter can read: the map has to be opened +/// as a document and picked as Map Document there. +pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: Map) -> Result { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None }, move |app, answer| { + let path = match answer { + FileDialogAnswer::Paths(paths) => paths.into_iter().next().ok_or("no displacement map was chosen")?, + FileDialogAnswer::Unavailable(e) => return Err(e), + FileDialogAnswer::Contents(..) | FileDialogAnswer::ContentsMany(_) => { + return Err("open the displacement map as a document, then choose it as the Map Document".into()); + } + FileDialogAnswer::SaveTo(_) | FileDialogAnswer::FolderTo(_) => return Err("unexpected answer from the file dialog".into()), + }; + let mut fields = fields; + fields.insert("mapPath".into(), json!(path)); + let (cmd, params) = crate::smart_ui::confirm_command(&fields, command, params_of(&fields)); + let result = app.run(&cmd, params); + if result.is_ok() { + remember(app, &cmd, &fields); + crate::actions::resume(app, &fields); + } + result + }) +} + /// Compute a preview document: run `command` with `params` on the proxy (scaled) copy of `doc`. pub fn preview_document(doc: &Document, active: Option, command: &str, params: &Value, k: u32) -> Option { preview_document_with(doc, active, command, params, k, None) @@ -1110,6 +1146,44 @@ mod tests { assert!(app.ui.dialogs.iter().any(|d| d.id == id)); } + /// #5806: OK in Displace with no map asks for the map file, as Photoshop does, and displaces + /// with the chosen file; Cancel in the picker applies nothing; a map already given runs at once. + #[test] + fn displace_asks_for_its_map_after_ok() { + let dir = std::env::temp_dir().join(format!("photocraft-displace-map-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let map = dir.join("map.png"); + let mut m = photocraft_engine::Session::new(); + m.execute("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + std::fs::write(&map, photocraft_io::export(&m.active().unwrap().doc, "map.png", &Default::default()).unwrap().bytes).unwrap(); + let answers = vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec![map.to_string_lossy().into_owned()])), None]; + let (show, asked) = crate::file_dialog::fake(answers); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + app.run("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let ctx = egui::Context::default(); + let id = open(&mut app, "filter.distort.displace").unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(asked.borrow().len(), 1, "OK asks for the map"); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "Displace ran with the chosen map"); + // Cancelled: nothing is applied. + let id = open(&mut app, "filter.distort.displace").unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(asked.borrow().len(), 2); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + // A map given in the dialog runs without asking. + assert!(!needs_displace_map("filter.distort.displace", &json!({"mapPath": "/maps/x.psd"}))); + assert!(!needs_displace_map("filter.distort.displace", &json!({"mapDocument": 0}))); + assert!(needs_displace_map("filter.distort.displace", &json!({"mapDocument": -1, "mapPath": " "}))); + assert!(!needs_displace_map("filter.blur.gaussianBlur", &json!({}))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn remembering_dialogs_never_persists_paths_or_document_indices() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); From 2750809257fb315436442d5ee8089b04fb4228c0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 18:56:28 -0400 Subject: [PATCH 0153/1452] Batch: Format "Same" saves camera raws as PSD instead of failing (#5799) With Format "Same" every output took its input's extension, so a folder of ARW/CR2/NEF/DNG files tried to write raws, which PhotoCraft can't, and every file failed. Raws and other import-only inputs (PDN, Krita, Affinity) are now saved as PSD; other inputs keep their format. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 12 ++++++++++-- crates/engine/src/file_cmds/tests.rs | 21 +++++++++++++++++++++ crates/io/src/raw.rs | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..e43723f0ab 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1042,13 +1042,21 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: /// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { - let ext = - if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); process_outputs(outputs, save, f) } +/// The extension a batch output takes with Format "Same": the input's own, except for formats +/// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on +/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. +fn same_format(path: &str) -> String { + const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub"]; + let ext = path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase(); + if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext } +} + fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..ece0858c84 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -65,6 +65,27 @@ fn camera_raw_opens_as_16_bit_with_notes() { assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err()); } +/// #5799: Batch with Format "Same" over a folder of camera raws saved nothing (every output was +/// a raw PhotoCraft can't write). Raws are saved as PSD; other inputs keep their format. +#[test] +fn batch_same_format_saves_camera_raws_as_psd() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-same-raw"); + let (w, h) = (24, 16); + std::fs::write(join(&dir, "IMG_0001.dng"), DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + png(&dir, "b.png", 8, 8, "#ff0000"); + let out = join(&dir, "out"); + let mut s = Session::new(); + let r = s.execute("file.automate.batch", json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + files.sort(); + assert_eq!(files.len(), 2, "{r}"); + assert!(files[0].ends_with("/IMG_0001.psd") && files[1].ends_with("/b.png"), "{files:?}"); + let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document; + assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32)); +} + #[test] fn revert_reloads_as_one_undoable_step() { let dir = tmp("revert"); diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 008173839b..aba70c21e7 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; -pub use photocraft_raw::{Tone, retone}; +pub use photocraft_raw::{EXTENSIONS, Tone, retone}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; From b153c4e74e236279c5d94724a5c47ab99788def6 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 19:18:26 -0400 Subject: [PATCH 0154/1452] =?UTF-8?q?Menus:=20Chinese,=20Japanese=20and=20?= =?UTF-8?q?Korean=20labels=20get=20access=20keys,=20"=E6=96=87=E4=BB=B6(F)?= =?UTF-8?q?"=20(#5863)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mnemonics (#4988) were assigned from the label as shown, so a label with no Latin letter had none and its menu couldn't be opened with Alt+letter. Such a label now shows its English label's letter in parentheses, before a trailing ellipsis ("文件(F)", "新建(N)…"), and takes it as its mnemonic, as Windows menus do in those languages. English and other Latin-script menus are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_keys.rs | 34 ++++++++++++++++++++++++++- crates/ui-egui/src/menu_keys_tests.rs | 24 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 25 ++++++++++---------- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..e49849b409 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -8,7 +8,8 @@ //! They are assigned per menu level from the labels as shown (so translated), unique within //! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V //! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys -//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic. +//! the keyboard reports as such, so a label without one (Japanese, Chinese…) shows its English +//! label's letter after it, as Windows menus do in those languages: "文件(F)" ([`localized`]). //! //! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native //! menu bar replaces the in-window one and this never runs. @@ -94,6 +95,37 @@ pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> out } +/// The labels to show and their mnemonics for translated `labels` whose English originals are +/// `english` (same order). A label with an ASCII letter or digit keeps its text and is assigned +/// as [`assign`] does. One without (Chinese, Japanese, Korean…) can't be typed on its own, so it +/// shows its English label's letter after it in parentheses and takes that letter, as Windows menus +/// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group. +pub fn localized(labels: &[String], english: &[&str], preferred: &[Option]) -> (Vec, Vec>) { + let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric()); + // The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…". + let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) { + Some((base, tail)) => format!("{base}({k}){tail}"), + None => format!("{l}({k})"), + }; + let english_keys = assign(english, preferred); + let mut shown = Vec::with_capacity(labels.len()); + let mut pref = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + match english_keys.get(i).copied().flatten().filter(|_| !typed(label)) { + Some(k) => { + shown.push(with_key(label, k)); + pref.push(Some(k)); + } + None => { + shown.push(label.clone()); + pref.push(preferred.get(i).copied().flatten()); + } + } + } + let keys = assign(&shown.iter().map(String::as_str).collect::>(), &pref); + (shown, keys) +} + /// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined /// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's. pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, color: Option) -> egui::WidgetText { diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 1f226054e0..0e0ce65e35 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -210,3 +210,27 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { h.run_steps(3); assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); } + +/// #5863: a label with no Latin letter (Chinese, Japanese…) shows its English label's letter in +/// parentheses, before a trailing ellipsis, and takes it as its mnemonic; labels with letters and +/// the top menus' Photoshop letters are as before, and the letters stay unique. +#[test] +fn labels_without_latin_letters_show_their_english_key() { + let labels: Vec = ["文件", "编辑", "Image", "图层"].map(String::from).to_vec(); + let english = ["File", "Edit", "Image", "Layer"]; + let preferred = [Some('F'), Some('E'), Some('I'), Some('L')]; + let (shown, keys) = crate::menu_keys::localized(&labels, &english, &preferred); + assert_eq!(shown, ["文件(F)", "编辑(E)", "Image", "图层(L)"]); + assert_eq!(keys, [Some('F'), Some('E'), Some('I'), Some('L')]); + // Rows: the key goes before the ellipsis; a clash takes the next letter of the shown label. + let labels: Vec = ["新建…", "打开...", "关闭"].map(String::from).to_vec(); + let (shown, keys) = crate::menu_keys::localized(&labels, &["New…", "Open...", "Close"], &[]); + assert_eq!(shown, ["新建(N)…", "打开(O)...", "关闭(C)"]); + assert_eq!(keys, [Some('N'), Some('O'), Some('C')]); + let (shown, keys) = crate::menu_keys::localized(&["关闭".to_string(), "复制".to_string()], &["Close", "Copy"], &[]); + assert_eq!(shown, ["关闭(C)", "复制(O)"]); + assert_eq!(keys, [Some('C'), Some('O')]); + // An English UI is unchanged. + let labels: Vec = ["File", "Edit"].map(String::from).to_vec(); + assert_eq!(crate::menu_keys::localized(&labels, &["File", "Edit"], &[Some('F'), Some('E')]).0, labels); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..f031b86500 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1208,9 +1208,10 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let mut keys = crate::menu_keys::Keys::load(ui.ctx()); keys.take_first_row(&mut nav); nav.underline = keys.show_underlines(ui.ctx()); - let top_labels: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); + let translated: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); let preferred: Vec> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect(); - let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::>(), &preferred); + // A title with no Latin letter shows its English key: "文件(F)" (#5863). + let (top_labels, top_mnemonics) = crate::menu_keys::localized(&translated, &TOP_MENUS, &preferred); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let v = &mut ui.style_mut().visuals; @@ -1496,24 +1497,25 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click // of its first child. // Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels // and the submenus' names as shown. - let shown_labels: Vec = { + // A label with no Latin letter shows its English label's key after it: "新建(N)…" (#5863). + let (translated, english): (Vec, Vec<&str>) = { let mut subs: Vec<&str> = Vec::new(); items .iter() .filter_map(|it| { if it.path.len() == depth { - (it.label != "---").then(|| translated_menu_label(lang, it)) + (it.label != "---").then(|| (translated_menu_label(lang, it), it.label.as_str())) } else { let name = it.path.get(depth).map(String::as_str)?; (!subs.contains(&name)).then(|| { subs.push(name); - crate::i18n::tr(lang, name).to_string() + (crate::i18n::tr(lang, name).to_string(), name) }) } }) - .collect() + .unzip() }; - let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::>(), &[]); + let (shown_labels, mnemonics) = crate::menu_keys::localized(&translated, &english, &[]); let mut next_row = 0usize; let mut shown_subs: Vec<&str> = Vec::new(); let mut last_was_sep = true; @@ -1526,7 +1528,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } continue; } - let label = translated_menu_label(lang, it); + let label = shown_labels.get(next_row).cloned().unwrap_or_else(|| translated_menu_label(lang, it)); let prefix = match it.checked { Some(true) => "✔ ", Some(false) => " ", @@ -1567,12 +1569,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect(); let enabled = submenu_enabled(&child); let mnemonic = mnemonics.get(next_row).copied().flatten(); + let shown = shown_labels.get(next_row).cloned().unwrap_or_else(|| crate::i18n::tr(lang, name).to_string()); next_row += 1; - let title: egui::WidgetText = if nav.underline { - crate::menu_keys::mnemonic_text(ui, "", crate::i18n::tr(lang, name), mnemonic, None) - } else { - crate::i18n::tr(lang, name).into() - }; + let title: egui::WidgetText = if nav.underline { crate::menu_keys::mnemonic_text(ui, "", &shown, mnemonic, None) } else { shown.into() }; ui.add_enabled_ui(enabled, |ui| { nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { let r = ui.menu_button(title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); From 21a9d13431a194d9521ab9c2408aafecbf0fe040 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 02:02:02 +1100 Subject: [PATCH 0155/1452] Prevent clipboard centering from overflowing document coordinates. --- crates/engine/src/edit_cmds.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..ae8a3b332f 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -454,9 +454,9 @@ pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) return (0, 0); } let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))); - let (cx, cy) = c.unwrap_or(((canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0)); + let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0)); let b = clip.bounds; - ((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 2.0).round() as i32) + ((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32) } pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { @@ -1107,6 +1107,20 @@ mod tests { assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]); } + #[test] + fn paste_centers_clipboard_bounds_near_coordinate_limits() { + for origin in [i32::MAX - 512, i32::MIN + 512] { + let clip = Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None }; + let (dx, dy) = paste_offset(&clip, Rect::new(0, 0, 20, 20), &json!({}), false); + assert_eq!(i64::from(origin) + i64::from(dx), 9); + assert_eq!(i64::from(origin) + i64::from(dy), 9); + let clip = Clip { bounds: Rect::new(0, 0, 2, 2), ..clip }; + let (dx, dy) = paste_offset(&clip, Rect::from_xywh(origin, origin, 20, 20), &json!({}), false); + assert_eq!(dx, origin + 9); + assert_eq!(dy, origin + 9); + } + } + #[test] fn injected_clear_buffer_failure_leaves_the_document_unchanged() { let mut s = session(); From 772209808ba251d4b4cb8927ab8f24ce4e1fb322 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 19:30:05 -0400 Subject: [PATCH 0156/1452] Pen options bar: Make Selection in Path mode (#5703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's Pen options bar in Path mode offers "Make: Selection…". PhotoCraft had it only on the canvas's right-click menu. The options bar now has a Make Selection button (enabled when there's a path) that opens the same dialog for the path being drawn, finished into the Work Path first, or the active path. "Make Selection" gains its zh-Hans, zh-Hant and ko catalog rows. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas_tool_menu.rs | 40 ++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/vector_ui.rs | 12 ++++++++ 5 files changed, 55 insertions(+) diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index da835a786f..92861a521c 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -796,6 +796,20 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { spec_dialog(app, command, label, spec, fields); } +/// The Pen options bar's Make Selection (Path mode, #5703): the canvas menu's Make Selection… +/// dialog for the path being drawn (committed first, as the menu does) or the active path. +pub(crate) fn make_selection(app: &mut PhotocraftApp) -> Result<(), String> { + let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || app.ui.vector_mask_target); + if pending { + crate::vector_ui::pen_commit(app, false); + } + let name = if pending { Some(if pending_layer { "layer" } else { "work" }.to_string()) } else { crate::vector_ui::active_path_name(app) } + .ok_or_else(|| "No path is available for this action".to_string())?; + pen_dialog(app, "path.toSelection", "Make Selection…", &name); + Ok(()) +} + fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { if command == "select.isolateLayers" { crate::menus::invoke(app, ctx, command, json!({}))?; @@ -1492,6 +1506,32 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + /// #5703: the Pen options bar's Make Selection (Path mode) opens the same dialog for the path + /// being drawn (finished into the Work Path first), and OK selects inside it; with no path at + /// all it reports that instead. + #[test] + fn pen_options_bar_makes_a_selection_from_the_path() { + let mut app = app(); + app.ui.tool = Tool::Pen; + assert!(make_selection(&mut app).is_err(), "no path yet"); + assert!(app.ui.dialogs.is_empty()); + for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + make_selection(&mut app).unwrap(); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.work_path.is_some() && st.doc.selection.is_some()); + // The active (Work) path again, with no pen path in progress. + app.run("select.deselect", json!({})).unwrap(); + make_selection(&mut app).unwrap(); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some()); + } + #[test] fn pen_menu_has_all_reference_rows_in_order_and_guards_unavailable_actions() { let labels: Vec<_> = PEN_MENU.iter().map(|row| row.map(|(label, _)| label)).collect(); diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76a3c9826..ab2bf52fd3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3353,3 +3353,4 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Make Selection 선택 영역 만들기 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2307739ed3..5d420b0981 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2790,3 +2790,4 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Make Selection 建立选区 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 478489f8c7..f13bda17cd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2775,3 +2775,4 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Make Selection 建立選取範圍 diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index f68a4a6a4e..cb0cd07ba4 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1139,6 +1139,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ), ), } + // Path mode: Make Selection from the path being drawn or the active path, as Photoshop's + // options bar's "Make: Selection…" does (#5703). + if app.ui.tool_options.vector_mode == "path" { + crate::widgets::vline(ui, 22.0); + let has_path = active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + if ui.add_enabled(has_path, egui::Button::new(tl!("Make Selection"))).clicked() + && let Err(e) = crate::canvas_tool_menu::make_selection(app) + { + app.ui.status = e; + app.ui.status_error = true; + } + } return true; } let mut mode = "shape".to_string(); From c62b35590834fff57684632dcf2d33a64d3f62b6 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Wed, 14 Oct 2026 20:51:08 -0300 Subject: [PATCH 0157/1452] fix(automation): document ui.set layer filter --- crates/automation/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..8e04d106a3 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -308,7 +308,7 @@ pub struct MenuParams { pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, - /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, + /// vectorMaskTarget, selectionMode, layerFilter (by kind, name or blend mode), zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one From a8db6dd10049118b451d35f32cc1f5f7d1cce059 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 01:53:42 +0200 Subject: [PATCH 0158/1452] Export As: Resample menu, incl. Nearest Neighbor (#5838) Before this change, Export As always resampled the exported copy with Bicubic, and the preview used Bilinear. Pixel art and screenshots could not be enlarged with hard edges. Photoshop's Export As has a Resample menu next to Width/Height/Scale for this. - engine export_preset_cmds: RESAMPLE lists Photoshop's six choices (Bicubic Automatic, the default, then Bicubic, Bicubic Smoother, Bicubic Sharper, Bilinear and Nearest Neighbor) and export_resample() checks a value. An unknown value is now an error. Before, Image Size quietly treated it as Bicubic. - Export presets save and restore resample. Older presets load with the default. - layer.exportAs takes a `resample` param, checked even at 100 %. - Export As dialog: a Resample dropdown under Image Size. It is remembered with the other settings, and a bad saved value falls back to the default. export_document passes the choice to image.imageSize for the exported copy only. - Preview: the proxy keeps its fast Bilinear path, except for Nearest Neighbor, which is just as cheap and is what the user wants to see. The preview texture also uses NEAREST magnification then, so small exports look blocky instead of blurred. - Translations for the three new labels in all 20 catalogues. - Scorecard FILE-215-18 is now done. Also fixes a stale doc comment on VectorMask::feather, because the compositor does feather vector masks. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/vector.rs | 3 +- crates/engine/src/export_preset_cmds.rs | 44 ++++- crates/engine/src/layer_menu_cmds.rs | 9 +- crates/engine/src/layer_menu_cmds/tests.rs | 32 ++++ crates/ui-egui/src/export_dialog.rs | 208 +++++++++++++++++++-- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/tests/export_as_scaling.rs | 79 ++++++++ docs/scorecard.md | 4 +- scorecard/file_compat.toml | 4 +- 28 files changed, 421 insertions(+), 22 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..5caca323be 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -192,7 +192,8 @@ pub struct VectorMask { pub linked: bool, /// Density `0..=1` (Properties panel), like [`crate::LayerMask::density`]. pub density: f32, - /// Feather radius in px (stored; rendered unfeathered for now). + /// Feather radius in px (Properties panel); the compositor blurs the rasterised path by it + /// (`photocraft_compose::masks::feather_sigma`), as it does a pixel mask's feather. pub feather: f32, } diff --git a/crates/engine/src/export_preset_cmds.rs b/crates/engine/src/export_preset_cmds.rs index 3d56c22437..7378e703ab 100644 --- a/crates/engine/src/export_preset_cmds.rs +++ b/crates/engine/src/export_preset_cmds.rs @@ -12,6 +12,33 @@ const MAX_PRESETS: usize = 256; const MAX_PX: u32 = 300_000; const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"]; +/// Export As › Image Size › Resample, in Photoshop's order: the `image.imageSize` method each +/// choice sends, and its menu label. The first one is the default. +pub const RESAMPLE: [(&str, &str); 6] = [ + ("bicubicAutomatic", "Bicubic Automatic"), + ("bicubic", "Bicubic"), + ("bicubicSmoother", "Bicubic Smoother"), + ("bicubicSharper", "Bicubic Sharper"), + ("bilinear", "Bilinear"), + ("nearest", "Nearest Neighbor"), +]; + +/// Photoshop's Export As default: Bicubic Automatic. +pub const DEFAULT_RESAMPLE: &str = "bicubicAutomatic"; + +/// `v` when it names one of [`RESAMPLE`]; otherwise an error listing the choices. Export As +/// never falls back silently: Image Size would quietly use Bicubic for an unknown name. +pub fn export_resample(v: &str) -> std::result::Result<&'static str, String> { + RESAMPLE.iter().map(|(k, _)| *k).find(|k| *k == v).ok_or_else(|| { + let names: Vec<&str> = RESAMPLE.iter().map(|(k, _)| *k).collect(); + format!("resample must be one of {}", names.join(", ")) + }) +} + +fn default_resample() -> String { + DEFAULT_RESAMPLE.into() +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportPresetSettings { @@ -33,6 +60,9 @@ pub struct ExportPresetSettings { pub embed_profile: bool, /// `"auto"`, `"24"` or `"32"`. pub tga_bits: String, + /// One of [`RESAMPLE`]; presets saved before the choice existed load as the default. + #[serde(default = "default_resample")] + pub resample: String, } impl ExportPresetSettings { @@ -61,6 +91,7 @@ impl ExportPresetSettings { if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") { return Err("tgaBits must be auto, 24 or 32".into()); } + export_resample(&self.resample)?; Ok(()) } @@ -79,7 +110,8 @@ impl ExportPresetSettings { "metadata": "none", "profile": "document", "embedProfile": true, - "tgaBits": "auto" + "tgaBits": "auto", + "resample": DEFAULT_RESAMPLE }); if let Some(defaults) = defaults.as_object() { for (k, v) in defaults { @@ -196,7 +228,7 @@ pub fn specs() -> Vec { label: "Save Export Preset", menu: &[], shortcut: None, - params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#, + params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32","resample":"bicubicAutomatic|bicubic|bicubicSmoother|bicubicSharper|bilinear|nearest"="bicubicAutomatic"}} (a saved name is replaced)"#, enabled: always, run: save, journal: false, @@ -232,7 +264,8 @@ mod tests { "metadata": "exif", "profile": "srgb", "embedProfile": false, - "tgaBits": "24" + "tgaBits": "24", + "resample": "nearest" }) } @@ -255,6 +288,7 @@ mod tests { assert_eq!(presets[0]["settings"]["width"], 640); assert_eq!(presets[0]["settings"]["embedProfile"], false); assert_eq!(presets[0]["settings"]["metadata"], "exif"); + assert_eq!(presets[0]["settings"]["resample"], "nearest"); let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap(); assert_eq!(got["preset"]["settings"]["profile"], "srgb"); @@ -276,6 +310,9 @@ mod tests { assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err()); assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err()); assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err()); + let err = s.execute("file.export.presets.save", json!({"name": "x", "settings": {"resample": "lanczos"}})).unwrap_err().to_string(); + assert!(err.contains("resample must be one of"), "{err}"); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"resample": 3}})).is_err()); assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err()); assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err()); @@ -286,5 +323,6 @@ mod tests { ])); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].name, "ok"); + assert_eq!(loaded[0].settings.resample, DEFAULT_RESAMPLE, "a preset saved before Resample existed loads with the default"); } } diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..bac385a102 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -876,11 +876,16 @@ fn export_layer(s: &mut Session, p: &Value, cmd: &str, png_only: bool) -> Result return Err(bad(cmd, "Quick Export as PNG writes .png files")); } let scale = num(p, "scale", 100.0).clamp(1.0, 1000.0); + // Export As › Resample; checked even at 100 % so a typo never goes unnoticed. + let resample = match p.get("resample") { + None | Some(Value::Null) => crate::export_preset_cmds::DEFAULT_RESAMPLE, + Some(v) => crate::export_preset_cmds::export_resample(v.as_str().ok_or_else(|| bad(cmd, "`resample` must be a string"))?).map_err(|e| bad(cmd, e))?, + }; if (scale - 100.0).abs() > 1e-3 { let mut tmp = Session::new(); tmp.add_document(ldoc, None); let w = (tmp.active().map_or(1, |d| d.doc.size.width) as f32 * scale / 100.0).round().max(1.0); - tmp.execute("image.imageSize", json!({"width": w}))?; + tmp.execute("image.imageSize", json!({"width": w, "resample": resample}))?; ldoc = (*tmp.active().ok_or(EngineError::NoDocument)?.doc).clone(); } let out = photocraft_io::export(&ldoc, path, &Default::default()).map_err(|e| other(e.to_string()))?; @@ -994,7 +999,7 @@ pub fn specs() -> Vec { "layer.exportAs", "Export As…", ["Layer"], - r##"{"layer":id?,"path":text,"scale":1..1000=100} (format from the path's extension)"##, + r##"{"layer":id?,"path":text,"scale":1..1000=100,"resample":"bicubicAutomatic|bicubic|bicubicSmoother|bicubicSharper|bilinear|nearest"="bicubicAutomatic"} (format from the path's extension; the document is not resampled)"##, has_layer, |s, p| export_layer(s, p, "layer.exportAs", false) ), diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 46688500b1..9694564a08 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -385,6 +385,38 @@ fn export_layer_trims_to_its_pixels() { let _ = std::fs::remove_dir_all(&dir); } +/// Layer › Export As › Resample: Nearest Neighbor turns each layer pixel into an exact 2 × 2 +/// block at 200 %, an unknown method is an error (never a silent Bicubic), and the document is +/// left as it was. +#[test] +fn export_layer_resamples_with_the_chosen_method() { + let dir = std::env::temp_dir().join(format!("pc-layer-export-resample-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for depth in [8, 16] { + let mut s = session(depth, "rgb"); + paint(&mut s, |x, y| if (x + y) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); + let before = s.active().unwrap().doc.clone(); + let path = dir.join(format!("nearest-{depth}.png")); + let path = path.to_string_lossy(); + let r = s.execute("layer.exportAs", json!({"path": path, "scale": 200, "resample": "nearest"})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(80), Some(60))); + let out = photocraft_io::import("x.png", &std::fs::read(&*path).unwrap()).unwrap().document; + let surf = out.layers[0].surface().unwrap(); + for y in 0..60 { + for x in 0..80 { + let want = if (x / 2 + y / 2) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }; + assert_eq!(surf.rgba(x, y), want, "{depth}-bit pixel {x},{y}"); + } + } + for bad in [json!("lanczos"), json!("Nearest"), json!(""), json!(2)] { + let err = s.execute("layer.exportAs", json!({"path": path, "scale": 200, "resample": bad})).unwrap_err().to_string(); + assert!(err.contains("resample"), "{depth}-bit {bad}: {err}"); + } + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before), "{depth}-bit: exporting never edits the document"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn stack_modes_render_from_the_nested_layers() { let mut s = session(8, "rgb"); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 9367045aff..3b9e232388 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -9,6 +9,8 @@ use std::sync::Mutex; use photocraft_doc::Document; use serde_json::{Map, Value, json}; +use photocraft_engine::export_preset_cmds::{DEFAULT_RESAMPLE, RESAMPLE, export_resample}; + use crate::state::DialogKind; use crate::theme::Tokens; use crate::{ExportSettings, PhotocraftApp}; @@ -41,6 +43,9 @@ fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "exif", "all"].contains(v)) { f.insert("metadata".into(), json!(v)); } + if let Some(v) = saved.get("resample").and_then(Value::as_str).filter(|v| export_resample(v).is_ok()) { + f.insert("resample".into(), json!(v)); + } if let Some(v) = saved.get("tgaBits").and_then(Value::as_str).filter(|v| ["auto", "24", "32"].contains(v)) { f.insert("tgaBits".into(), json!(v)); } @@ -65,8 +70,22 @@ fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { /// Record the settings for the next Export As (see [`seed_remembered`]). fn remember(app: &mut PhotocraftApp, f: &Map) { - const KEYS: [&str; 13] = - ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits", "sizing", "width", "height", "lockAspect", "profile", "embedProfile"]; + const KEYS: [&str; 14] = [ + "format", + "quality", + "lossless", + "transparency", + "scale", + "resample", + "metadata", + "tgaBits", + "sizing", + "width", + "height", + "lockAspect", + "profile", + "embedProfile", + ]; let saved: Map = KEYS.iter().filter_map(|k| f.get(*k).map(|v| ((*k).to_string(), v.clone()))).collect(); app.session.prefs.edit(|p| { p.dialogs.insert(COMMAND.into(), Value::Object(saved)); @@ -83,6 +102,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("lossless".into(), json!(app.session.prefs().export.webp_lossless)); f.insert("transparency".into(), json!(true)); f.insert("scale".into(), json!(100)); + f.insert("resample".into(), json!(DEFAULT_RESAMPLE)); f.insert("metadata".into(), json!("none")); f.insert("tgaBits".into(), json!("auto")); f.insert("sizing".into(), json!("scale")); @@ -240,9 +260,27 @@ fn n(f: &Map, k: &str, d: f64) -> f64 { f.get(k).and_then(Value::as_f64).unwrap_or(d) } -/// The document as exported: scaled (engine Image Size, bicubic) and flattened over white when -/// transparency is off or the format has no alpha. +/// Image Size's method for the exported copy (Export As › Resample). A missing choice is +/// Photoshop's default, Bicubic Automatic; an unknown one is an error, never a silent Bicubic. +/// The preview proxy (`max_side`) resamples bilinearly to stay fast, except for Nearest +/// Neighbor, whose hard pixel edges are the point of choosing it and cost nothing extra. +fn resample_method(f: &Map, max_side: Option) -> Result<&'static str, String> { + let chosen = match f.get("resample") { + None | Some(Value::Null) => DEFAULT_RESAMPLE, + Some(v) => export_resample(v.as_str().ok_or("resample must be a string")?)?, + }; + Ok(if max_side.is_some() && chosen != "nearest" { "bilinear" } else { chosen }) +} + +/// Whether the dialog asks for Nearest Neighbor (the preview then magnifies without smoothing). +fn nearest(f: &Map) -> bool { + f.get("resample").and_then(Value::as_str) == Some("nearest") +} + +/// The document as exported: scaled (engine Image Size, with the dialog's Resample method) and +/// flattened over white when transparency is off or the format has no alpha. fn export_document(doc: &Document, f: &Map, max_side: Option) -> Result { + let resample = resample_method(f, max_side)?; let mut session = photocraft_engine::Session::new(); session.add_document(doc.clone(), None); if s(f, "profile") == "srgb" && doc.icc_profile.is_some() { @@ -253,9 +291,7 @@ fn export_document(doc: &Document, f: &Map, max_side: Option return Err("ICO export is limited to 256 × 256 pixels; reduce the export scale".into()); } if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 { - session - .execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) - .map_err(|e| e.to_string())?; + session.execute("image.imageSize", json!({"width": w, "height": h, "resample": resample})).map_err(|e| e.to_string())?; } let fmt = s_fmt(f); if !f.get("transparency").and_then(Value::as_bool).unwrap_or(true) || fmt == "jpg" { @@ -446,11 +482,17 @@ struct CachedPreview { texture: Option>, } -fn cache_preview(ui: &mut egui::Ui, key: egui::Id, signature: String, result: Result) -> CachedPreview { +/// The preview texture's filter: a small export is magnified to fill the preview, and with +/// Nearest Neighbor that magnification keeps hard pixel edges too. +fn preview_filter(f: &Map) -> egui::TextureOptions { + if nearest(f) { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR } +} + +fn cache_preview(ui: &mut egui::Ui, key: egui::Id, signature: String, result: Result, filter: egui::TextureOptions) -> CachedPreview { let preview = match result { Ok(pixels) => { let image = egui::ColorImage::from_rgba_unmultiplied([pixels.width, pixels.height], &pixels.rgba); - let texture = Arc::new(ui.ctx().load_texture("export-preview", image, egui::TextureOptions::LINEAR)); + let texture = Arc::new(ui.ctx().load_texture("export-preview", image, filter)); CachedPreview { signature, estimate: pixels.estimate, texture: Some(texture) } } Err(error) => { @@ -585,6 +627,32 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map, src_w: f64, src_h: } } +/// Export As › Image Size › Resample, with Photoshop's choices (only the exported copy is +/// resampled; the document keeps its pixels). +fn resample_field(ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Resample")).color(t.text_dim)); + let mut method = resample_method(f, None).unwrap_or(DEFAULT_RESAMPLE).to_string(); + let opts: Vec<(String, &str)> = RESAMPLE.iter().map(|(k, label)| ((*k).to_string(), resample_label(label))).collect(); + if crate::widgets::dropdown(ui, "export-resample", &mut method, &opts, 130.0) { + f.insert("resample".into(), json!(method)); + } + }); +} + +/// The translated label of a [`RESAMPLE`] choice (literal `tl!`s, so the catalogue check sees them). +fn resample_label(label: &str) -> &'static str { + match label { + "Bicubic" => tl!("Bicubic"), + "Bicubic Smoother" => tl!("Bicubic Smoother"), + "Bicubic Sharper" => tl!("Bicubic Sharper"), + "Bilinear" => tl!("Bilinear"), + "Nearest Neighbor" => tl!("Nearest Neighbor"), + _ => tl!("Bicubic Automatic"), + } +} + fn apply_preset(f: &mut Map, settings: &photocraft_engine::export_preset_cmds::ExportPresetSettings, src_w: f64, src_h: f64) { f.insert("format".into(), json!(settings.format)); f.insert("quality".into(), json!(settings.quality)); @@ -597,6 +665,7 @@ fn apply_preset(f: &mut Map, settings: &photocraft_engine::export f.insert("profile".into(), json!(settings.profile)); f.insert("embedProfile".into(), json!(settings.embed_profile)); f.insert("tgaBits".into(), json!(settings.tga_bits)); + f.insert("resample".into(), json!(settings.resample)); if settings.sizing == "pixels" { f.insert("width".into(), json!(settings.width)); f.insert("height".into(), json!(settings.height)); @@ -628,6 +697,7 @@ fn preset_settings(f: &Map) -> Value { "profile": if s(f, "profile") == "srgb" { "srgb" } else { "document" }, "embedProfile": f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true), "tgaBits": bits, + "resample": resample_method(f, None).unwrap_or(DEFAULT_RESAMPLE), }) } @@ -771,6 +841,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map 256.0 || h > 256.0) { @@ -784,7 +855,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = ui.data(|d| d.get_temp(key)); let preview = match cached.filter(|cached| cached.signature == sig) { @@ -814,7 +886,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Document { + use photocraft_doc::{Color, ColorMode, Size}; + let mut doc = Document::with_background("checker", Size::new(5, 3), ColorMode::Rgb, depth, Color::WHITE); + let b = doc.bounds(); + let surf = doc.layers[0].surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + data.extend(photocraft_raster::from_rgba(&fmt, checker(x, y))); + } + } + surf.write_region(b, &data); + doc + } + + fn checker(x: i32, y: i32) -> [f32; 4] { + if (x + y) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] } + } + + /// Export As › Resample › Nearest Neighbor at 200 %: every source pixel becomes an exact + /// 2 × 2 block, at 8 and 16 bits, and the document keeps its pixels. Bicubic Automatic (the + /// default) blends the squares instead. + #[test] + fn nearest_neighbor_doubles_each_pixel_into_an_exact_block() { + use photocraft_doc::SampleType; + for depth in [SampleType::U8, SampleType::U16] { + let doc = checker_doc(depth); + let before = doc.clone(); + let mut f = Map::new(); + f.insert("format".into(), json!("png")); + f.insert("scale".into(), json!(200)); + f.insert("resample".into(), json!("nearest")); + let out = export_document(&doc, &f, None).unwrap(); + assert_eq!((out.size.width, out.size.height), (10, 6)); + assert_eq!(out.depth, depth); + let surf = out.layers[0].surface().unwrap(); + for y in 0..6 { + for x in 0..10 { + assert_eq!(surf.rgba(x, y), checker(x / 2, y / 2), "{depth:?} pixel {x},{y}"); + } + } + assert_eq!(doc, before, "{depth:?}: export leaves the source document unchanged"); + + // The preview proxy keeps Nearest Neighbor's hard edges too. + let proxy = export_document(&doc, &f, Some(512)).unwrap(); + assert_eq!(proxy.layers[0].surface().unwrap().rgba(1, 1), checker(0, 0), "{depth:?}: preview proxy is nearest as well"); + assert_eq!(preview_filter(&f), egui::TextureOptions::NEAREST); + + f.insert("resample".into(), json!(DEFAULT_RESAMPLE)); + let smooth = export_document(&doc, &f, None).unwrap(); + let blended = (0..6).flat_map(|y| (0..10).map(move |x| (x, y))).any(|(x, y)| { + let p = smooth.layers[0].surface().unwrap().rgba(x, y); + p[0] > 0.01 && p[2] > 0.01 + }); + assert!(blended, "{depth:?}: Bicubic Automatic blends neighbouring squares"); + assert_eq!(preview_filter(&f), egui::TextureOptions::LINEAR); + } + } + + /// Every Resample choice reaches Image Size; a method Export As does not offer is an error + /// naming the choices, never a silent Bicubic, and a missing one is Bicubic Automatic. + #[test] + fn resample_choices_are_checked() { + let doc = checker_doc(photocraft_doc::SampleType::U8); + let mut f = Map::new(); + f.insert("scale".into(), json!(300)); + for (method, _) in RESAMPLE { + f.insert("resample".into(), json!(method)); + let out = export_document(&doc, &f, None).unwrap(); + assert_eq!((out.size.width, out.size.height), (15, 9), "{method}"); + } + for bad in [json!("lanczos"), json!("NEAREST"), json!(""), json!(1), json!({"m": "nearest"})] { + f.insert("resample".into(), bad.clone()); + let err = export_document(&doc, &f, None).unwrap_err(); + assert!(err.contains("resample must be"), "{bad}: {err}"); + assert!(export_document(&doc, &f, Some(512)).is_err(), "{bad}: the preview reports it too"); + } + f.remove("resample"); + assert_eq!(resample_method(&f, None).unwrap(), "bicubicAutomatic"); + assert_eq!(resample_method(&f, Some(512)).unwrap(), "bilinear", "the preview stays on the fast path"); + } + + /// Resample is remembered with the other settings; a saved value the menu does not offer + /// opens the dialog with the default. + #[test] + fn resample_is_remembered_and_a_bad_saved_value_falls_back() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + let id = open(&mut app).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["resample"], DEFAULT_RESAMPLE, "Photoshop's default"); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + f.insert("resample".into(), json!("nearest")); + remember(&mut app, &f); + assert_eq!(app.session.prefs().dialogs[COMMAND]["resample"], "nearest"); + let id = open(&mut app).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["resample"], "nearest"); + + for bad in [json!("lanczos"), json!(7), json!(null)] { + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), json!({"resample": bad})); + }); + let id = open(&mut app).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["resample"], DEFAULT_RESAMPLE, "saved {bad}"); + } + } + #[test] fn export_document_scales_height_when_rounded_width_is_unchanged() { use photocraft_doc::{Color, ColorMode, SampleType, Size}; @@ -1297,9 +1478,12 @@ mod tests { profile: "document".into(), embed_profile: false, tga_bits: "auto".into(), + resample: "nearest".into(), }; let mut f = Map::new(); apply_preset(&mut f, &pixels, 400.0, 200.0); + assert_eq!(f.get("resample"), Some(&json!("nearest")), "a preset restores its Resample method"); + assert_eq!(preset_settings(&f)["resample"], "nearest", "and saves it"); assert_eq!(f.get("width"), Some(&json!(64))); assert_eq!(f.get("height"), Some(&json!(32))); assert_eq!(f.get("sizing"), Some(&json!("pixels"))); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5cfc0f8ff5..93c7896251 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3783,3 +3783,6 @@ navigator Zoom Zvětšení # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Bicubic Automatic Bikubická automatická + Bicubic Smoother Bikubická hladší + Bicubic Sharper Bikubická ostřejší diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05ac77803c..5d70deff34 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3177,3 +3177,6 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Bicubic Automatic Bikubisch automatisch + Bicubic Smoother Bikubisch glatter + Bicubic Sharper Bikubisch schärfer diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c6c5ff8f66..d4330e7cb4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3875,3 +3875,6 @@ curves Smooth Εξομάλυνση # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Bicubic Automatic Δικυβική αυτόματη + Bicubic Smoother Δικυβική πιο ομαλή + Bicubic Sharper Δικυβική πιο ευκρινής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e24171195a..cc8f1287fd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4137,3 +4137,6 @@ navigator Zoom Zoom # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Bicubic Automatic Bicúbica automática + Bicubic Smoother Bicúbica más suavizada + Bicubic Sharper Bicúbica más enfocada diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index db40d97abf..1d5022093c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3179,3 +3179,6 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Bicubic Automatic Bicubique automatique + Bicubic Smoother Bicubique plus lisse + Bicubic Sharper Bicubique plus net diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0404b429ce..d61e2a5a8e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4094,3 +4094,6 @@ cameraRaw Yellow Sárga # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Bicubic Automatic Automatikus bikubikus + Bicubic Smoother Simább bikubikus + Bicubic Sharper Élesebb bikubikus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 544ead05a4..ddd5b1c6c6 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3166,3 +3166,6 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Bicubic Automatic Bicubic otomatis + Bicubic Smoother Bicubic lebih halus + Bicubic Sharper Bicubic lebih tajam diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 40b468926e..ebdb71f128 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4296,3 +4296,6 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Bicubic Automatic Bicubica automatica + Bicubic Smoother Bicubica più morbida + Bicubic Sharper Bicubica più nitida diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1d64bf868c..4f9f4cc093 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3851,3 +3851,6 @@ curves Smooth スムーズ # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Bicubic Automatic バイキュービック法 (自動) + Bicubic Smoother バイキュービック法 - 滑らか + Bicubic Sharper バイキュービック法 - シャープ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76a3c9826..327b73b417 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3353,3 +3353,6 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Bicubic Automatic 쌍입방 자동 + Bicubic Smoother 쌍입방 더 부드럽게 + Bicubic Sharper 쌍입방 더 선명하게 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2c9416c707..705f1e525e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3935,3 +3935,6 @@ curves Smooth Vloeiend maken # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Bicubic Automatic Bicubisch automatisch + Bicubic Smoother Bicubisch vloeiender + Bicubic Sharper Bicubisch scherper diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eee1e61a90..61f1aeb59c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3773,3 +3773,6 @@ curves Smooth Wygładź # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Bicubic Automatic Dwusześcienna automatyczna + Bicubic Smoother Dwusześcienna wygładzona + Bicubic Sharper Dwusześcienna wyostrzona diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e10fe7dbb..7c29aeffd1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3965,3 +3965,6 @@ layerLabel No Color Sem cor # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Bicubic Automatic Bicúbica automática + Bicubic Smoother Bicúbica mais suave + Bicubic Sharper Bicúbica mais nítida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 862788630b..81317e6be9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3144,3 +3144,6 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Bicubic Automatic Бикубическая, автоматически + Bicubic Smoother Бикубическая, глаже + Bicubic Sharper Бикубическая, чётче diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b91b67b3eb..a4447f4753 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4248,3 +4248,6 @@ shape Appearance Izgled # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Bicubic Automatic Bikubično automatski + Bicubic Smoother Bikubično glađe + Bicubic Sharper Bikubično oštrije diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4c8f06ccc1..c19ceb1f1d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3905,3 +3905,6 @@ cameraData Lens Lens # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Bicubic Automatic Bikübik Otomatik + Bicubic Smoother Bikübik Pürüzsüzleştirici + Bicubic Sharper Bikübik Daha Keskin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 39597c15cb..c77b143b76 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3874,3 +3874,6 @@ curves Smooth Згладити # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Bicubic Automatic Бікубічна, автоматично + Bicubic Smoother Бікубічна, гладкіша + Bicubic Sharper Бікубічна, чіткіша diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ac7123d850..57f9082e97 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3661,3 +3661,6 @@ curves Smooth Làm mượt # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Bicubic Automatic Bicubic tự động + Bicubic Smoother Bicubic mượt hơn + Bicubic Sharper Bicubic sắc hơn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2307739ed3..7eaff68ff4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2790,3 +2790,6 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Bicubic Automatic 双三次(自动) + Bicubic Smoother 双三次(较平滑) + Bicubic Sharper 双三次(较锐利) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 478489f8c7..a5eb751adc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2775,3 +2775,6 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Bicubic Automatic 雙三次 (自動) + Bicubic Smoother 雙三次 (較平滑) + Bicubic Sharper 雙三次 (較銳利) diff --git a/crates/ui-egui/tests/export_as_scaling.rs b/crates/ui-egui/tests/export_as_scaling.rs index f1c9cba32c..c984f11447 100644 --- a/crates/ui-egui/tests/export_as_scaling.rs +++ b/crates/ui-egui/tests/export_as_scaling.rs @@ -129,3 +129,82 @@ fn export_as_scales_a_narrow_image_without_editing_the_source() { assert_eq!(st.revision, revision, "export must not change source revision"); } } + +/// #5838: Export As › Image Size › Resample. Choose Nearest Neighbor in the real dropdown, export +/// at 200 %, and every source pixel arrives as an exact 2 × 2 block; the choice is remembered. +#[test] +fn export_as_resample_menu_writes_nearest_neighbor_blocks() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut h, writes)) = harness() else { return }; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 4, "height": 3, "depth": 8, "background": "white"})).expect("new document"); + app.session + .edit("checker", |doc, active| { + let b = doc.bounds(); + let layer = (*active).ok_or(photocraft_engine::EngineError::NoDocument)?; + let surf = doc.layer_mut(layer).and_then(|l| l.surface_mut()).ok_or(photocraft_engine::EngineError::NoDocument)?; + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + data.extend(photocraft_raster::from_rgba(&fmt, if (x + y) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] })); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .expect("paint a checkerboard"); + app.sync_views(); + } + let source = h.state().session.active().expect("document").doc.clone(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "file.export.exportAs", json!({})).expect("open Export As"); + h.run_steps(6); + + let label = h.get_by_label("Resample").rect(); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("Resample dropdown beside its label") + .click(); + h.run_steps(3); + h.get_by_label("Nearest Neighbor").click(); + h.run_steps(3); + + let label = h.get_by_label("Scale").rect(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("Scale numeric field") + .click(); + h.run_steps(2); + h.event(egui::Event::Text("200".into())); + h.run_steps(3); + h.get_by_label("Image Size").click(); + h.run_steps(3); + assert!(h.query_by_label("8 × 6 px").is_some(), "dialog shows the requested output size"); + evidence(&mut h, 8, "resample-dialog"); + + h.get_by_label("Export").click(); + h.run_steps(6); + let written = writes.borrow(); + assert_eq!(written.len(), 1, "Export writes one file"); + let png = photocraft_io::import("out.png", &written[0].1).expect("decode the written PNG").document; + assert_eq!((png.size.width, png.size.height), (8, 6)); + let surf = png.layers[0].surface().expect("pixels"); + for y in 0..6 { + for x in 0..8 { + let want = if (x / 2 + y / 2) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }; + assert_eq!(surf.rgba(x, y), want, "pixel {x},{y}"); + } + } + let st = h.state().session.active().expect("source remains open"); + assert!(Arc::ptr_eq(&st.doc, &source), "the source document is unchanged"); + assert_eq!(h.state().session.prefs().dialogs["file.export.exportAs"]["resample"], "nearest", "the choice is remembered"); +} diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..5b0a01660f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 8 | 12 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -115,7 +115,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | -| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | +| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 40b242ea53..c6fd2fe753 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -127,9 +127,9 @@ note = "compose multichannel.rs:1 spot preview for Multichannel only" [[item]] id = "FILE-215-18" target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling" -status = "partial" +status = "done" issue = 215 -note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling" +note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks" [[item]] id = "FILE-215-19" From 8601a7b17057f11210206c7a83dab9d7ba8b8fb4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 02:05:26 +0200 Subject: [PATCH 0159/1452] docs: resolve the conflict markers left in the main merge The merge of main (5a4df1a3) committed unresolved conflict markers in the status lines of gaps.md, target-app-parity.md and ui-parity.md and in the Type/Vector rows of target-app-parity.md. Keep main's text and add this PR's own items back: G8 Warp Text preview done (TYPE-218-7), the Type row's evidence, and the selection-modifier and Alt-wheel rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 6 +----- docs/target-app-parity.md | 11 +---------- docs/ui-parity.md | 6 +----- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 146892effe..f231dab29a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,6 @@ # Where PhotoCraft falls short of Photoshop -<<<<<<< ours -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; 2026-10-11: G8: the live Warp Text preview is done, TYPE-218-7) · **Target:** Adobe Photoshop 2026 -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 ->>>>>>> theirs +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; 2026-10-11: G8: the live Warp Text preview is done, TYPE-218-7) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9fb2679db3..9033163d68 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,10 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -<<<<<<< ours -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; 2026-10-11: Type evidence: kerning, the Warp Text preview and variable-font axes are done, percentages unchanged; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 -======= -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 ->>>>>>> theirs +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; 2026-10-11: Type evidence: kerning, the Warp Text preview and variable-font axes are done, percentages unchanged; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -177,13 +173,8 @@ parity for the area and are already contained in the dimension rows above. | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | -<<<<<<< ours | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | -======= -| Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | ->>>>>>> theirs | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ae1734b9a5..252207fd07 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,6 @@ # UI parity: tools, handles, modifiers and feel -<<<<<<< ours -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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-14 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 ->>>>>>> theirs +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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, From 3de03f48bfd22b1b31d5d075a7eb0b4345425acb Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 08:16:22 +0800 Subject: [PATCH 0160/1452] Keep unexported clipboard copies usable when native reads fail --- crates/ui-egui/src/lib.rs | 183 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d75df23aa0..fe970e318c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -687,8 +687,11 @@ pub struct PhotocraftApp { /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the /// egui context is available again (rasterize_prompt). pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, - /// Signature of the image we last put on the OS clipboard (to tell ours from other apps'). + /// Last mirrored or imported OS image; this can predate the current internal copy. os_clip_sig: Option, + /// The session copy, if any, need not track the OS clipboard. An older OS signature is kept + /// to recognize unchanged system contents when exporting a newer copy failed or was disabled. + clip_unmirrored: bool, /// The session clipboard currently holds an image imported from the OS clipboard: it has no /// original position, so Paste centres it (see `menus` "edit.paste"). pub(crate) clip_external: bool, @@ -856,6 +859,7 @@ impl PhotocraftApp { clone_preview: None, info_sample: None, os_clip_sig: None, + clip_unmirrored: true, clip_external: false, clip_read_for_paste: false, transform_preview: None, @@ -2418,6 +2422,7 @@ fn channel_tint(mode: photocraft_doc::ColorMode, k: usize, byte: u8, in_color: b impl PhotocraftApp { /// Mirror the session clipboard onto the OS clipboard (RGBA8). fn export_os_clipboard(&mut self) { + self.clip_unmirrored = true; if !self.session.prefs().general.export_clipboard { return; } @@ -2431,6 +2436,7 @@ impl PhotocraftApp { let bytes: Vec = px.into_iter().flatten().collect(); if set(b.width(), b.height(), &bytes).is_ok() { self.os_clip_sig = Some(clip_signature(b.width(), b.height(), &bytes)); + self.clip_unmirrored = false; } } @@ -2460,14 +2466,21 @@ impl PhotocraftApp { /// clipboard (so ⌘V pastes screenshots and images copied in other apps, like Photoshop). /// Returns `Ok(true)` when a new external image was imported. Once the OS clipboard no longer holds /// the image we mirrored or imported (text or a file was copied since), that image is stale: - /// it is dropped rather than pasted. + /// it is dropped rather than pasted. Unexported internal copies survive unavailable or empty + /// system clipboards. pub(crate) fn import_os_clipboard(&mut self) -> Result { let Some(get) = self.services.clipboard_get_image.as_mut() else { return Ok(false) }; // A read failure says nothing about the clipboard contents. Keep our editable copy and - // its signature, and abort this paste rather than treating the failure as an empty image. + // its signature rather than treating the failure as an empty image. let image = match get() { Ok(image) => image, Err(error) => { + // An unexported copy remains usable without an OS clipboard (for example, in + // a Wayland sandbox). Mirrored/imported copies still require a successful read + // before pasting, because their system contents may have changed. + if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { + return Ok(false); + } self.ui.status = error.clone(); self.ui.status_error = true; return Err(error); @@ -2475,9 +2488,10 @@ impl PhotocraftApp { }; let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { - if self.os_clip_sig.take().is_some() { + if self.os_clip_sig.take().is_some() && !self.clip_unmirrored { self.session.clipboard = None; self.clip_external = false; + self.clip_unmirrored = true; } return Ok(false); }; @@ -2491,6 +2505,7 @@ impl PhotocraftApp { self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None }); self.os_clip_sig = Some(sig); self.clip_external = true; + self.clip_unmirrored = false; Ok(true) } } @@ -2678,14 +2693,20 @@ mod clipboard_tests { use std::sync::atomic::{AtomicBool, Ordering}; let busy = Arc::new(AtomicBool::new(true)); let unavailable = busy.clone(); + let os = OsClip::default(); + let (write, read) = (os.clone(), os); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), clipboard_get_image: Some(Box::new(move || { if unavailable.load(Ordering::SeqCst) { Err("The native clipboard is not accessible due to being held by another party.".into()) } else { - Ok(None) + Ok(read.lock().unwrap().clone()) } })), ..Default::default() @@ -2767,6 +2788,158 @@ mod clipboard_tests { } } + #[test] + fn internal_only_clipboard_remains_usable_when_native_access_fails() { + use std::sync::atomic::{AtomicBool, Ordering}; + for export in [true, false] { + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let os = OsClip::default(); + let unavailable = Arc::new(AtomicBool::new(true)); + let (read, failed) = (os.clone(), unavailable.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(|_, _, _| Err("clipboard unavailable".into()))), + clipboard_get_image: Some(Box::new(move || { + if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = export); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + assert!(app.os_clip_sig.is_none() && !app.clip_external); + let bounds = app.session.clipboard.as_ref().unwrap().bounds; + assert_eq!(app.new_document_fields()["__preset"], "Clipboard"); + crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + assert!(!app.ui.status_error); + if id == "file.newFromClipboard" { + assert_eq!(app.session.documents().len(), 3); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height), (bounds.width(), bounds.height())); + } else { + assert_eq!(app.session.documents().len(), 2); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); + } + app.run("edit.purge.clipboard", serde_json::json!({})).unwrap(); + assert!(app.session.clipboard.is_none()); + assert!(app.run("edit.paste", serde_json::json!({})).is_err(), "purged content must not be a fallback"); + // The fallback is only for failed reads: a later successful external image wins. + *os.lock().unwrap() = Some((3, 2, [0u8, 0, 255, 255].repeat(6))); + unavailable.store(false, Ordering::SeqCst); + app.run("edit.paste", serde_json::json!({})).unwrap(); + assert!(app.clip_external && app.os_clip_sig.is_some()); + let clip = app.session.clipboard.as_ref().unwrap(); + assert!(clip.layers.is_none()); + assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2)); + } + } + } + + #[test] + fn newer_unexported_copies_do_not_track_an_older_os_signature() { + use std::sync::atomic::{AtomicBool, Ordering}; + for imported in [true, false] { + for disable_export in [true, false] { + for copy_id in ["edit.copy", "edit.cut", "edit.copyMerged"] { + let os = OsClip::default(); + let write_fails = Arc::new(AtomicBool::new(false)); + let read_fails = Arc::new(AtomicBool::new(false)); + let (write, read, failed_write, failed_read) = (os.clone(), os.clone(), write_fails.clone(), read_fails.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + if failed_write.load(Ordering::SeqCst) { + return Err("clipboard unavailable".into()); + } + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), + clipboard_get_image: Some(Box::new(move || { + if failed_read.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + if imported { + *os.lock().unwrap() = Some((2, 2, [255u8, 0, 0, 255].repeat(4))); + assert!(app.import_os_clipboard().unwrap()); + } else { + app.run("edit.fill", serde_json::json!({"color": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + } + let old_signature = app.os_clip_sig; + assert!(old_signature.is_some() && !app.clip_unmirrored); + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + app.run("edit.fill", serde_json::json!({"color": "#0000ff"})).unwrap(); + write_fails.store(true, Ordering::SeqCst); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = !disable_export); + app.run(copy_id, serde_json::json!({})).unwrap(); + let copied = app.session.clipboard.clone().unwrap(); + assert!(app.clip_unmirrored && !app.clip_external); + assert_eq!(app.os_clip_sig, old_signature); + assert_eq!(copied.surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + // An old successful export/import must not block the newer internal copy. + read_fails.store(true, Ordering::SeqCst); + app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap(); + assert_eq!(app.os_clip_sig, old_signature); + assert!(app.clip_unmirrored && !app.clip_external); + read_fails.store(false, Ordering::SeqCst); + assert!(!app.import_os_clipboard().unwrap(), "unchanged old OS image must not overwrite the newer copy"); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0)); + *os.lock().unwrap() = None; + assert!(!app.import_os_clipboard().unwrap(), "empty OS clipboard must not discard an unexported copy"); + assert!(app.os_clip_sig.is_none() && app.clip_unmirrored); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0)); + // A different external image supersedes the internal copy and disables fallback. + *os.lock().unwrap() = Some((3, 2, [0u8, 255, 0, 255].repeat(6))); + assert!(app.import_os_clipboard().unwrap()); + assert!(app.clip_external && !app.clip_unmirrored); + let clip = app.session.clipboard.as_ref().unwrap(); + assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2)); + read_fails.store(true, Ordering::SeqCst); + assert!(app.run("edit.paste", serde_json::json!({})).is_err()); + } + } + } + } + + #[test] + fn failed_clipboard_reads_do_not_paste_an_external_or_missing_copy() { + use std::sync::atomic::{AtomicBool, Ordering}; + for external in [true, false] { + let unavailable = Arc::new(AtomicBool::new(false)); + let failed = unavailable.clone(); + let services = Services { + clipboard_get_image: Some(Box::new(move || { + if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(Some((3, 2, [255u8, 0, 0, 255].repeat(6)))) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + if external { + assert!(app.import_os_clipboard().unwrap()); + } + let signature = app.os_clip_sig; + let before = app.session.active().unwrap().doc.walk().len(); + unavailable.store(true, Ordering::SeqCst); + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})); + assert_eq!(result.unwrap_err(), "clipboard unavailable", "{id}"); + assert!(app.ui.status_error); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.walk().len(), before); + assert_eq!(app.os_clip_sig, signature); + assert_eq!(app.session.clipboard.is_some(), external); + assert_eq!(app.clip_external, external); + } + } + } + /// An app whose OS clipboard is `os`, counting every read in `reads`. fn app_with_os_clipboard(os: &OsClip, reads: &Arc) -> PhotocraftApp { let (b, n) = (os.clone(), reads.clone()); From 1ce9a87b6b10d53ff29fa923e9266114af355f62 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 02:18:12 +0200 Subject: [PATCH 0161/1452] Effects: Gradient Overlay transparency follows Smoothness, midpoints and method (#1954) Gradient layer effects baked only their colour stops; the opacity stops came through raw, so Smoothness, opacity midpoints and the interpolation method never reached the transparency ramp. Gradient fill layers applied Smoothness and midpoints but always with Classic. Both now share one bake (gradient_bake::bake_opacity). Measured on scripted Photoshop 25.1 (overlays and fill layers render identical pixels): Linear and Perceptual also bend the opacity curve (stops enter the method's space as sRGB grey levels, the result leaves as y^0.65), fitted within 0.015 of every 8-bit sample. Smooth keeps Classic's opacity curve (not measurable on 25.1). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/blocks.rs | 26 ++++---- crates/io/src/effects_map.rs | 78 ++++++++++++++++++++++++ crates/io/src/gradient_bake.rs | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 12 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..dbd4cfe841 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -252,7 +252,8 @@ pub(crate) fn gradient_style_value(s: GradientStyle) -> Value { pub(crate) type Stops = (Vec<(f32, Color)>, Vec<(f32, f32)>); /// Stops of a `Grdn` descriptor, baked to Photoshop's interpolation (smoothness `Intr`, stop -/// midpoints, and the parent's interpolation method when given, see [`gradient_method`]). +/// midpoints, and the parent's interpolation method when given, see [`gradient_method`]); the +/// opacity stops as [`baked_opacity_stops`]. pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> Stops { let raw = gradient_stops_raw(grad); let stops = if raw.stops.len() >= 2 { @@ -260,7 +261,7 @@ pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> S } else { raw.stops }; - (stops, raw.opacity) + (stops, baked_opacity_stops(grad, method)) } /// A `Grdn` descriptor's stops as stored: colour stops (sorted), the midpoint of each segment @@ -376,26 +377,27 @@ pub(crate) fn gradient_desc_with(stops: &[(f32, Color)], opacity: &[(f32, f32)], .with("Trns", Value::List(trns)) } -/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the -/// same curve as the colour stops, Classic interpolation), densely sampled. -fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> { +/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness, opacity midpoints and +/// interpolation method applied (densely sampled; as stored when the interpolation is plain). +/// Gradient fill layers and the gradient layer effects share it: Photoshop renders their +/// transparency identically. +fn baked_opacity_stops(grad: &Descriptor, method: Option<&[u8]>) -> Vec<(f32, f32)> { let mut stops = Vec::new(); let mut mids = Vec::new(); if let Some(Value::List(items)) = grad.get("Trns") { for it in items { if let Value::Descriptor(s) = it { - let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0; - stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0))); + stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0)); mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0); } } } + // Midpoint k applies to the segment after stop k (stored on the stop that ends it). let mut order: Vec = (0..stops.len()).collect(); order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0)); let mids: Vec = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect(); let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32); - let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops }; - baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect() + crate::gradient_bake::bake_opacity(stops, &mids, smooth, crate::gradient_bake::Method::from_code(method)) } /// Parses a fill block (`SoCo`, `GdFl`, `PtFl`). @@ -415,9 +417,9 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d))); let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default(); - // Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when - // the interpolation is plain); none when fully opaque. - let mut opacity_stops = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default(); + // Opacity stops with Photoshop's smoothness, opacity midpoints and method applied + // (raw when the interpolation is plain); none when fully opaque. + let mut opacity_stops = get_desc(d, "Grad").map(|g| baked_opacity_stops(g, gradient_method(d))).unwrap_or_default(); if opacity_stops.iter().all(|o| o.1 >= 1.0 - 1e-4) { opacity_stops.clear(); } diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..38d22e3729 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -811,6 +811,84 @@ mod tests { } } + /// A Gradient Overlay `lfx2` laid out as Photoshop writes it: one black colour, opacity + /// 100 → 0 % with the opacity midpoint `mid` (%), Smoothness `intr` (0..4096) and the method + /// enum `gs99` (Photoshop 25.1's key in layer effects; codes `Gcls`, `Lnr `, `Perc`). + fn overlay_lfx2(mid: i32, intr: f64, method: &str) -> Vec { + let black = || Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + let clr = |loc: i32| { + Value::Descriptor( + Descriptor::new("Clrt") + .with("Clr ", Value::Descriptor(black())) + .with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") }) + .with("Lctn", Value::Integer(loc)) + .with("Mdpn", Value::Integer(50)), + ) + }; + let trn = |loc: i32, op: f32, mid: i32| { + Value::Descriptor(Descriptor::new("TrnS").with("Opct", unit(b"#Prc", op)).with("Lctn", Value::Integer(loc)).with("Mdpn", Value::Integer(mid))) + }; + let grad = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("CstS") }) + .with("Intr", Value::Double(intr)) + .with("Clrs", Value::List(vec![clr(0), clr(4096)])) + .with("Trns", Value::List(vec![trn(0, 100.0, 50), trn(4096, 0.0, mid)])); + let fx = Descriptor::new("GrFl") + .with("enab", Value::Boolean(true)) + .with("Grad", Value::Descriptor(grad)) + .with("Angl", unit(b"#Ang", 0.0)) + .with("gs99", Value::Enumerated { type_id: Id::new("gradientInterpolationMethodType"), value: Id::new(method) }); + let d = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with("GrFl", Value::Descriptor(fx)); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes()); + data + } + + fn overlay_opacity(data: &[u8]) -> Vec<(f32, f32)> { + let (_, fx) = parse_lfx2(data).unwrap(); + let Some(Effect::GradientOverlay { gradient, .. }) = fx.first() else { panic!("overlay: {fx:?}") }; + gradient.opacity_stops.clone() + } + + fn opacity_at(o: &[(f32, f32)], t: f32) -> f32 { + let i = o.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + o[i].1 + (o[i + 1].1 - o[i].1) * (t - o[i].0) / (o[i + 1].0 - o[i].0) + } + + /// #1954: the overlay's transparency follows Smoothness, the opacity midpoints and the method + /// like a gradient fill layer's (Photoshop 25.1 renders both identically; the values are its + /// composites at t = 0.25 and 0.5). + #[test] + fn gradient_overlay_opacity_follows_smoothness_midpoint_and_method() { + let at = |mid, intr, method: &str, t| opacity_at(&overlay_opacity(&overlay_lfx2(mid, intr, method)), t); + // Plain Classic stays the linear fade it is stored as. + assert_eq!(overlay_opacity(&overlay_lfx2(50, 0.0, "Gcls")), vec![(0.0, 1.0), (1.0, 0.0)]); + // Midpoint 25 %: half transparent a quarter of the way. + assert!((at(25, 0.0, "Gcls", 0.25) - 0.5).abs() < 0.01); + // Smoothness 100 % eases the ends (Photoshop: 0.796 at a quarter). + assert!((at(50, 4096.0, "Gcls", 0.25) - 0.796).abs() < 0.015); + // The method bends the transparency ramp too, not only the colours (Photoshop at the + // middle: 0.631 Linear, 0.255 Perceptual). + assert!((at(50, 0.0, "Lnr ", 0.5) - 0.631).abs() < 0.015); + assert!((at(50, 0.0, "Perc", 0.5) - 0.255).abs() < 0.015); + assert!((at(25, 4096.0, "Perc", 0.25) - 0.255).abs() < 0.015); + } + + /// A regenerated `lfx2` carries the baked ramp as plain (Classic, Smoothness 0) stops, so it + /// reads back unchanged. + #[test] + fn baked_overlay_opacity_round_trips() { + let data = overlay_lfx2(25, 4096.0, "Perc"); + let (master, fx) = parse_lfx2(&data).unwrap(); + let a = overlay_opacity(&data); + let b = overlay_opacity(&write_lfx2(master, &fx)); + assert!(a.len() > 2); + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(&b) { + assert!((x.0 - y.0).abs() < 1e-3 && (x.1 - y.1).abs() < 1e-4, "{x:?} vs {y:?}"); + } + } + #[test] fn garbage_is_none() { assert!(parse_lfx2(&[0, 0]).is_none()); diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index 0d34816a79..ca4c82e9f5 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -185,10 +185,116 @@ pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method out } +/// Exponent of Photoshop's opacity response for Perceptual and Linear gradients: the opacity +/// stops enter the method's space as if they were sRGB grey levels (Linear: linear light; +/// Perceptual: Oklab L), and the interpolated linear-light value `y` comes out as opacity +/// `y^0.65`. Fitted on Photoshop 25.1 (scripted Gradient Overlays and gradient fill layers, which +/// render identical pixels: 100 → 0 % and 100 → 20 → 90 % ramps with Smoothness 0 and 100 and +/// midpoints 25 and 50 %, within 0.015 of every 8-bit sample; a flat 60 % renders as 47 %). +const OPACITY_RESPONSE: f32 = 0.65; + +/// Dense linear opacity stops reproducing Photoshop's interpolation of a gradient's transparency +/// (Smoothness, opacity midpoints and the method, see [`OPACITY_RESPONSE`]). Returns the input +/// unchanged when it already interpolates linearly. Smooth keeps Classic's opacity curve: it was +/// not measured (Photoshop 25.1 has no Smooth method). +pub fn bake_opacity(stops: Vec<(f32, f32)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, f32)> { + let method = if method == Method::Smooth { Method::Classic } else { method }; + let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); + let smoothness = if smoothness.is_finite() { smoothness.clamp(0.0, 1.0) } else { 0.0 }; + if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) { + return stops; + } + let into = |o: f32| -> f32 { + let o = if o.is_finite() { o.clamp(0.0, 1.0) } else { 1.0 }; + match method { + Method::Linear => lin(o), + Method::Perceptual => lin(o).cbrt(), + Method::Classic | Method::Smooth => o, + } + }; + let out = |v: f32| -> f32 { + let v = if v.is_finite() { v } else { 1.0 }; + let y = match method { + Method::Linear => v, + Method::Perceptual => v.max(0.0).powi(3), + Method::Classic | Method::Smooth => return v.clamp(0.0, 1.0), + }; + y.clamp(0.0, 1.0).powf(OPACITY_RESPONSE) + }; + let mut s = stops; + s.sort_by(|a, b| a.0.total_cmp(&b.0)); + let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, o)| (*t, [into(*o); 3])).collect(); + const N: usize = 96; + let mut baked: Vec<(f32, f32)> = (0..=N) + .map(|k| { + let t = k as f32 / N as f32; + // Smoothness and midpoints shape the curve as for Classic colours, in the method's space. + (t, out(eval(&pts, midpoints, smoothness, t, Method::Classic)[0])) + }) + .collect(); + baked.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + baked +} + #[cfg(test)] mod tests { use super::*; + fn opacity_at(stops: &[(f32, f32)], t: f32) -> f32 { + let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let (a, b) = (stops[i], stops[i + 1]); + a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) + } + + /// Photoshop 25.1 Gradient Overlay samples (black, Normal, over white; a 256 px wide linear + /// gradient, pixel `x` at t = (x + 0.5) / 256): opacity stops, midpoints, Smoothness, method, + /// and (x, opacity) pairs read from the composite. + #[test] + fn opacity_matches_photoshop_samples() { + type Case = (&'static [(f32, f32)], &'static [f32], f32, Method, &'static [(f32, f32)]); + let ramp: &[(f32, f32)] = &[(0.0, 1.0), (1.0, 0.0)]; + let three: &[(f32, f32)] = &[(0.0, 1.0), (0.25, 0.2), (1.0, 0.9)]; + let cases: [Case; 9] = [ + (ramp, &[0.5], 0.0, Method::Linear, &[(64.0, 0.827), (128.0, 0.631), (192.0, 0.400)]), + (ramp, &[0.5], 0.0, Method::Perceptual, &[(64.0, 0.565), (128.0, 0.255), (192.0, 0.063)]), + (ramp, &[0.25], 1.0, Method::Classic, &[(32.0, 0.796), (64.0, 0.502), (128.0, 0.298), (192.0, 0.122)]), + (ramp, &[0.25], 1.0, Method::Linear, &[(32.0, 0.859), (64.0, 0.631), (128.0, 0.447), (192.0, 0.247)]), + (ramp, &[0.25], 1.0, Method::Perceptual, &[(32.0, 0.639), (64.0, 0.255), (128.0, 0.090), (192.0, 0.016)]), + (ramp, &[0.5], 1.0, Method::Classic, &[(64.0, 0.796), (128.0, 0.502), (192.0, 0.204)]), + (three, &[0.5, 0.5], 1.0, Method::Classic, &[(32.0, 0.557), (64.0, 0.200), (160.0, 0.502)]), + (three, &[0.5, 0.5], 1.0, Method::Perceptual, &[(32.0, 0.392), (64.0, 0.106), (160.0, 0.341)]), + (three, &[0.5, 0.5], 0.0, Method::Linear, &[(32.0, 0.647), (64.0, 0.106), (160.0, 0.557)]), + ]; + for (stops, mids, smooth, m, samples) in cases { + let baked = bake_opacity(stops.to_vec(), mids, smooth, m); + for (x, want) in samples { + let got = opacity_at(&baked, (x + 0.5) / 256.0); + assert!((got - want).abs() < 0.015, "{m:?} smooth {smooth} mids {mids:?} x {x}: {got} vs Photoshop {want}"); + } + } + // A flat 60 % renders as 47 % in Linear and Perceptual, 60 % in Classic. + for (m, want) in [(Method::Linear, 0.471), (Method::Perceptual, 0.471)] { + let baked = bake_opacity(vec![(0.0, 0.6), (1.0, 0.6)], &[0.5], 0.0, m); + assert!(baked.iter().all(|o| (o.1 - want).abs() < 0.01), "{m:?}: {baked:?}"); + } + } + + #[test] + fn plain_classic_and_smooth_opacity_stay_as_stored() { + let stops = vec![(1.0, 0.0), (0.0, 1.0)]; + assert_eq!(bake_opacity(stops.clone(), &[0.5], 0.0, Method::Classic), stops); + assert_eq!(bake_opacity(stops.clone(), &[0.5], 0.0, Method::Smooth), stops); + assert_eq!(bake_opacity(vec![(0.5, 0.3)], &[], 1.0, Method::Linear), vec![(0.5, 0.3)]); + } + + #[test] + fn opacity_bake_survives_hostile_values() { + for m in [Method::Classic, Method::Perceptual, Method::Linear, Method::Smooth] { + let baked = bake_opacity(vec![(f32::NAN, f32::INFINITY), (1.0, f32::NAN), (0.5, -3.0)], &[f32::NAN, 2.0], f32::NAN, m); + assert!(baked.iter().all(|o| (0.0..=1.0).contains(&o.1)), "{m:?}: {baked:?}"); + } + } + fn at(stops: &[(f32, Color)], t: f32) -> [f32; 3] { let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); let (a, b) = (&stops[i], &stops[i + 1]); From a0c2c1992f0e09e143728c8fb89c9dc48e89f3b1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 02:36:30 +0200 Subject: [PATCH 0162/1452] docs: ui-parity marks the modifiers-at-press row done, as BUG-208-4 is Main has the marquee Shift+Alt intersect test (#3624), so the row no longer says it is missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 252207fd07..01c94cc7c4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -106,7 +106,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | ⇧ square/circle, ⌥ from centre, live W × H readout (marquee) | done | `marquee_tests.rs` | -| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | partial | works; tested for the marquee (add, subtract), Lasso (all three) and Polygonal Lasso; no marquee ⇧⌥ intersect test (BUG-208-4) | +| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | done | tested for the marquee (add, subtract, ⇧⌥ intersect: `marquee_tests.rs`), Lasso (all three: `lasso_ui/tests.rs`) and Polygonal Lasso (`polygon_lasso_tests.rs`) (BUG-208-4) | | Space repositions the marquee while dragging | done | `hold_keys.rs` | | Polygonal Lasso ⇧ snaps to 45° | missing | #2352 | | Autoscroll at the window edge while selecting | missing | #2231 | From 599a2304ed3fb68e8796bbe1b2ee887049db59d9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 02:41:06 +0200 Subject: [PATCH 0163/1452] History panel menu: Delete removes the selected snapshot, as the trash does Main's History panel can select a snapshot, and its trash then deletes that snapshot (#5324). The panel menu's Delete still deleted the current state. It now follows the same selection. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/history_menu_ui.rs | 38 +++++++++++++++++++++++++-- crates/ui-egui/src/panels.rs | 9 +++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index 6a7912e346..e0768f5ba6 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -28,6 +28,15 @@ fn params(cmd: &str) -> Value { if cmd == "window.panel.close" { json!({"panel": "history"}) } else { json!({}) } } +/// The command and params an item runs: with a snapshot selected in the panel, Delete removes +/// that snapshot, as the panel's trash does (#5324); otherwise it removes the current state. +fn resolve(cmd: &str, snapshot: Option) -> (&str, Value) { + match snapshot { + Some(id) if cmd == "history.deleteState" => ("history.deleteSnapshot", json!({"snapshot": id})), + _ => (cmd, params(cmd)), + } +} + fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { if cmd == "window.panel.close" { return !app.session.prefs().workspace_locked; @@ -38,20 +47,22 @@ fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { /// Draw the History items of the panel menu and run the one clicked. pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let snapshot = app.session.active().and_then(|st| crate::panels::selected_snapshot(ui.ctx(), st)); let mut clicked: Option<&str> = None; for &(label, cmd) in ITEMS { if label == "---" { ui.separator(); continue; } - let on = cmd.is_some_and(|c| enabled(app, c)); + let on = cmd.is_some_and(|c| enabled(app, resolve(c, snapshot).0)); if ui.add_enabled(on, egui::Button::new(crate::i18n::t(label))).clicked() { clicked = cmd; } } let Some(cmd) = clicked else { return }; ui.close(); - if let Err(e) = app.run(cmd, params(cmd)) { + let (cmd, params) = resolve(cmd, snapshot); + if let Err(e) = app.run(cmd, params) { app.ui.status = e; app.ui.status_error = true; } @@ -140,6 +151,29 @@ mod tests { } } + /// With a snapshot selected in the panel, Delete removes the snapshot and leaves the states, + /// as the panel's trash does (#5324); without one it removes the current state. + #[test] + fn delete_removes_the_selected_snapshot() { + let mut app = app(); + app.run("history.newSnapshot", json!({})).unwrap(); + let mut h = harness(app); + let snaps = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().snapshots.len(); + let before = snaps(&h); + let id = h.state().session.active().unwrap().snapshots.last().unwrap().id; + let ctx = h.ctx.clone(); + crate::panels::select_snapshot(&ctx, h.state().session.active().unwrap(), id); + h.run_steps(2); + let steps = states(&h); + click(&mut h, "Delete"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(snaps(&h), before - 1, "Delete removed the selected snapshot"); + assert_eq!(states(&h), steps, "and left the states alone"); + click(&mut h, "Delete"); + assert_eq!(snaps(&h), before - 1, "no snapshot selected now: the snapshots are kept"); + assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted"); + } + /// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that /// claims complete menus must still translate them. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 769f5a8b9a..0b4fd6ce1e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3195,7 +3195,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The snapshot stays selected while the document is the state the click made. Ok(_) => { if let (Some(id), Some(st)) = (picked, app.session.active()) { - ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); + select_snapshot(&ctx, st, id); } } } @@ -3234,10 +3234,15 @@ fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id { egui::Id::new(("history-snapshot-selected", doc)) } +/// Select snapshot `id` at the history's current state, as a click on its row does. +pub(crate) fn select_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState, id: u64) { + ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); +} + /// The selected snapshot: the one last clicked, while it still exists and the history is still at /// the state that click made. Any other step (an edit, Undo, a state row) deselects it, as in /// Photoshop. -fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { +pub(crate) fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { let key = selected_snapshot_key(st.doc.id); let (id, spot) = ctx.data(|d| d.get_temp::(key))?; let live = spot == history_spot(st) && st.snapshots.iter().any(|s| s.id == id); From 6bb45c8d5b552ba00924bd25b92842641aab01b8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 03:02:19 +0200 Subject: [PATCH 0164/1452] Shapes: Density and Feather on shape layers (#5846) A shape layer's path is its vector mask in Photoshop (Properties > Masks), with its own vector-mask density and feather. PhotoCraft's ShapeLayer had neither, so soft shapes could not be made, and stroked soft shapes from PSDs lost both on import. - doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px, default 0). - vector: the shape renderer applies them to the fill as a soft vector mask (fill shows beyond the path at 1 - density; the feather is the vector mask's Gaussian), so the shape's own pixels, the GPU (which samples them) and the stroke/fill split all follow. The stroke stays crisp. The feather blur moves from compose::masks to vector::feather and both share it and the feathered area, so a shape and an equal vector mask render the same pixels. - compose: soft shapes' bounds reach their fill, their layer effects follow the soft alpha, and the split cache key includes density and feather. - engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one history step (path/enabled/linked/invert are refused there); density is now validated (finite 0..100) instead of clamped. `layer.vectorMask.info` reports a shape's values. - ui: Properties shows the Vector Mask Density and Feather sliders for a selected shape layer in both themes (live drag, one undo per gesture). - .pcraft: manifest and convert carry both with serde defaults (older files load at 100 % and 0 px). - PSD: export writes them as the vector density/feather mask parameters; import reads them into the shape (re-rendered, as the stored pixels lack the fill beyond the path). Soft unstroked solid/gradient shapes now import as shapes instead of fill layers with a vector mask; pattern-filled ones stay fill layers. Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72: ours is within 1 level of every measured value (8, 16 and 32 bits); density 50 % shows 127/128 beyond the path and 0 inside. Scorecard: TOOL-214-12 done. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 23 +- crates/compose/src/masks.rs | 265 +------------------- crates/compose/src/shape_split.rs | 2 + crates/compose/src/soft_shape_tests.rs | 159 ++++++++++++ crates/compose/src/stroke_tests.rs | 13 +- crates/doc/src/vector.rs | 21 +- crates/engine/src/vector_cmds.rs | 45 +++- crates/engine/src/vector_cmds/tests.rs | 65 +++++ crates/format/src/convert.rs | 6 +- crates/format/src/manifest.rs | 23 ++ crates/format/tests/common/mod.rs | 2 + crates/format/tests/roundtrip.rs | 14 ++ crates/gpu/tests/parity.rs | 47 +++- crates/io/src/affinity.rs | 2 +- crates/io/src/psd_export.rs | 8 +- crates/io/src/psd_import.rs | 31 ++- crates/io/tests/corpus_regressions.rs | 10 +- crates/io/tests/effects_multi.rs | 3 +- crates/io/tests/vector.rs | 45 ++++ crates/ui-egui/src/layer_props_ui.rs | 5 +- crates/ui-egui/src/mask_props_ui.rs | 19 +- crates/ui-egui/src/mask_props_ui_tests.rs | 58 ++++- crates/ui-egui/src/panels.rs | 1 + crates/vector/src/feather.rs | 281 ++++++++++++++++++++++ crates/vector/src/lib.rs | 101 +++++++- docs/scorecard.md | 4 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 4 +- scorecard/tools.toml | 4 +- 29 files changed, 958 insertions(+), 308 deletions(-) create mode 100644 crates/compose/src/soft_shape_tests.rs create mode 100644 crates/vector/src/feather.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b742540b95..366c13b543 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -802,11 +802,13 @@ pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { /// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the /// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other /// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches -/// past the path (their pixels give the outline: psd-tools double-stroke-effects). +/// past the path (their pixels give the outline: psd-tools double-stroke-effects), and shapes +/// with a density or feather. pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> { let LayerContent::Shape(sh) = &layer.content else { return None }; let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside); - (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path) + // A soft (feathered or less dense) fill's own alpha is its outline. + (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted && !sh.has_soft_mask()).then_some(&sh.path) } /// The shape a layer's effects are built from over `rect` (row-major): its content's alpha @@ -864,7 +866,8 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } -/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. +/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width +/// (and the reach of a soft fill). pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { return None; @@ -872,7 +875,17 @@ pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { let (x0, y0, x1, y1) = sh.path.control_bounds()?; let stroke_margin = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| s.width.ceil()) as i32; let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32).inflate(stroke_margin); - (!r.is_empty()).then_some(r) + if r.is_empty() { + return None; + } + // A soft fill reaches past the path: by its feather, or everywhere below 100 % density. + if sh.fill.is_some() && sh.has_soft_mask() { + if sh.density.is_finite() && sh.density < 1.0 { + return Some(Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4)); + } + return Some(r.union(&photocraft_vector::feather::path_area(&sh.path, masks::feather_sigma(sh.feather)))); + } + Some(r) } /// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask @@ -1889,6 +1902,8 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f } } +#[cfg(test)] +mod soft_shape_tests; #[cfg(test)] mod stroke_tests; #[cfg(test)] diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index 8323e1b939..802a0f2df9 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -171,17 +171,8 @@ fn key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. -/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector -/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's -/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). -pub fn feather_sigma(px: f32) -> f32 { - if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } -} - -const FEATHER_SIGMA: f32 = 1.0; -/// Photoshop's feather tops out at 1000 px. -const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA; +pub use photocraft_vector::feather::feather_sigma; +use photocraft_vector::feather::gaussian; /// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]). pub fn has_feather(layer: &Layer) -> bool { @@ -189,166 +180,6 @@ pub fn has_feather(layer: &Layer) -> bool { || layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0) } -/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just -/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. -#[derive(Clone, Copy, Debug, PartialEq)] -struct BoxPass { - r: usize, - end: f32, -} - -impl BoxPass { - /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et - /// al. 2011), or `None` when there is nothing to blur. - fn for_sigma(sigma: f32) -> Option { - if !sigma.is_finite() || sigma <= 0.0 { - return None; - } - // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. - let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; - // The largest whole radius whose box variance r(r+1)/3 is at most v. - let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; - let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; - while r > 0 && var(r) > v { - r -= 1; - } - while var(r + 1) <= v { - r += 1; - } - let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); - let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); - Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) - } -} - -/// Apply one of the three box passes to a single line. `scratch` preserves the input while -/// the sliding sum writes the output, matching the previous sequential implementation exactly. -fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { - if line.is_empty() { - return; - } - let Ok(radius) = isize::try_from(pass.r) else { return }; - scratch.copy_from_slice(line); - let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; - let mut acc: f32 = (-radius..=radius).map(get).sum(); - let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; - // The sample leaving the window is the next one's low end weight. - let mut below = get(-radius - 1); - for (i, dst) in line.iter_mut().enumerate() { - let i = i as isize; - let (above, leaving) = (get(i + radius + 1), get(i - radius)); - *dst = (acc + pass.end * (below + above)) / n; - acc += above - leaving; - below = leaving; - } -} - -/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used -/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon -/// work here can deadlock when every worker waits on the same OnceLock (#276). -fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { - let mut scratch = vec![0.0; w]; - for row in v.chunks_mut(w) { - for _ in 0..3 { - blur_line(row, &mut scratch, pass); - } - } -} - -fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { - if w == 0 || v.is_empty() { - return; - } - let h = v.len() / w; - let threads = if cfg!(target_arch = "wasm32") || v.len() < 1 << 16 { - 1 - } else { - std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) - }; - if threads == 1 { - blur_rows_chunk(v, w, pass); - return; - } - let rows_per_worker = h.div_ceil(threads); - let chunk_len = rows_per_worker.saturating_mul(w); - std::thread::scope(|scope| { - for chunk in v.chunks_mut(chunk_len) { - scope.spawn(move || blur_rows_chunk(chunk, w, pass)); - } - }); -} - -/// Cache-friendly blocked transpose. It makes the vertical blur contiguous without a Rayon job -/// or unsafe disjoint-column access; the temporary is released before the next mask is built. -fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { - const BLOCK: usize = 32; - for by in (0..h).step_by(BLOCK) { - for bx in (0..w).step_by(BLOCK) { - for y in by..(by + BLOCK).min(h) { - for x in bx..(bx + BLOCK).min(w) { - out[x * h + y] = v[y * w + x]; - } - } - } - } -} - -/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is -/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into -/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like -/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels -/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes -/// are processed in parallel on bounded OS threads, preserving each line's floating-point -/// operation order. -fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { - if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { - return; - } - let Some(pass) = BoxPass::for_sigma(sigma) else { return }; - // Reserve before modifying the input so allocation failure can use the sequential fallback - // without leaving a half-blurred mask. - let mut transposed = Vec::new(); - if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, pass); - return; - } - transposed.resize(v.len(), 0.0); - blur_rows(v, w, pass); - transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, pass); - transpose_into(&transposed, v, h, w); -} - -/// Allocation-light reference path used if parallel transpose storage cannot be reserved. -fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { - let mut line = Vec::new(); - let mut scratch = Vec::new(); - let len = w.max(h); - if line.try_reserve_exact(len).is_err() || scratch.try_reserve_exact(len).is_err() { - return; - } - line.resize(len, 0.0); - scratch.resize(len, 0.0); - for axis in [false, true] { - let (lines, line_len) = if axis { (w, h) } else { (h, w) }; - for k in 0..lines { - for i in 0..line_len { - line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; - } - for _ in 0..3 { - blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); - } - for i in 0..line_len { - if axis { - v[i * w + k] = line[i]; - } else { - v[k * w + i] = line[i]; - } - } - } - } -} - /// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather, /// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or /// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel @@ -372,29 +203,14 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { let d = m.surface.default_pixel().first().copied().unwrap_or(1.0); 1.0 - m.density * (1.0 - d) }); - let grow = |r: Rect, sigma: f32| { - let m = (sigma * 3.0).ceil() as i32 + 2; - if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) } - }; // Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. - let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow( - Rect::new( - (x0.floor() as i32).saturating_sub(2), - (y0.floor() as i32).saturating_sub(2), - (x1.ceil() as i32).saturating_add(2), - (y1.ceil() as i32).saturating_add(2), - ), - sv, - ), - None => Rect::EMPTY, - }; + let mut area = vm.map_or(Rect::EMPTY, |vm| photocraft_vector::feather::path_area(&vm.path, sv)); if let Some(m) = pixel && v_out > 0.0 { - let b = grow(m.surface.content_bounds(), sp); + let b = photocraft_vector::feather::grow(m.surface.content_bounds(), sp); area = if area.is_empty() { b } else if b.is_empty() { @@ -729,79 +545,6 @@ mod tests { } } - /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole - /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). - #[test] - fn feather_blur_has_the_exact_variance() { - for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { - let (w, c) = (801usize, 400usize); - let mut v = vec![0.0f32; w]; - v[c] = 1.0; - gaussian(&mut v, w, 1, sigma); - // Within the kernel's reach (the running sums leave ~1e-8 behind further out). - let reach = (8.0 * sigma) as usize + 4; - let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); - assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); - assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); - } - assert_eq!(BoxPass::for_sigma(0.0), None); - assert_eq!(BoxPass::for_sigma(f32::NAN), None); - assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); - } - - /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools - /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it - /// within 1.5 levels, where whole-pixel boxes were 4 off. - #[test] - fn a_feathered_edge_is_a_gaussian_step() { - // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). - let erf = |x: f64| { - let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); - let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); - y.copysign(x) - }; - let (w, edge, sigma) = (240usize, 120usize, 6.0f32); - let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); - gaussian(&mut v, w, 1, sigma); - for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { - let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); - let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); - // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). - assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); - } - } - - #[test] - fn parallel_gaussian_matches_the_sequential_reference() { - let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); - let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = parallel.clone(); - let pass = BoxPass::for_sigma(sigma).unwrap(); - gaussian_sequential(&mut sequential, w, h, pass); - gaussian(&mut parallel, w, h, sigma); - assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); - } - - #[test] - #[ignore = "manual 50 MP sequential-vs-parallel mask blur measurement"] - fn benchmark_50mp_mask_blur() { - use std::time::Instant; - - let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); - let pass = BoxPass::for_sigma(sigma).unwrap(); - let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = source.clone(); - let start = Instant::now(); - gaussian_sequential(&mut sequential, w, h, pass); - let sequential_time = start.elapsed(); - let mut parallel = source; - let start = Instant::now(); - gaussian(&mut parallel, w, h, sigma); - let parallel_time = start.elapsed(); - assert_eq!(parallel, sequential); - eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); - } - #[test] fn cache_budget_holds_five_50mp_u8_input_u16_result_masks() { // Account for whole 256×256 tiles, as entry_bytes does, rather than just logical pixels. diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 9f0143f889..09d404669b 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -53,6 +53,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); depth.hash(&mut h); format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h); + (sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h); if let Some(c) = &sh.cache { format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h); for (k, t) in c.tiles() { @@ -163,6 +164,7 @@ mod tests { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } diff --git a/crates/compose/src/soft_shape_tests.rs b/crates/compose/src/soft_shape_tests.rs new file mode 100644 index 0000000000..d506c3e55b --- /dev/null +++ b/crates/compose/src/soft_shape_tests.rs @@ -0,0 +1,159 @@ +//! A shape layer's own Density and Feather (Properties › Masks: its path is its vector mask), +//! against Photoshop's measured pixels and against a fill layer with an equal vector mask. + +use super::*; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath, VectorMask}; +use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_geom::Size; + +/// Photoshop: new 200×200 white RGB document, a black 100×100 rectangle shape at 50..150 with a +/// 10 px vector-mask feather, row y = 100 at x = 30, 33, …, 72 (8-bit grey levels). +const PS_FEATHER_10: [u8; 15] = [249, 242, 232, 216, 195, 170, 142, 113, 85, 60, 39, 23, 13, 6, 2]; + +fn square() -> Path { + Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)])]) +} + +fn doc(depth: SampleType) -> Document { + Document::with_background("t", Size::new(200, 200), ColorMode::Rgb, depth, Color::WHITE) +} + +/// The document with a black square shape layer of `density` (0..1) and `feather` (px). +fn shape_doc(depth: SampleType, density: f32, feather: f32, stroke: Option) -> Document { + let mut d = doc(depth); + let mut sh = ShapeLayer { path: square(), fill: Some(Fill::Solid(Color::BLACK)), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Rectangle 1", LayerContent::Shape(sh))); + d +} + +/// The same appearance built as Photoshop stores it: a black fill layer with a vector mask. +fn fill_doc(depth: SampleType, density: f32, feather: f32) -> Document { + let mut d = doc(depth); + let mut l = Layer::new("Color Fill 1", LayerContent::Fill(Fill::Solid(Color::BLACK))); + l.vector_mask = Some(VectorMask { density, feather, ..VectorMask::new(square()) }); + d.layers.push(l); + d +} + +/// Row `y` of the composite as 8-bit grey levels. +fn row(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 255.0).round() as i32).collect() +} + +/// Row `y` of the composite in 16-bit levels. +fn row16(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 65535.0).round() as i32).collect() +} + +fn max_diff(a: &[i32], b: &[i32]) -> i32 { + a.iter().zip(b).map(|(x, y)| (x - y).abs()).max().unwrap_or(0) +} + +/// A 10 px feather is a symmetric ramp centred on the path, as Photoshop draws it. +#[test] +fn feather_matches_photoshops_ramp() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 1.0, 10.0, None), 100); + let ours: Vec = (0..15).map(|i| r[30 + 3 * i]).collect(); + let dev = ours.iter().zip(PS_FEATHER_10).map(|(a, b)| (a - i32::from(b)).abs()).max().unwrap_or(0); + // Same Gaussian (sigma = feather) as a vector mask: within 3 levels of Photoshop. + assert!(dev <= 3, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})"); + eprintln!("{depth:?}: ours {ours:?}, Photoshop {PS_FEATHER_10:?}, max deviation {dev}"); + // The path runs between pixels 49 and 50: their mean is half way. + assert!((r[49] + r[50] - 255).abs() <= 2, "{depth:?}: half way on the path: {} {}", r[49], r[50]); + assert_eq!(r[100], 0, "{depth:?}: the centre stays solid"); + // Symmetric about the square's centre. + assert!((0..200).all(|x| (r[x] - r[199 - x]).abs() <= 1), "{depth:?}: {r:?}"); + } +} + +/// Density 50 %: the fill shows at half strength beyond the path, full inside, hard edge. +#[test] +fn density_shows_the_fill_beyond_the_path() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 0.5, 0.0, None), 100); + for x in [0, 20, 49, 150, 199] { + assert!((127..=128).contains(&r[x]), "{depth:?}: x={x} outside the path: {}", r[x]); + } + for x in [50, 100, 149] { + assert_eq!(r[x], 0, "{depth:?}: x={x} inside the path"); + } + let top = row(&shape_doc(depth, 0.5, 0.0, None), 0); + assert!(top.iter().all(|v| (127..=128).contains(v)), "{depth:?}: everywhere beyond the path"); + } +} + +/// A shape and a fill layer with an equal vector mask render the same pixels. (Density 50 % puts +/// white × 0.5 = 127.5 exactly on a rounding tie, which the shape's stored pixels and the fill's +/// mask break differently: 127 and 128, both what Photoshop's "127/128" allows.) +#[test] +fn soft_shape_matches_an_equal_vector_mask() { + for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0)] { + for depth in [SampleType::U8, SampleType::U16] { + let (s, f) = (shape_doc(depth, density, feather, None), fill_doc(depth, density, feather)); + for y in [0, 45, 50, 100, 151, 199] { + // An 8-bit shape keeps its rendering in 8-bit pixels (the fill layer's mask is + // 16-bit), which can round its alpha one level apart, as at any anti-aliased edge. + let d8 = max_diff(&row(&s, y), &row(&f, y)); + assert!(d8 <= i32::from(depth == SampleType::U8), "{depth:?} density {density} feather {feather}, row {y}: {d8} levels"); + // The compositor keeps masks at 16 bits (an 8-bit shape's pixels are 8-bit). + if depth == SampleType::U16 { + let d16 = max_diff(&row16(&s, y), &row16(&f, y)); + assert!(d16 <= 2, "{depth:?} density {density} feather {feather}, row {y}: {d16} 16-bit levels"); + } + } + } + } +} + +/// Density and feather soften the fill only: the vector stroke keeps its crisp edge. +#[test] +fn soft_shape_keeps_its_stroke_crisp() { + let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() }; + let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke)); + let px = render(&d, Rect::new(0, 100, 200, 101)).px; + // Centred 4 px stroke over x = 48..52: opaque red. + for x in [48, 49, 50, 51] { + let p = px[x]; + assert!(p[0] > 0.99 && p[1] < 0.01 && p[2] < 0.01, "x={x}: {p:?}"); + } + // Beyond the stroke the feathered fill continues: at 10 px out, 50 % density plus half of + // the feather's 16 % ≈ 58 % black. + assert!((px[40][1] - 0.42).abs() < 0.03, "{:?}", px[40]); +} + +/// Defaults (density 100 %, no feather) leave a shape exactly as before. +#[test] +fn default_shape_is_unchanged() { + let d = shape_doc(SampleType::U8, 1.0, 0.0, None); + let r = row(&d, 100); + assert_eq!(r[49], 255); + assert_eq!(r[50], 0); + assert_eq!(r[149], 0); + assert_eq!(r[150], 255); + assert!(row(&d, 10).iter().all(|v| *v == 255)); +} + +/// File-provided values outside the range never panic and render as the nearest valid ones. +#[test] +fn hostile_values_render() { + for (density, feather) in [(f32::NAN, f32::NAN), (-1.0, -5.0), (7.0, 1.0e9), (f32::INFINITY, f32::NEG_INFINITY)] { + let d = shape_doc(SampleType::U8, density, feather, None); + let r = row(&d, 100); + assert_eq!(r.len(), 200, "{density} {feather}"); + } +} + +/// The layer's bounds reach as far as its soft fill. +#[test] +fn soft_shape_bounds_cover_the_fill() { + let canvas = Rect::new(0, 0, 200, 200); + let feathered = shape_doc(SampleType::U8, 1.0, 10.0, None); + let b = layer_bounds(&feathered.layers[1], canvas); + assert!(b.x0 <= 50 - 30 && b.x1 >= 150 + 30, "feather reach: {b:?}"); + let dense = shape_doc(SampleType::U8, 0.5, 0.0, None); + assert_eq!(layer_bounds(&dense.layers[1], canvas), canvas); + assert!(effect_outline(&dense.layers[1]).is_none(), "a soft fill's alpha is its outline"); +} diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index ab265fa208..e2af6f03b3 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -31,7 +31,7 @@ fn blue() -> FxPaint { /// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels. fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option) -> Layer { let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40))); Layer::new("shape", LayerContent::Shape(sh)) } @@ -45,6 +45,7 @@ fn gradient_shape(width: u32, height: u32) -> ShapeLayer { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } @@ -234,7 +235,15 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() { let mut cache = photocraft_raster::Surface::new(fmt); cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]); cache.fill_rect(Rect::new(11, 11, 29, 29), &[fill, fill, fill, 1.0]); - let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None }; + let sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), + stroke: Some(vs), + live: None, + cache: Some(cache), + psd_raw: None, + ..ShapeLayer::default() + }; let mut l = Layer::new("shape", LayerContent::Shape(sh)); // Any layer effect sends the shape through the fill/stroke split. l.effects.items = vec![Effect::default_drop_shadow()]; diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..a2ec151f69 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -316,7 +316,7 @@ pub enum LiveShape { } /// A shape layer: a filled and/or stroked path. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct ShapeLayer { pub path: Path, /// `None` = no fill (Photoshop's fill "none"). @@ -328,6 +328,25 @@ pub struct ShapeLayer { /// Data of the PSD vector mask block (`vsms`, else `vmsk`). On export it replaces that entry /// in [`crate::Layer::psd_blocks`] while it still decodes to `path`. pub psd_raw: Option>>, + /// Density `0..=1` of the path as the fill's mask (Properties › Masks, like + /// [`VectorMask::density`]): below 1 the fill shows beyond the path at `1 - density`. + pub density: f32, + /// Feather radius in px of the path as the fill's mask (like [`VectorMask::feather`]). + pub feather: f32, +} + +impl Default for ShapeLayer { + fn default() -> Self { + ShapeLayer { path: Path::default(), fill: None, stroke: None, live: None, cache: None, psd_raw: None, density: 1.0, feather: 0.0 } + } +} + +impl ShapeLayer { + /// Whether the fill's edge is softened by a density below 100 % or a feather (non-finite + /// values count as the defaults). + pub fn has_soft_mask(&self) -> bool { + (self.density.is_finite() && self.density < 1.0) || (self.feather.is_finite() && self.feather > 0.0) + } } #[cfg(test)] diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..9bc17d43e8 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1369,11 +1369,15 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { fn vector_mask_info(s: &Session, id: LayerId) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - Ok(match &l.vector_mask { - Some(v) => { + Ok(match (&l.vector_mask, &l.content) { + (Some(v), _) => { json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) } - None => json!({ "layer": id.0, "vectorMask": null }), + // A shape layer's path is its vector mask. + (None, LayerContent::Shape(sh)) => { + json!({ "layer": id.0, "path": path_json(&sh.path), "enabled": true, "linked": true, "shape": true, "density": sh.density * 100.0, "feather": sh.feather }) + } + (None, _) => json!({ "layer": id.0, "vectorMask": null }), }) } @@ -1425,6 +1429,35 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) }) .transpose()?; + let density = p + .get("density") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=100.0).contains(n)) + .map(|n| (n / 100.0) as f32) + .ok_or_else(|| bad(CMD, "`density` must be a finite number within 0..100 (%)")) + }) + .transpose()?; + // A shape layer's path is its vector mask (Photoshop's Properties › Masks): Density and + // Feather soften its fill; the path itself is edited with the shape and path commands. + if s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)) && l.vector_mask.is_none()) { + if let Some(k) = ["path", "enabled", "linked", "invert"].into_iter().find(|k| p.get(*k).is_some()) { + return Err(bad(CMD, format!("`{k}`: a shape layer's vector mask is its path; only `density` and `feather` apply"))); + } + if density.is_none() && feather.is_none() { + return vector_mask_info(s, id); + } + with_shape(s, id, "Edit Vector Mask", |sh, _| { + if let Some(d) = density { + sh.density = d; + } + if let Some(f) = feather { + sh.feather = f; + } + Ok(()) + })?; + return vector_mask_info(s, id); + } s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1437,8 +1470,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(b) = p.get("linked").and_then(Value::as_bool) { vm.linked = b; } - if let Some(d) = f64p(p, "density") { - vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; + if let Some(d) = density { + vm.density = d; } if let Some(f) = feather { vm.feather = f; @@ -1832,7 +1865,7 @@ pub fn specs() -> Vec { "Edit Vector Mask", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. On a shape layer (whose path is its vector mask) only density and feather apply: they soften its fill, which shows beyond the path at 100 − density %. {PATH_FORM}"## )), has_layer, vector_mask_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..a2e3f178f7 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -746,6 +746,71 @@ fn vector_mask_edit_rejects_out_of_range_feather() { assert_eq!(feather(&s), 1000.0); } +/// #5846: a shape layer's path is its vector mask; `layer.vectorMask.edit` sets its Density and +/// Feather in one undoable step, re-rendering the fill, which shows beyond the path at 100 − +/// density %. Hostile values are refused before any edit. +#[test] +fn shape_density_and_feather_through_vector_mask_edit() { + for depth in [8, 16, 32] { + let mut s = session(200, 200, depth); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [50, 50, 100, 100], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let info = s.execute("layer.vectorMask.info", json!({})).unwrap(); + assert_eq!((info["shape"].clone(), info["density"].clone(), info["feather"].clone()), (json!(true), json!(100.0), json!(0.0)), "{depth}"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("layer.vectorMask.edit", json!({"density": 50})).unwrap(); + assert_eq!(r["density"], 50.0); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (0.5, 0.0)); + // The shape's own pixels carry the soft fill (half-transparent black beyond the path). + let cache = sh.cache.as_ref().unwrap(); + assert!((cache.rgba(10, 10)[3] - 0.5).abs() < 0.01, "{depth}: {:?}", cache.rgba(10, 10)); + assert!(cache.rgba(100, 100)[3] > 0.99); + s.execute("layer.vectorMask.edit", json!({"feather": 10, "density": 100})).unwrap(); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (1.0, 10.0)); + let a = |x| sh.cache.as_ref().unwrap().rgba(x, 100)[3]; + assert!(a(30) > 0.0 && a(30) < 0.05 && (a(49) + a(50) - 1.0).abs() < 0.02 && a(70) > 0.95, "{depth}: {} {} {} {}", a(30), a(49), a(50), a(70)); + // Fill and stroke stay editable: the shape is still a shape. + s.execute("shape.edit", json!({"fill": "#ff0000"})).unwrap(); + assert_eq!(shape(&s, id).feather, 10.0, "shape.edit keeps the feather"); + s.undo(); + s.undo(); + assert_eq!((shape(&s, id).density, shape(&s, id).feather), (0.5, 0.0), "{depth}: undo restores"); + } +} + +#[test] +fn shape_vector_mask_edit_fails_gracefully() { + let mut s = session(64, 64, 8); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [8, 8, 30, 30]})).unwrap()["layer"].as_u64().unwrap(); + let before = doc(&s).clone(); + let steps = s.active().unwrap().history.past_len(); + let bad = [ + json!({"density": -1}), + json!({"density": 100.5}), + json!({"density": 1e39}), + json!({"density": "50"}), + json!({"density": null}), + json!({"feather": -0.5}), + json!({"feather": 1001}), + json!({"feather": [1]}), + json!({"feather": 5, "path": {"subpaths": []}}), + json!({"density": 50, "enabled": false}), + json!({"invert": true}), + json!({"layer": u64::MAX, "density": 50}), + ]; + for p in bad { + assert!(s.execute("layer.vectorMask.edit", p.clone()).is_err(), "{p}"); + assert_eq!(*doc(&s), before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } + // A locked shape refuses it too. + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + assert!(s.execute("layer.vectorMask.edit", json!({"layer": id, "density": 50})).is_err()); + assert_eq!(shape(&s, id).density, 1.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 95d45fafb9..3f21e7e47c 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -152,6 +152,8 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { path: s.path.clone(), stroke: s.stroke.clone(), live: s.live.clone(), + density: s.density, + feather: s.feather, }, LayerContent::Smart(s) => ContentM::Smart { contents_id: Some(s.contents_id.0), @@ -466,13 +468,15 @@ impl Loader<'_> { warp: warp.clone(), }) } - ContentM::Shape { fill, cache, psd_raw, path, stroke, live } => LayerContent::Shape(ShapeLayer { + ContentM::Shape { fill, cache, psd_raw, path, stroke, live, density, feather } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), live: live.clone(), cache: self.opt_surface(cache)?, psd_raw: self.opt_blob(psd_raw)?, + density: *density, + feather: *feather, }), ContentM::Smart { contents_id, source, transform, smart_filters, cache, psd_raw, filters_enabled, filter_mask, warp, stack_mode, perspective } => { LayerContent::Smart(SmartObject { diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 82480e05c0..8cb31f653e 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -294,6 +294,12 @@ pub enum ContentM { stroke: Option, #[serde(default)] live: Option, + /// The path's density as the fill's mask; older files have none (100 %). + #[serde(default = "full_density")] + density: f32, + /// The path's feather in px; older files have none. + #[serde(default)] + feather: f32, }, Smart { /// Shared source identity; older bundles did not record it. @@ -318,6 +324,10 @@ pub enum ContentM { }, } +fn full_density() -> f32 { + 1.0 +} + fn yes() -> bool { true } @@ -385,3 +395,16 @@ mod channel_tests { assert_eq!(c.indicates, photocraft_doc::ColorIndicates::MaskedAreas); } } + +#[cfg(test)] +mod shape_tests { + use super::*; + + /// Shapes saved before Density and Feather load at 100 % and no feather. + #[test] + fn older_shapes_load_at_full_density_without_feather() { + let old = serde_json::json!({ "kind": "shape", "fill": null, "cache": null, "psd_raw": null }); + let ContentM::Shape { density, feather, .. } = serde_json::from_value(old).unwrap() else { panic!("a shape") }; + assert_eq!((density, feather), (1.0, 0.0)); + } +} diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index 8d5641d9cd..0c91e33e32 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -154,6 +154,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { live: Some(LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [4.0, 0.0, 4.0, 0.0] }), cache: Some(cache.clone()), psd_raw: Some(blob(10, 30)), + density: 0.6, + feather: 3.5, }), ); shape.label = LabelColor::Green; diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..b8a19fa0e1 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -488,6 +488,11 @@ fn vector_fields_roundtrip_and_default_when_absent() { for l in layers.as_array_mut().unwrap() { l.as_object_mut().unwrap().remove("vector_mask"); let c = l["content"].as_object_mut().unwrap(); + // A shape's density and feather (#5846) came later still. + if c.get("kind").and_then(|k| k.as_str()) == Some("shape") { + c.remove("density"); + c.remove("feather"); + } for k in ["path", "stroke", "live"] { c.remove(k); } @@ -500,6 +505,15 @@ fn vector_fields_roundtrip_and_default_when_absent() { let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); assert!(old.paths.is_empty() && old.work_path.is_none()); assert!(old.layers.iter().all(|l| l.vector_mask.is_none())); + let shapes: Vec<_> = old + .layers + .iter() + .filter_map(|l| match &l.content { + photocraft_format::manifest::ContentM::Shape { density, feather, .. } => Some((*density, *feather)), + _ => None, + }) + .collect(); + assert!(!shapes.is_empty() && shapes.iter().all(|s| *s == (1.0, 0.0)), "{shapes:?}"); } /// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..7099dce856 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1758,7 +1758,7 @@ fn layer_effects_on_shape_layers() { let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); let mut fill = Color::rgb(0.3, 0.6, 0.9); fill.alpha = alpha; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.effects.items = vec![ @@ -1796,7 +1796,7 @@ fn stroke_effects_on_filled_and_stroked_shapes() { paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); if masked { @@ -2151,7 +2151,15 @@ fn stroked_shapes_with_clipped_layers() { d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3)); let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]); let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() }; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), + stroke: Some(stroke), + live: None, + cache: None, + psd_raw: None, + ..ShapeLayer::default() + }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.blend = blend; @@ -2618,3 +2626,36 @@ fn pass_through_clipping_base_with_an_adjustment() { d.layers.push(clip); check(&mut g, &d, "pass-through clipping base with an adjustment"); } + +/// #5846: a shape's Density and Feather soften its fill (its own rendered pixels), with and +/// without a vector stroke over clipped layers and layer effects: the GPU matches the CPU. +#[test] +fn soft_shapes_match_the_cpu() { + let Some(mut g) = gpu() else { return }; + use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (density, feather, stroked, fx) in [(1.0, 6.0, false, false), (0.4, 0.0, true, false), (0.6, 4.0, true, true), (1.0, 3.0, false, true)] { + let mut d = fx_doc(90, 70, depth); + let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); + let stroke = stroked.then(|| ShapeStroke { width: 3.0, paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + let mut l = Layer::new("shape", LayerContent::Shape(sh)); + if fx { + l.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0))]; + } + d.layers.push(l); + if stroked { + let mut c = noise_layer("clipped", d.pixel_format(), Rect::from_xywh(0, 0, 90, 70), 11, 0.9); + c.clipped = true; + d.layers.push(c); + } + let what = format!("{depth:?} density {density} feather {feather} stroked {stroked} fx {fx}"); + if fx { + fx_check(&mut g, &d, &what); + } else { + check(&mut g, &d, &what); + } + } + } +} diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..639fc18fee 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -424,7 +424,7 @@ impl Builder { dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32), }) }); - let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); sh } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4fb952ba25..16b3ea0e3f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -431,7 +431,11 @@ impl Ex { self.warnings.extend(warnings); let mut mask = MaskData::None; // Vector mask density/feather live in the mask parameters, with or without a user mask. - let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); + // A shape layer's path is its vector mask, with the shape's own density and feather. + let vector = match &l.content { + LayerContent::Shape(sh) if l.vector_mask.is_none() => Some((sh.density, sh.feather)), + _ => l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)), + }; if let Some(m) = &l.mask { let (md, ch) = self.mask(m, vector); mask = md; @@ -1789,7 +1793,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c /// every value is the default (density 1, feather 0). fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option { let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d))); - let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f))); + let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f.is_finite() && f > 0.0).then_some(f64::from(f))); let p = MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) }; let flags = u8::from(p.user_density.is_some()) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 94c4ea9932..ddf12edf7d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -88,9 +88,19 @@ fn selected_real_mask(rec: &LayerRecord) -> Option { rec.layer_mask()?.real } +/// The (density, feather) a shape layer's mask parameters give its path, which is its vector +/// mask: (1, 0) without them. +fn shape_mask_params(rec: &LayerRecord) -> (f32, f32) { + let p = rec.layer_mask().and_then(|m| m.parameters); + let density = p.and_then(|p| p.vector_density).map_or(1.0, |d| f32::from(d) / 255.0); + let feather = p.and_then(|p| p.vector_feather).filter(|f| f.is_finite()).map_or(0.0, |f| (f as f32).clamp(0.0, 1000.0)); + (density, feather) +} + /// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer -/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a -/// feather (`.1` true), or when it fills with a pattern. +/// with a vector mask: when it fills with a pattern, which the shape renderer can't draw (`.1` +/// true when its vector mask is also soft: a density below 100 % or a feather). Other soft +/// shapes stay shapes with that density and feather. fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<(photocraft_doc::Fill, bool)> { if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() { return None; @@ -104,7 +114,7 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 // (our own export writes fill layers so, #1907): it is a pattern fill layer seen through // its vector mask. let pattern = matches!(fill, photocraft_doc::Fill::Pattern { .. }); - (soft || pattern).then_some((fill, soft)) + pattern.then_some((fill, soft)) } /// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. @@ -417,10 +427,9 @@ impl Ctx<'_> { perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { - // A shape whose vector mask has a density or feather is a fill layer seen through a - // soft vector mask: the fill shows beyond the path, which the stored pixels (the - // shape alone) lack. Import it as exactly that. A pattern fill keeps the stored - // pixels as its rendering (a soft mask's pixels lack the fill beyond the path). + // A pattern-filled shape is a pattern fill layer seen through its vector mask (soft + // or not). Unless that mask is soft, the stored pixels stay its rendering (a soft + // mask's pixels lack the fill beyond the path). if !soft && matches!(f, photocraft_doc::Fill::Pattern { .. }) && !rec.rect.is_empty() { fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) }); } @@ -430,9 +439,13 @@ impl Ctx<'_> { let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() }; let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone()); crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi); + (sh.density, sh.feather) = shape_mask_params(rec); // No stored pixels (32-bit documents, files saved without layer pixels): render the - // shape from its path and fill, as Photoshop does when it opens the file. - if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) { + // shape from its path and fill, as Photoshop does when it opens the file. A soft + // shape's stored pixels hold the shape alone, without the fill its density and feather + // show beyond the path: render it too. + let soft = sh.has_soft_mask() && sh.fill.is_some(); + if soft || ((rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted)) { let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32); sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas)); } diff --git a/crates/io/tests/corpus_regressions.rs b/crates/io/tests/corpus_regressions.rs index 0555fc47ca..b335642cc9 100644 --- a/crates/io/tests/corpus_regressions.rs +++ b/crates/io/tests/corpus_regressions.rs @@ -116,9 +116,10 @@ fn real_user_mask_with_parameters_is_decoded() { /// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose /// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the -/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask. +/// stored pixels hold only the shape. It imports as a shape with that density (#5846), rendered +/// from its path rather than from the stored pixels. #[test] -fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { +fn shape_with_vector_mask_density_imports_as_soft_shape() { let mut d = doc(SampleType::U8); let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK))); let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0)); @@ -138,8 +139,9 @@ fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { } let imp = reimport(&file); let l = &imp.document.layers[1]; - assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name()); - assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0)); + let LayerContent::Shape(sh) = &l.content else { panic!("{}", l.content.kind_name()) }; + assert_eq!(sh.density, 204.0 / 255.0); + assert!(l.vector_mask.is_none()); let f = photocraft_compose::flatten(&imp.document); assert!(f.get(16, 16)[0] < 0.01, "inside the path: black"); assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2)); diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/effects_multi.rs index 4eda6ad81b..81db8aa12f 100644 --- a/crates/io/tests/effects_multi.rs +++ b/crates/io/tests/effects_multi.rs @@ -65,7 +65,8 @@ fn joined_shape_components_roundtrip() { let outer = Subpath::polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]); let hole = Subpath::polygon(&[(12.0, 12.0), (12.0, 28.0), (28.0, 28.0), (28.0, 12.0)]).with_op(PathOp::Join); let path = Path::new(vec![outer, hole]); - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = + ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, d.bounds())); let mut l = Layer::new("ring", LayerContent::Shape(sh)); l.effects.items = vec![stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), 1.0)]; diff --git a/crates/io/tests/vector.rs b/crates/io/tests/vector.rs index 594a1827e8..e32cbed3fa 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/vector.rs @@ -171,6 +171,51 @@ fn custom_stroke_styles_roundtrip_through_psd_vstk() { } } +/// A shape layer's Density and Feather (#5846) live in the PSD mask parameters, as Photoshop +/// writes a shape's vector-mask density and feather; with and without a vector stroke, at 8 and +/// 16 bits, the shape comes back a shape with both and renders its soft fill again. +#[test] +fn shape_density_and_feather_roundtrip_through_mask_parameters() { + let square = Path::new(vec![Subpath::polygon(&[(64.0, 64.0), (192.0, 64.0), (192.0, 192.0), (64.0, 192.0)])]); + for depth in [SampleType::U8, SampleType::U16] { + for stroke in [None, Some(ShapeStroke { width: 2.0, ..ShapeStroke::default() })] { + let mut d = Document::with_background("v", Size::new(256, 256), ColorMode::Rgb, depth, Color::WHITE); + let mut sh = ShapeLayer { + path: square.clone(), + fill: Some(Fill::Solid(Color::BLACK)), + stroke: stroke.clone(), + density: 0.5, + feather: 6.0, + ..ShapeLayer::default() + }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Shape", LayerContent::Shape(sh))); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let rec = file.layers().last().unwrap(); + let p = rec.layer_mask().and_then(|m| m.parameters).expect("mask parameters"); + assert_eq!((p.vector_density, p.vector_feather), (Some(128), Some(6.0)), "{depth:?}"); + assert!(rec.channel(-2).is_none(), "no user mask channel"); + let b = import("x.psd", &bytes).unwrap().document; + let LayerContent::Shape(back) = &b.layers[1].content else { panic!("{depth:?} {stroke:?}: {}", b.layers[1].content.kind_name()) }; + assert_eq!((back.density, back.feather), (128.0 / 255.0, 6.0), "{depth:?} {stroke:?}"); + assert_eq!(back.stroke, stroke); + assert!(b.layers[1].mask.is_none() && b.layers[1].vector_mask.is_none()); + let f = photocraft_compose::flatten(&b); + assert!(f.get(128, 128)[0] < 0.01, "inside: black"); + assert!((f.get(4, 4)[0] - 127.0 / 255.0).abs() < 0.01, "{depth:?}: beyond the path at half density: {:?}", f.get(4, 4)); + } + } + // Defaults write no parameters. + let mut d = doc(); + d.layers.push(shape_layer(ShapeLayer { path: square, fill: Some(Fill::Solid(Color::BLACK)), ..ShapeLayer::default() })); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + assert!(file.layers().last().unwrap().layer_mask().and_then(|m| m.parameters).is_none()); + let LayerContent::Shape(back) = &import("x.psd", &bytes).unwrap().document.layers[1].content else { panic!("a shape") }; + assert_eq!((back.density, back.feather), (1.0, 0.0)); +} + #[test] fn vector_mask_roundtrip_and_removal() { let mut d = doc(); diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index c2ea848c5e..96899a81de 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -177,7 +177,10 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } match &layer.content { LayerContent::Text(_) => crate::type_tool::type_properties(app, ui), - LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id), + LayerContent::Shape(_) => { + crate::vector_ui::shape_properties(app, ui, layer.id); + crate::mask_props_ui::shape_properties(app, ui, layer); + } _ => {} } // Re-read: a section above may have changed the layer (e.g. point to paragraph type). diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 295310bf97..c2674b138e 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -1,6 +1,6 @@ //! Non-destructive layer-mask controls (pixel and vector), shared by docked and floating Properties. -use photocraft_doc::Layer; +use photocraft_doc::{Layer, LayerContent}; use serde_json::json; use crate::props_layout::COL_GAP; @@ -49,6 +49,23 @@ pub fn vector_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Lay }); } +/// A shape layer's path is its vector mask (Photoshop's Properties › Masks): its Density and +/// Feather soften the fill through `layer.vectorMask.edit`, the shape staying editable. +pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { + let LayerContent::Shape(sh) = &layer.content else { return }; + if layer.vector_mask.is_some() { + return; + } + let Some(document) = app.session.active().map(|s| s.doc.id.0) else { return }; + ui.push_id((document, layer.id, "shape-mask-properties"), |ui| { + if !crate::props_layout::section(ui, "shape-mask", "Vector Mask") { + return; + } + let (density, feather) = (sh.density, sh.feather); + sliders(app, ui, layer.id.0, document, "layer.vectorMask.edit", "shape-mask", density, feather); + }); +} + /// Density (%) and Feather (px) sliders writing one undoable edit per drag: the value lives on the /// mask, not in its pixels. `command` is the mask's edit command and `prefix` keys its coalescing. #[allow(clippy::too_many_arguments)] diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1cde904682..eacafafa23 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -1,5 +1,8 @@ use super::*; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; fn harness(adjustment: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { let mut session = photocraft_engine::Session::new(); @@ -150,6 +153,59 @@ fn vector_mask_properties_edit_density_and_feather() { assert!((vm.density - 0.5).abs() < 0.02, "density edited through the vector mask command: {}", vm.density); } +/// #5846: a selected shape layer shows its path's Density and Feather (Photoshop's Properties › +/// Masks), in both themes; a drag previews live and leaves one undo step per gesture, and the +/// shape stays a shape. +#[test] +fn shape_properties_edit_density_and_feather() { + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 16, 32, 32]})).unwrap()["layer"].as_u64().unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 1600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 1600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + if Tokens::get(ui.ctx()).pro { + egui::ScrollArea::vertical().show(ui, |ui| properties_body(app, ui)); + } else { + properties_window(app, ui.ctx()); + } + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let shape = |h: &Harness<'_, PhotocraftApp>| match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape: {}", other.kind_name()), + }; + let slider = |h: &Harness<'_, PhotocraftApp>, label: &str| { + h.query_all_by_role(egui::accesskit::Role::Slider) + .position(|n| n.accesskit_node().label().as_deref() == Some(label)) + .unwrap_or_else(|| panic!("{theme:?}: no {label} slider")) + }; + let (density, feather) = (slider(&h, "Density"), slider(&h, "Feather")); + let before = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, density, &[1.0, 0.8, 0.6, 0.5]); + assert!((shape(&h).density - 0.5).abs() < 0.02, "{theme:?}: density {}", shape(&h).density); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 1, "{theme:?}: one step per drag"); + // Live: the fill already shows beyond the path at half strength. + let a = shape(&h).cache.as_ref().unwrap().rgba(2, 2)[3]; + assert!((a - 0.5).abs() < 0.03, "{theme:?}: {a}"); + drag(&mut h, feather, &[0.0, 0.005, 0.01]); + assert!((shape(&h).feather - 10.0).abs() < 0.5, "{theme:?}: feather {}", shape(&h).feather); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).feather, 0.0); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).density, 1.0, "{theme:?}"); + } +} + /// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 769f5a8b9a..4df4441831 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3506,6 +3506,7 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if matches!(layer.content, LayerContent::Shape(_)) { crate::vector_ui::shape_properties(app, ui, id); + crate::mask_props_ui::shape_properties(app, ui, layer); } } } diff --git a/crates/vector/src/feather.rs b/crates/vector/src/feather.rs new file mode 100644 index 0000000000..5048916d49 --- /dev/null +++ b/crates/vector/src/feather.rs @@ -0,0 +1,281 @@ +//! Mask feathering: the Gaussian blur Properties › Feather applies to a mask (pixel masks, vector +//! masks and a shape layer's own path, so equal feathers match exactly). Pure `f32` planes; the +//! compositor (`photocraft-compose::masks`) and the shape renderer share it. + +use photocraft_doc::Path; +use photocraft_geom::Rect; + +/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. +/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector +/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's +/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). +pub fn feather_sigma(px: f32) -> f32 { + if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } +} + +const FEATHER_SIGMA: f32 = 1.0; +/// Photoshop's feather tops out at 1000 px. +const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA; + +/// `r` grown by a feather of Gaussian `sigma`: how far the blur carries a mask's edge (three +/// sigmas and two pixels, beyond which it is constant to well under a level). +pub fn grow(r: Rect, sigma: f32) -> Rect { + let m = (sigma * 3.0).ceil() as i32 + 2; + if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) } +} + +/// Where a feathered path mask can differ from its value far outside the path: the path's control +/// bounds (and two pixels of anti-aliasing) grown by the feather. Empty for a path without knots. +/// A vector mask and a soft shape layer use the same area, so equal settings match exactly. +pub fn path_area(path: &Path, sigma: f32) -> Rect { + match path.control_bounds() { + Some((x0, y0, x1, y1)) => grow( + Rect::new( + (x0.floor() as i32).saturating_sub(2), + (y0.floor() as i32).saturating_sub(2), + (x1.ceil() as i32).saturating_add(2), + (y1.ceil() as i32).saturating_add(2), + ), + sigma, + ), + None => Rect::EMPTY, + } +} + +/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just +/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct BoxPass { + r: usize, + end: f32, +} + +impl BoxPass { + /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et + /// al. 2011), or `None` when there is nothing to blur. + fn for_sigma(sigma: f32) -> Option { + if !sigma.is_finite() || sigma <= 0.0 { + return None; + } + // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. + let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; + // The largest whole radius whose box variance r(r+1)/3 is at most v. + let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; + let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; + while r > 0 && var(r) > v { + r -= 1; + } + while var(r + 1) <= v { + r += 1; + } + let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); + let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); + Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) + } +} + +/// Apply one of the three box passes to a single line. `scratch` preserves the input while +/// the sliding sum writes the output, matching the previous sequential implementation exactly. +fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { + if line.is_empty() { + return; + } + let Ok(radius) = isize::try_from(pass.r) else { return }; + scratch.copy_from_slice(line); + let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; + let mut acc: f32 = (-radius..=radius).map(get).sum(); + let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; + // The sample leaving the window is the next one's low end weight. + let mut below = get(-radius - 1); + for (i, dst) in line.iter_mut().enumerate() { + let i = i as isize; + let (above, leaving) = (get(i + radius + 1), get(i - radius)); + *dst = (acc + pass.end * (below + above)) / n; + acc += above - leaving; + below = leaving; + } +} + +/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used +/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon +/// work here can deadlock when every worker waits on the same OnceLock (#276). +fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { + let mut scratch = vec![0.0; w]; + for row in v.chunks_mut(w) { + for _ in 0..3 { + blur_line(row, &mut scratch, pass); + } + } +} + +fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { + if w == 0 || v.is_empty() { + return; + } + let h = v.len() / w; + let threads = if cfg!(target_arch = "wasm32") || v.len() < 1 << 16 { + 1 + } else { + std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) + }; + if threads == 1 { + blur_rows_chunk(v, w, pass); + return; + } + let rows_per_worker = h.div_ceil(threads); + let chunk_len = rows_per_worker.saturating_mul(w); + std::thread::scope(|scope| { + for chunk in v.chunks_mut(chunk_len) { + scope.spawn(move || blur_rows_chunk(chunk, w, pass)); + } + }); +} + +/// Cache-friendly blocked transpose. It makes the vertical blur contiguous without a Rayon job +/// or unsafe disjoint-column access; the temporary is released before the next mask is built. +fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { + const BLOCK: usize = 32; + for by in (0..h).step_by(BLOCK) { + for bx in (0..w).step_by(BLOCK) { + for y in by..(by + BLOCK).min(h) { + for x in bx..(bx + BLOCK).min(w) { + out[x * h + y] = v[y * w + x]; + } + } + } + } +} + +/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is +/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into +/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like +/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels +/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes +/// are processed in parallel on bounded OS threads, preserving each line's floating-point +/// operation order. +pub fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { + if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { + return; + } + let Some(pass) = BoxPass::for_sigma(sigma) else { return }; + // Reserve before modifying the input so allocation failure can use the sequential fallback + // without leaving a half-blurred mask. + let mut transposed = Vec::new(); + if transposed.try_reserve_exact(v.len()).is_err() { + gaussian_sequential(v, w, h, pass); + return; + } + transposed.resize(v.len(), 0.0); + blur_rows(v, w, pass); + transpose_into(v, &mut transposed, w, h); + blur_rows(&mut transposed, h, pass); + transpose_into(&transposed, v, h, w); +} + +/// Allocation-light reference path used if parallel transpose storage cannot be reserved. +fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { + let mut line = Vec::new(); + let mut scratch = Vec::new(); + let len = w.max(h); + if line.try_reserve_exact(len).is_err() || scratch.try_reserve_exact(len).is_err() { + return; + } + line.resize(len, 0.0); + scratch.resize(len, 0.0); + for axis in [false, true] { + let (lines, line_len) = if axis { (w, h) } else { (h, w) }; + for k in 0..lines { + for i in 0..line_len { + line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; + } + for _ in 0..3 { + blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); + } + for i in 0..line_len { + if axis { + v[i * w + k] = line[i]; + } else { + v[k * w + i] = line[i]; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole + /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). + #[test] + fn feather_blur_has_the_exact_variance() { + for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { + let (w, c) = (801usize, 400usize); + let mut v = vec![0.0f32; w]; + v[c] = 1.0; + gaussian(&mut v, w, 1, sigma); + // Within the kernel's reach (the running sums leave ~1e-8 behind further out). + let reach = (8.0 * sigma) as usize + 4; + let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); + assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); + assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); + } + assert_eq!(BoxPass::for_sigma(0.0), None); + assert_eq!(BoxPass::for_sigma(f32::NAN), None); + assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); + } + + /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools + /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it + /// within 1.5 levels, where whole-pixel boxes were 4 off. + #[test] + fn a_feathered_edge_is_a_gaussian_step() { + // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). + let erf = |x: f64| { + let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); + let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); + y.copysign(x) + }; + let (w, edge, sigma) = (240usize, 120usize, 6.0f32); + let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); + gaussian(&mut v, w, 1, sigma); + for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { + let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); + let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); + // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). + assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); + } + } + + #[test] + fn parallel_gaussian_matches_the_sequential_reference() { + let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); + let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let mut sequential = parallel.clone(); + let pass = BoxPass::for_sigma(sigma).unwrap(); + gaussian_sequential(&mut sequential, w, h, pass); + gaussian(&mut parallel, w, h, sigma); + assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); + } + + #[test] + #[ignore = "manual 50 MP sequential-vs-parallel mask blur measurement"] + fn benchmark_50mp_mask_blur() { + use std::time::Instant; + + let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); + let pass = BoxPass::for_sigma(sigma).unwrap(); + let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let mut sequential = source.clone(); + let start = Instant::now(); + gaussian_sequential(&mut sequential, w, h, pass); + let sequential_time = start.elapsed(); + let mut parallel = source; + let start = Instant::now(); + gaussian(&mut parallel, w, h, sigma); + let parallel_time = start.elapsed(); + assert_eq!(parallel, sequential); + eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); + } +} diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d336435492..ac5fe8aad5 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -5,6 +5,7 @@ //! mask back into a path for "Make Work Path"). //! //! * [`edit`]: point-level path editing (Direct Selection, Convert Point) and hit testing. +//! * [`feather`]: the Gaussian blur of a feathered mask (vector masks, pixel masks, soft shapes). //! * [`flatten`]: cubic Bézier flattening with a distance tolerance. //! * [`map`]: non-affine maps of paths (Distort, Perspective, Warp). //! * [`raster`]: exact area-coverage scanline rasterizer (non-zero / even-odd per component, @@ -20,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod edit; +pub mod feather; pub mod flatten; pub mod map; pub mod raster; @@ -458,9 +460,101 @@ fn dither(c: &mut [f32; 4], x: i32, y: i32) { } } +/// How much of a shape's fill shows at each pixel: the path's coverage, or (with a density or +/// feather) the path as the fill's soft vector mask. +enum FillCoverage { + Path(Rasterizer), + /// Density without feather: `1 - density × (1 - coverage)`. + Dense(Rasterizer, f32), + /// Feathered (and maybe less dense): the mask over `area`, `outside` everywhere else. + Plane { + area: Rect, + values: Vec, + outside: f32, + }, +} + +impl FillCoverage { + /// Builds the fill's coverage for `shape` over the frame `canvas` (a feather is blurred + /// within it, as the compositor blurs a vector mask within the canvas). + fn new(shape: &ShapeLayer, area: &Rasterizer, canvas: Rect) -> Self { + if !shape.has_soft_mask() { + return FillCoverage::Path(area.clone()); + } + let density = if shape.density.is_finite() { shape.density.clamp(0.0, 1.0) } else { 1.0 }; + let sigma = feather::feather_sigma(shape.feather); + if sigma <= 0.0 { + return FillCoverage::Dense(area.clone(), density); + } + // Exactly what the compositor does for a vector mask with these settings + // (`photocraft-compose::masks::build_mask`): coverage with density, blurred. + let soften = |v: &mut [f32]| { + if density < 1.0 { + for x in v.iter_mut() { + *x = 1.0 - density * (1.0 - *x); + } + } + }; + let mut far = area.render(Rect::from_xywh(canvas.x0.saturating_sub(1_000_000), canvas.y0.saturating_sub(1_000_000), 1, 1)); + soften(&mut far); + let outside = far.first().copied().unwrap_or(0.0); + let region = feather::path_area(&shape.path, sigma).intersect(&canvas); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut values = Vec::new(); + if values.try_reserve_exact(w.saturating_mul(h)).is_err() { + // Too large to blur: keep the edge hard rather than fail the render. + return FillCoverage::Dense(area.clone(), density); + } + values.resize(w * h, 0.0); + if !values.is_empty() { + area.render_into(region, &mut values); + soften(&mut values); + feather::gaussian(&mut values, w, h, sigma); + } + FillCoverage::Plane { area: region, values, outside } + } + + /// Pixel bounds of the visible fill (`None` = nothing). + fn bounds(&self) -> Option { + const ALL: Rect = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4); + match self { + FillCoverage::Path(r) => r.pixel_bounds(), + FillCoverage::Dense(_, d) if *d < 1.0 => Some(ALL), + FillCoverage::Dense(r, _) => r.pixel_bounds(), + FillCoverage::Plane { outside, .. } if *outside > 0.0 => Some(ALL), + FillCoverage::Plane { area, .. } => (!area.is_empty()).then_some(*area), + } + } + + fn render(&self, rect: Rect) -> Vec { + match self { + FillCoverage::Path(r) => r.render(rect), + FillCoverage::Dense(r, d) => { + let mut v = r.render(rect); + for x in &mut v { + *x = 1.0 - d * (1.0 - *x); + } + v + } + FillCoverage::Plane { area, values, outside } => { + let w = area.width() as usize; + let mut v = Vec::with_capacity(rect.width() as usize * rect.height() as usize); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let inside = area.contains(x, y); + let i = if inside { (y - area.y0) as usize * w + (x - area.x0) as usize } else { usize::MAX }; + v.push(values.get(i).copied().unwrap_or(*outside)); + } + } + v + } + } + } +} + /// Everything needed to render a shape layer, compiled once. pub struct CompiledShape { - fill: Option<(Rasterizer, Paint)>, + fill: Option<(FillCoverage, Paint)>, stroke: Option<(Rasterizer, Paint, f32)>, /// Fill area used to clip inside/outside strokes. align: Option<(Rasterizer, bool)>, @@ -476,8 +570,9 @@ impl CompiledShape { let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - bounds = area.pixel_bounds(); - (area.clone(), Paint::from_fill(f, pb, cb)) + let coverage = FillCoverage::new(shape, &area, canvas); + bounds = coverage.bounds(); + (coverage, Paint::from_fill(f, pb, cb)) }); let all_closed = shape.path.subpaths.iter().all(|s| s.closed); let mut align = None; diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..177fc3b6a0 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..22901de907 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -174,7 +174,7 @@ parity for the area and are already contained in the dimension rows above. | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..792826aee8 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-14 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (shape Density and Feather, TOOL-214-12, #5846; 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) · **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, @@ -124,6 +124,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | +| Shape Density and Feather (Properties › Masks: a shape's path is its vector mask) | done: Density and Feather sliders for a selected shape layer, one undo step per drag, through `layer.vectorMask.edit`; the fill shows beyond the path at 100 − density %, a feather is the vector mask's Gaussian (Photoshop's 10 px ramp within 1 level); fill and stroke stay editable, the stroke stays crisp; PSD mask parameters and .pcraft round trip (#5846) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -225,6 +226,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..92a02d9801 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -190,9 +190,9 @@ note = "path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool [[item]] id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" -status = "partial" +status = "done" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" +note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)" [[item]] id = "TOOL-214-13" From cc5971ff5b9b7bc764bce9b6a8920fba69d7e87e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 10:07:54 +0800 Subject: [PATCH 0165/1452] ci: run clipboard regressions independently --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..d7a7d65b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,40 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # Keep clipboard regressions runnable when an unrelated workspace test fails. + clipboard: + name: clipboard regressions (${{ matrix.os }}) + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Install Linux system dependencies (egui/winit/rfd) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: test + save-if: false + - name: Clipboard state and regression tests + run: cargo test -p photocraft-ui-egui --locked --lib 'clipboard_tests::' + - name: Native Windows clipboard contention + if: runner.os == 'Windows' + run: cargo test -p photocraft --locked --bin photocraft services::tests::busy_native_clipboard_preserves_copied_shape -- --ignored --exact --test-threads=1 + corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache From 6dffa480acaca285fe56f1e6cbae756291c325f9 Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Thu, 15 Oct 2026 04:08:28 +0200 Subject: [PATCH 0166/1452] BMP export: Indexed Color documents save as palette BMPs Saving an Indexed Color document as BMP went through the ordinary RGB path and wrote a 24-bit file, so the colour table was lost on reopen (the PNG exporter already had a palette branch). export_mode_specific now sends Indexed Color to BMP as well as PNG, through a new encode_bmp_indexed that writes an 8-bit palette BMP with the document's table. BMP has no transparency, so a transparent index is written as its palette colour and the export says so. bmp_palette returned the decoder's 256-entry padding, so a 3-colour BMP reopened with 253 extra black entries. It now trims the table to the colours the header declares. --- crates/codecs/README.md | 2 + crates/codecs/src/codecs/via_image.rs | 88 ++++++++++++++++++++++++++- crates/codecs/src/lib.rs | 6 ++ crates/io/src/flat.rs | 12 +++- crates/io/tests/modes.rs | 34 +++++++++++ docs/file-format-parity.md | 3 +- 6 files changed, 141 insertions(+), 4 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..5e76097db8 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -65,6 +65,8 @@ the same. Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode. +`bmp_palette` returns the colours the header declares, not the decoder's 256-entry padding. +`encode_bmp_indexed` writes the inverse: indices plus a table as an 8-bit palette BMP. ## Documented asymmetries and limitations diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 7e7eb76b2f..286ef02701 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -34,7 +34,51 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError { pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?; - Ok(decoder.get_palette().map(|palette| palette.to_vec())) + let mut palette = decoder.get_palette().map(|palette| palette.to_vec()); + // The decoder always hands back 256 entries (it pads a short table with black to survive + // corrupt files); the file's own count is what the document's colour table should hold. + if let (Some(p), Some(n)) = (palette.as_mut(), bmp_palette_len(bytes)) { + p.truncate(n.max(1)); + } + Ok(palette) +} + +/// How many colours a palette BMP's header declares: `biClrUsed`, or 2^bits when it is 0. +/// `None` for a header this does not recognise, which leaves the table as decoded. +fn bmp_palette_len(bytes: &[u8]) -> Option { + let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let (bit_count, used) = match u32_at(14)? { + // OS/2 BITMAPCOREHEADER: no colour count, the bit depth sets the table size. + 12 => (u16_at(24)?, 0), + n if n >= 40 => (u16_at(28)?, u32_at(46)?), + _ => return None, + }; + if !(1..=8).contains(&bit_count) { + return None; + } + let max = 1usize << bit_count; + Some(if used == 0 { max } else { (used as usize).min(max) }) +} + +/// Writes an 8-bit palette BMP: `indices` row-major, `palette` RGB entries (1..=256). The +/// counterpart of [`bmp_palette`], for Indexed Color documents. +pub(crate) fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result, CodecError> { + let f = Format::Bmp; + if width == 0 || height == 0 || u64::from(width) * u64::from(height) != indices.len() as u64 { + return Err(CodecError::InvalidImage("index data does not match the size".into())); + } + if palette.is_empty() || palette.len() > 256 { + return Err(CodecError::InvalidImage("a palette BMP holds 1 to 256 colours".into())); + } + if indices.iter().any(|&i| usize::from(i) >= palette.len()) { + return Err(CodecError::InvalidImage("palette index out of range".into())); + } + let mut out = Cursor::new(Vec::new()); + image::codecs::bmp::BmpEncoder::new(&mut out) + .encode_with_palette(indices, width, height, image::ExtendedColorType::L8, Some(palette)) + .map_err(|e| CodecError::encode(f, e))?; + Ok(out.into_inner()) } /// The `image` decoder for `f` with our limits, its header read and its size checked. @@ -178,3 +222,45 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - } Ok(cursor.into_inner()) } + +#[cfg(test)] +mod indexed_tests { + use super::*; + + #[test] + fn palette_bmp_roundtrips_its_table_and_pixels() { + // 3 px wide: rows are padded to 4 bytes, so a wrong stride shows in the second row. + let pal = [[255, 0, 0], [0, 0, 255], [10, 20, 30]]; + let idx = [0u8, 1, 2, 2, 1, 0]; + let bytes = encode_bmp_indexed(3, 2, &idx, &pal).unwrap(); + assert_eq!(bmp_palette(&bytes).unwrap().as_deref(), Some(&pal[..]), "the table survives, not a 256-entry grey ramp"); + let img = decode(Format::Bmp, &bytes, &Limits::default()).unwrap(); + assert_eq!(img.dimensions(), (3, 2)); + let rgb = img.convert(ChannelLayout::Rgb, SampleType::U8); + let want: Vec = idx.iter().flat_map(|&i| pal[usize::from(i)]).collect(); + assert_eq!(rgb.data(), want.as_slice()); + } + + #[test] + fn palette_len_follows_the_header() { + let mut bytes = encode_bmp_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]]).unwrap(); + assert_eq!(bmp_palette_len(&bytes), Some(2)); + // biClrUsed = 0 means the full 2^bits table. + bytes[46..50].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(bmp_palette_len(&bytes), Some(256)); + // A short or unknown header leaves the decoded table alone instead of panicking. + assert_eq!(bmp_palette_len(&bytes[..30]), None); + bytes[14..18].copy_from_slice(&7u32.to_le_bytes()); + assert_eq!(bmp_palette_len(&bytes), None); + } + + #[test] + fn palette_bmp_rejects_bad_input() { + let pal = [[0, 0, 0], [255, 255, 255]]; + assert!(encode_bmp_indexed(3, 2, &[0; 5], &pal).is_err(), "size mismatch"); + assert!(encode_bmp_indexed(0, 2, &[], &pal).is_err(), "empty"); + assert!(encode_bmp_indexed(2, 1, &[0, 2], &pal).is_err(), "index past the table"); + assert!(encode_bmp_indexed(1, 1, &[0], &[]).is_err(), "no colours"); + assert!(encode_bmp_indexed(1, 1, &[0], &[[0; 3]; 257]).is_err(), "more than 256 colours"); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..76f8b88f1e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -128,6 +128,12 @@ pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) } +/// Encodes `indices` (row-major, one byte per pixel) as an 8-bit palette BMP with the colour +/// table `palette` (1 to 256 entries). Used for Indexed Color documents; BMP has no transparency. +pub fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result, CodecError> { + via_image::encode_bmp_indexed(width, height, indices, palette) +} + /// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the /// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or /// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens. diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 54fe96090d..492cbe80e8 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -798,10 +798,10 @@ fn cmyk_image_to_srgb(img: &Image) -> Result { Ok(Image::from_normalized(w, h, layout, sample, &out)?.with_icc(Some(dst.to_bytes().to_vec())).with_meta(img.meta.clone())) } -/// Indexed Color → PNG-8 with its colour table; Duotone → the inks rendered as RGB. +/// Indexed Color → PNG-8 or an 8-bit BMP with its colour table; Duotone → the inks rendered as RGB. fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> Result, IoError> { match doc.mode { - ColorMode::Indexed if format == Format::Png => { + ColorMode::Indexed if matches!(format, Format::Png | Format::Bmp) => { let Some(table) = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256) else { return Ok(None) }; let mut idx = try_buffer(doc.size.area() as usize, 1)?; let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { @@ -811,6 +811,14 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> })); Ok(()) }); + if format == Format::Bmp { + let bytes = codecs::encode_bmp_indexed(doc.size.width, doc.size.height, &idx, &table.colors)?; + let mut warnings = vec![format!("written as an 8-bit palette BMP ({} colours)", table.colors.len())]; + if let Some(t) = table.transparent { + warnings.push(format!("BMP has no transparency; transparent pixels are written in the colour of palette entry {t}")); + } + return Ok(Some(ExportResult { bytes, warnings })); + } // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; diff --git a/crates/io/tests/modes.rs b/crates/io/tests/modes.rs index f33fe5927c..3b9e6135c8 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/modes.rs @@ -49,3 +49,37 @@ fn indexed_png_keeps_the_document_resolution() { let back = photocraft_io::import("x.png", &r.bytes).unwrap().document; assert!((back.resolution_dpi - 300.0).abs() < 0.01, "{}", back.resolution_dpi); } + +#[test] +fn indexed_bmp_is_palette_bmp() { + // The BMP export wrote an Indexed Color document as 24-bit RGB, so reopening it lost the mode (#3869 follow-up). + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]], transparent: None }); + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert_eq!(u16::from_le_bytes([r.bytes[28], r.bytes[29]]), 8, "8 bits per pixel"); + assert!(r.warnings.iter().any(|w| w.contains("palette BMP")), "{:?}", r.warnings); + let back = photocraft_io::import("x.bmp", &r.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Indexed); + assert_eq!(back.color_table.as_ref().map(|t| t.colors.clone()), Some(vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]])); + let px = back.layers[0].surface().unwrap().rgba(1, 1); + assert_eq!(&px[..3], &[1.0, 0.0, 0.0]); +} + +#[test] +fn indexed_bmp_warns_that_it_has_no_transparency() { + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0]], transparent: Some(0) }); + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("no transparency")), "{:?}", r.warnings); +} + +#[test] +fn indexed_bmp_without_a_usable_table_falls_back_to_rgb() { + // No table (or one past 256 entries) keeps the old 24-bit path rather than failing the save. + for table in [None, Some(ColorTable { colors: vec![[0; 3]; 257], transparent: None })] { + let mut d = doc(ColorMode::Indexed); + d.color_table = table; + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert_eq!(u16::from_le_bytes([r.bytes[28], r.bytes[29]]), 24); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..45c25964d2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -28,7 +28,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | -| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | +| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Indexed Color documents save as 8-bit palette BMPs with their colour table instead of 24-bit RGB; a palette BMP opens with its declared number of colours, not 256 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | From ad72d1d1fd0cacf11b5c7d48af22d808f0ce9be3 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Wed, 14 Oct 2026 23:21:27 -0300 Subject: [PATCH 0167/1452] fix(ci): repair preview tests and reduce WebAssembly size --- Cargo.toml | 8 ++++++++ crates/ui-egui/src/filter_dialog.rs | 17 +++++++++++++++-- crates/ui-egui/src/profile_open_tests.rs | 11 +++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 663d268c3a..98218d37f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,14 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" +# The browser UI toolkit favors size to stay within the WebAssembly upload budget. +[profile.wasm-release.package.egui] +opt-level = "z" +[profile.wasm-release.package.epaint] +opt-level = "z" +[profile.wasm-release.package.eframe] +opt-level = "z" + # Pixel processing keeps its speed-oriented optimization level. [profile.wasm-release.package.photocraft-raster] opt-level = 3 diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 3da0735204..eea7ba67e0 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1915,12 +1915,25 @@ mod tests { _ => panic!("a smart object"), }; let k = 4; - let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let params = json!({"points": [[0, 20], [90, 150], [255, 230]]}); + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", ¶ms, k).unwrap(); + let full = preview_document(&doc, Some(layer), "image.adjustments.curves", ¶ms, 1).unwrap(); + let photocraft_doc::LayerContent::Smart(full_sm) = &full.layer(layer).unwrap().content else { panic!("a smart object") }; + let reference = photocraft_algo::resample::resize_surface(full_sm.cache.as_ref().unwrap(), 0.25, 0.25, photocraft_algo::resample::Resample::Bicubic); let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). - for (x, y) in [(4, 4), (5, 6), (7, 7)] { + // Bicubic reduction blends the square's boundary with transparency; compare all alpha + // values with a reduced full-size result, and require opacity only in the interior. + for y in 0..shown.size.height as i32 { + for x in 0..shown.size.width as i32 { + let expected = reference.pixel(x, y).last().copied().unwrap(); + let actual = alpha(&shown, x, y); + assert!((actual - expected).abs() <= 1.0 / 255.0, "({x}, {y}): alpha {actual}, expected {expected}"); + } + } + for (x, y) in [(5, 5), (5, 6), (6, 6)] { assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); } for (x, y) in [(2, 2), (9, 9), (12, 12), (15, 15)] { diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 2ede8ffef0..b071048dea 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -13,7 +13,9 @@ use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; use serde_json::Value; -const USE: &str = "Use the embedded profile (Adobe RGB (1998)-compatible (Photocraft))"; +fn use_embedded() -> String { + format!("Use the embedded profile ({})", photocraft_cms::Builtin::AdobeRgbCompat.description()) +} const CONVERT: &str = "Convert to the working space (sRGB IEC61966-2.1)"; const DISCARD: &str = "Discard the embedded profile (don't color manage)"; @@ -125,7 +127,7 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { assert_eq!(docs(&h), answered, "background {background}: a file opens only after its prompt"); let name = asked(&h); assert!(!answered.contains(&name), "background {background}: {name} asked twice"); - answer(&mut h, USE); + answer(&mut h, &use_embedded()); answered.push(name); } in_order(background, &answered); @@ -217,6 +219,7 @@ fn a_held_file_still_reports_its_missing_fonts() { #[test] fn each_answer_applies_to_its_own_file() { + let use_embedded = use_embedded(); for background in [false, true] { let mut h = harness(background); open_three(&mut h); @@ -229,7 +232,7 @@ fn each_answer_applies_to_its_own_file() { match name { "a.png" => CONVERT, "b.png" => DISCARD, - _ => USE, + _ => &use_embedded, }, ); answered.push(name); @@ -249,7 +252,7 @@ fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { open_three(&mut h); let first = asked(&h); in_order(background, &[first]); - answer(&mut h, USE); + answer(&mut h, &use_embedded()); click(&mut h, "Cancel"); settle(&mut h); assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt left"); From a4b1e2a60700028905a8752d040c086ff2163c72 Mon Sep 17 00:00:00 2001 From: Fazmin Date: Wed, 14 Oct 2026 23:09:05 -0400 Subject: [PATCH 0168/1452] Type: Create warped text button in the options bar, as in Photoshop (#5911) The Type tool's options bar now has Photoshop's Create warped text button after the text colour swatch. It opens the same Warp Text dialog as Type > Warp Text... for the selected type layers (or the layer being edited), and is greyed when there is no type layer to warp. - assets/icons/text-warp.svg: original glyph (a T above a bowed arc) on Lucide's 24 px grid and stroke; listed in ATTRIBUTION.md. - The tooltip is translated in all 20 catalogs, reusing each language's existing "Create Warped Text" wording (new for Russian). Test: options_bar_warp_button_warps_the_selected_type_layers. Co-Authored-By: Claude --- ATTRIBUTION.md | 3 ++- assets/icons/text-warp.svg | 17 +++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/type_tool.rs | 8 ++++++++ crates/ui-egui/src/type_tool_tests.rs | 23 +++++++++++++++++++++++ 25 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 assets/icons/text-warp.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 26eca62464..ffb53b5506 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -35,6 +35,7 @@ next to them. | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/text-warp.svg b/assets/icons/text-warp.svg new file mode 100644 index 0000000000..03f30ed87b --- /dev/null +++ b/assets/icons/text-warp.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5cfc0f8ff5..0edf24fa9e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1498,6 +1498,7 @@ Set shape fill type Nastavit typ výplně tvaru Set shape stroke type Nastavit typ tahu tvaru Set the text color Nastavit barvu textu + Create warped text Vytvořit deformovaný text Shadows Stíny Shape: Tvar: Sharpening Doostření diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05ac77803c..d64f465bea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1481,6 +1481,7 @@ Set shape fill type Fülltyp der Form festlegen Set shape stroke type Konturtyp der Form festlegen Set the text color Textfarbe festlegen + Create warped text Verkrümmten Text erstellen Shadows Tiefen Shape: Form: Sharpening Scharfzeichnung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c6c5ff8f66..2025aeff77 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3033,6 +3033,7 @@ Set shape fill type Ορισμός τύπου γεμίσματος σχήματος Set shape stroke type Ορισμός τύπου περιγράμματος σχήματος Set the text color Ορισμός χρώματος κειμένου + Create warped text Δημιουργία παραμορφωμένου κειμένου Shadows/Highlights Σκιές/φωτεινές περιοχές Shape Σχήμα Shape Blur Θόλωση σχήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e24171195a..753da30ade 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1488,6 +1488,7 @@ Set shape fill type Establecer el tipo de relleno de la forma Set shape stroke type Establecer el tipo de trazo de la forma Set the text color Establecer el color del texto + Create warped text Crear texto deformado Shadows Sombras Shape: Forma: Sharpening Enfoque diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index db40d97abf..ee45d66e3b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1468,6 +1468,7 @@ Set shape fill type Définir le type de remplissage de la forme Set shape stroke type Définir le type de contour de la forme Set the text color Définir la couleur du texte + Create warped text Créer un texte déformé Shadows Ombres Shape: Forme : Sharpening Netteté diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 54bde5d0c4..4175879d44 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1304,6 +1304,7 @@ Set painting symmetry options Festési szimmetria beállításai Set the overlay options for the Crop tool A Körbevágás eszköz fedvénybeállításai Set the text color Szövegszín beállítása + Create warped text Torzított szöveg létrehozása Settings: Beállítások: Shape Dynamics Alakdinamika Sharpen Tool Élesítő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 544ead05a4..584ef448f3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1462,6 +1462,7 @@ Set shape fill type Atur jenis isian bentuk Set shape stroke type Atur jenis stroke bentuk Set the text color Atur warna teks + Create warped text Buat teks warp Shadows Bayangan Shape: Bentuk: Sharpening Penajaman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 40b468926e..6f7cf38347 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1483,6 +1483,7 @@ Set shape fill type Imposta tipo riempimento forma Set shape stroke type Imposta tipo traccia forma Set the text color Imposta colore testo + Create warped text Crea testo deformato Shadows Ombre Shape: Forma: Sharpening Contrasto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1d64bf868c..2e9d86ec4d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1485,6 +1485,7 @@ Set shape fill type シェイプの塗りの種類を設定 Set shape stroke type シェイプの線の種類を設定 Set the text color 文字色を設定 + Create warped text ワープテキストを作成 Shadows シャドウ Shape: シェイプ: Sharpening シャープ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76a3c9826..5aeaf20ec2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1457,6 +1457,7 @@ Set shape fill type 모양 채우기 유형 설정 Set shape stroke type 모양 획 유형 설정 Set the text color 텍스트 색상 설정 + Create warped text 뒤틀린 텍스트 만들기 Shadows 어두운 영역 Shape: 모양: Sharpening 선명 효과 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2c9416c707..aea65c48fd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1466,6 +1466,7 @@ Set shape fill type Vultype van vorm instellen Set shape stroke type Lijntype van vorm instellen Set the text color De tekstkleur instellen + Create warped text Verbogen tekst maken Shadows Schaduwen Shape: Vorm: Sharpening Verscherping diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eee1e61a90..ba8fd7af03 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1481,6 +1481,7 @@ Set shape fill type Ustaw typ wypełnienia kształtu Set shape stroke type Ustaw typ obrysu kształtu Set the text color Ustaw kolor tekstu + Create warped text Utwórz wypaczony tekst Shadows Cienie Shape: Kształt: Sharpening Wyostrzanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e10fe7dbb..04be6a5c8c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1463,6 +1463,7 @@ Set shape fill type Definir tipo de preenchimento da forma Set shape stroke type Definir tipo de traçado da forma Set the text color Definir a cor do texto + Create warped text Criar texto deformado Shadows Sombras Shape: Forma: Sharpening Nitidez diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 862788630b..63ecf634f2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -782,6 +782,7 @@ Set shape fill type Тип заливки фигуры Set shape stroke type Тип обводки фигуры Set the text color Цвет текста + Create warped text Создать деформированный текст Only Web Colors Только web-цвета Overlay Color Цвет наложения Overlay Opacity Непрозрачность наложения diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b91b67b3eb..bb7d2f17ff 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1446,6 +1446,7 @@ Set shape fill type Podesi vrstu popune oblika Set shape stroke type Podesi vrstu obruba oblika Set the text color Postavi boju teksta + Create warped text Napravi iskrivljeni tekst Shadows Senke Shape: Oblik: Sharpening Izoštravanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4c8f06ccc1..9f710549fd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1442,6 +1442,7 @@ Set shape fill type Şekil doldurma türünü ayarla Set shape stroke type Şekil kontur türünü ayarlama Set the text color Metin rengini ayarlama + Create warped text Çarpık metin oluştur Shadows Gölgeler Shape: Şekil: Sharpening Bileme diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 39597c15cb..ed5b4de734 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -759,6 +759,7 @@ Set shape fill type Установити тип заливки фігури Set shape stroke type Установити тип обведення фігури Set the text color Установити колір тексту + Create warped text Створити деформований текст Only Web Colors Лише вебкольори Overlay Color Колір накладання Overlay Opacity Непрозорість накладання diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ac7123d850..2254010c2b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -870,6 +870,7 @@ Right align text Căn chữ phải Toggle text orientation Đổi hướng chữ Set the text color Đặt màu chữ + Create warped text Tạo chữ uốn Cancel any current edits (Esc) Hủy chỉnh sửa hiện tại (Esc) Commit any current edits ({key}) Áp dụng chỉnh sửa hiện tại ({key}) Author: Tác giả: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2307739ed3..f3637050d1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1498,6 +1498,7 @@ Set shape fill type 设置形状填充类型 Set shape stroke type 设置形状描边类型 Set the text color 设置文字颜色 + Create warped text 创建变形文字 Shadows 阴影 Shape: 形状: Sharpening 锐化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 478489f8c7..fc58d4e86c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1494,6 +1494,7 @@ Set shape fill type 設定形狀填滿類型 Set shape stroke type 設定形狀筆畫類型 Set the text color 設定文字顏色 + Create warped text 建立彎曲文字 Shadows 陰影 Shape: 形狀: Sharpening 銳利化 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 9301f709ce..e7e919bd96 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -134,6 +134,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("symmetry-vertical", include_bytes!("../../../assets/icons/symmetry-vertical.svg")), ("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")), ("text-cursor", include_bytes!("../../../assets/icons/text-cursor.svg")), + ("text-warp", include_bytes!("../../../assets/icons/text-warp.svg")), ("toggle-left", include_bytes!("../../../assets/icons/toggle-left.svg")), ("toggle-right", include_bytes!("../../../assets/icons/toggle-right.svg")), ("trash", include_bytes!("../../../assets/icons/trash.svg")), diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 5641127489..3f08e385b9 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1785,6 +1785,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.clicked() { open_color_picker(app, [c[0], c[1], c[2]]); } + // Create warped text, after the swatch as in Photoshop (#5911): the Type › Warp Text… dialog. + let can_warp = crate::menus::is_enabled(app, "type.warpText"); + if ui.add_enabled_ui(can_warp, |ui| crate::icons::button(ui, "text-warp", 24.0, false, tl!("Create warped text"))).inner.clicked() + && let Err(e) = crate::menus::invoke(app, &ui.ctx().clone(), "type.warpText", json!({})) + { + app.ui.status = e; + app.ui.status_error = true; + } if app.ui.text_edit.is_some() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 725d2890fb..43f668b7c5 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1356,6 +1356,7 @@ fn the_font_menu_lists_served_families() { mod multi_layer_formatting { use super::*; use egui::accesskit::Role; + use egui_kittest::kittest::NodeT; use photocraft_doc::text::{AntiAlias, CharStyle, Orientation, TextAlign}; fn selected(app: &mut PhotocraftApp, ids: &[LayerId]) { @@ -1726,6 +1727,28 @@ mod multi_layer_formatting { assert_eq!(style(h.state(), id, 0), expected); } } + + /// #5911: the options bar's Create warped text button opens Warp Text for the selected type + /// layers, as Type › Warp Text… does; with no type layer to warp it is greyed. + #[test] + fn options_bar_warp_button_warps_the_selected_type_layers() { + let (app, ids) = fixture(); + let mut h = controls(app, false); + h.get_by_role_and_label(Role::Button, "Create warped text").click(); + h.run(); + let dialog = h.state().ui.dialogs.last().unwrap(); + assert_eq!(dialog.fields.get("__command").and_then(|c| c.as_str()), Some("type.warpText")); + let dialog = dialog.id; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("style".into(), json!("arc")); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + for id in &ids[..2] { + assert!(text(h.state(), *id).warp.is_some(), "a selected layer is warped"); + } + assert!(text(h.state(), ids[2]).warp.is_none(), "an unselected layer is left alone"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.run(); + assert!(h.get_by_role_and_label(Role::Button, "Create warped text").accesskit_node().is_disabled()); + } } /// #1132: with the Move tool, double-clicking type on the canvas edits it (Photoshop CC 2019+): From 8df66a71caedb118c79c1aeee374f23b748ce025 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 15 Oct 2026 08:50:32 +0530 Subject: [PATCH 0169/1452] Layers filter tests: expand the fixture's group (#5571) The fixture builds "Sheets" with layer.groupLayers, which now creates the group collapsed. The unfiltered baseline lists only expanded groups' contents (Photoshop), so open the group as the other group tests do. The filtered checks already look inside collapsed groups (display_rows gets show_all while a filter is active). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/layer_filter_ui_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/layer_filter_ui_tests.rs b/crates/ui-egui/src/layer_filter_ui_tests.rs index 16400ae120..aeeb63e7cc 100644 --- a/crates/ui-egui/src/layer_filter_ui_tests.rs +++ b/crates/ui-egui/src/layer_filter_ui_tests.rs @@ -17,6 +17,7 @@ fn session() -> photocraft_engine::Session { s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); s.execute("layer.new.layer", json!({"name": "Paper"})).unwrap(); s.execute("layer.groupLayers", json!({"name": "Sheets"})).unwrap(); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); let paper = layer(&s.active().unwrap().doc, "Paper"); s.execute("layer.select", json!({"layer": paper})).unwrap(); let ink = s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap(); From f8cc7fca50f6a378a154e6cebb4b449c25268650 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 20:07:49 -0700 Subject: [PATCH 0170/1452] Brushes: importing an .abr file numbers repeated brush names in linear time brush.presets.importAbr named each imported preset with unique, which compared a candidate name with the names of the presets one by one and tried "Name", "Name 2", "Name 3" and so on until one was free. A version 1 or 2 brush with a computed tip and no name is named after its tip ("9 px Round"), so a file of n such brushes of one diameter cost about n^3/6 string comparisons. In a test build, importing 10,000 of them took 25 minutes (22 minutes of CPU time). The import now builds the set of preset names once and keeps the next number to try for each repeated name, as swatches.import does for swatch and group names. Names still compare case-insensitively, and every preset gets the same name as before. brush.presets.importImage names its one preset with the same code. repeated_brush_names_are_numbered_in_linear_time imports 10,000 such brushes on a thread of its own and fails when the import takes more than 20 seconds. repeated_brush_names_take_the_first_free_number checks the names when the file repeats a name in other letter cases, when a preset already holds one of the numbers, when the file has a name that the import already gave to an earlier brush, and when a name clashes with a built-in preset. --- crates/engine/src/preset_import_cmds.rs | 90 +++++++++++++++++++++---- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8da6cee8a0..51ce9bf078 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -49,13 +49,32 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S Ok((bytes, stem)) } -/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`… -fn unique(name: &str, taken: &[String]) -> String { - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(name) { - return name.to_string(); +/// The preset names in use, for naming new presets. +struct UniqueNames { + /// Every name in use, ASCII-lowercased. + taken: std::collections::HashSet, + /// The next number to try for each repeated name (lowercased). + next: std::collections::HashMap, +} + +impl UniqueNames { + fn new<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() } + } + + /// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.taken.insert(key.clone()) { + return name.to_string(); + } + // Resume numbering per name, so a file of identical names stays linear. + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.taken.contains(&format!("{key} {i}"))).unwrap_or(from); + self.taken.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") } - (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) } fn import_abr(s: &mut Session, p: &Value) -> Result { @@ -93,11 +112,10 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { s.tools.presets.retain(|x| x.builtin || x.group != group); } - let mut taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); + let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())); let mut names = Vec::with_capacity(imp.presets.len()); for mut preset in imp.presets { - preset.name = unique(&preset.name, &taken); - taken.push(preset.name.clone()); + preset.name = taken.unique(&preset.name); names.push(preset.name.clone()); s.tools.presets.push(preset); } @@ -180,8 +198,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let doc = crate::file_cmds::import(decode_name, &bytes)?; let (v, w, h) = image_paint(&doc, cmd)?; let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?; - let taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); - let name = unique(&name, &taken); + let name = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())).unique(&name); s.tools.presets.push(photocraft_paint::BrushPreset { name: name.clone(), brush: brush.clone(), @@ -526,10 +543,55 @@ mod tests { assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); } + /// A version 1 brush with a computed tip and no name is named after its tip ("9 px Round"). The + /// import used to number each repeated name by searching the preset names once for every number + /// it tried, so importing 10,000 such brushes took minutes. The test fails when the import takes + /// more than 20 seconds. + #[test] + fn repeated_brush_names_are_numbered_in_linear_time() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "9 PX ROUND 3"})).unwrap(); + let round = LegacyBrush { + name: String::new(), + spacing: 25, + anti_alias: true, + tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 }, + }; + let data = photocraft_paint::tile::b64_encode(&write_v12(1, &vec![round; 10_000], false).unwrap()); + let r = s.execute("brush.presets.importAbr", json!({"data": data})).unwrap(); + let names: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let _ = tx.send(names); + }); + let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) { + Ok(names) => names, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the brushes did not finish"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"), + }; + // Numbering starts at 2 and skips the number a preset already uses, in any letter case. + assert_eq!(names[..4], ["9 px Round", "9 px Round 2", "9 px Round 4", "9 px Round 5"]); + assert_eq!(names.last(), Some(&format!("9 px Round {}", names.len() + 1))); + assert_eq!(names.iter().collect::>().len(), names.len()); + } + + #[test] + fn repeated_brush_names_take_the_first_free_number() { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "speckle 3"})).unwrap(); + let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }; + let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip.clone()) }; + let file = write_v12(2, &["Speckle", "SPECKLE", "speckle", "Speckle 2", "Speckle", "hard round"].map(brush), true).unwrap(); + let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&file)})).unwrap(); + // Names that differ only in letter case count as the same name. The file's "Speckle 2" comes + // after "SPECKLE" took that name, and "hard round" clashes with the built-in Hard Round. + assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"])); + } + #[test] fn unique_names() { - let taken = vec!["A".to_string(), "a 2".to_string()]; - assert_eq!(unique("a", &taken), "a 3"); - assert_eq!(unique("B", &taken), "B"); + let mut taken = UniqueNames::new(["A", "a 2"]); + assert_eq!(taken.unique("a"), "a 3"); + assert_eq!(taken.unique("B"), "B"); } } From d41171f5a1a5512ec28f43bee4b711dd3cdcc77a Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 23:35:28 -0400 Subject: [PATCH 0171/1452] Tests: the language checks find the File menu with its access key (#5863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu bar draws a title with no Latin letter with its English access key ("文件(F)"), so the three live-language tests now look for the title as the bar draws it (menu_keys::localized) rather than the bare translation. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/live_tests.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 89c47ef1ac..34dd27545c 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -4,6 +4,13 @@ use super::*; use crate::{PhotocraftApp, Services}; use serde_json::json; +/// The File menu's title as the menu bar draws it in `lang`: the translation, plus the English +/// access key where it has no Latin letter of its own ("文件(F)", #5863). +fn file_title(lang: Lang) -> String { + let (shown, _) = crate::menu_keys::localized(&[tr(lang, "File").to_string()], &["File"], &[Some('F')]); + shown.into_iter().next().unwrap_or_default() +} + fn lang(code: &str) -> Lang { Lang::from_code(code).expect("registered language") } @@ -225,7 +232,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { let h = harness(); assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(current(), lang(code), "{tag}"); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang(code), "File")), "first-launch menu: {tag}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "first-launch menu: {tag}"); }); }); } @@ -276,7 +283,7 @@ fn an_explicit_language_survives_restart_on_a_different_system_language() { let h = harness_with_services(services); assert_eq!(h.state().session.prefs().interface.language, "ko"); assert_eq!(current(), lang("ko")); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang("ko"), "File"))); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang("ko")))); }); }); } @@ -295,7 +302,7 @@ fn a_control_request_changes_the_drawing_language_in_the_same_frame() { h.step(); assert_eq!(reply.try_recv().expect("response")["ok"], true); assert_eq!(current(), lang(code)); - assert!(drawn_text(&h).iter().any(|text| text == tr(lang(code), "File")), "the menu must change in this frame: {code}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "the menu must change in this frame: {code}"); assert!(std::sync::Arc::ptr_eq(&document, &h.state().session.active().expect("document").doc), "switching language must preserve the document"); } assert!(h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": []})).is_err()); From 1f745a9d451b8dd84d17067bc09c4061b9432d81 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 23:35:31 -0400 Subject: [PATCH 0172/1452] Tests: Displace's map picker is asked when the frame polls it (#5806) ask_file only queues the request; the file dialog service is called from poll_file_dialog, so the request count is checked after the poll that shows the picker and acts on its answer. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/filter_dialog.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa3336a6cf..fe2fb213f8 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1167,8 +1167,10 @@ mod tests { let ctx = egui::Context::default(); let id = open(&mut app, "filter.distort.displace").unwrap(); crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(asked.borrow().len(), 1, "OK asks for the map"); + assert!(app.file_dialog_open(), "OK leaves the map picker pending"); + // The frame shows the picker (the service is asked then) and acts on its answer. app.poll_file_dialog(&ctx, None); + assert_eq!(asked.borrow().len(), 1, "OK asks for the map"); assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "Displace ran with the chosen map"); // Cancelled: nothing is applied. let id = open(&mut app, "filter.distort.displace").unwrap(); From 86d1630de9114e46b0172b10854defe2e8927845 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 23:35:33 -0400 Subject: [PATCH 0173/1452] Tests: the Move readout drag starts on the layer (#5727) With Auto-Select (on by default) a press on an empty spot starts the layer marquee, not a move; the test pressed at (32, 32), just outside the 16..32 square. It now presses inside it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/move_ui.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 22c43d41f2..db62ad7ee3 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -337,11 +337,13 @@ mod tests { app.ui.view.show.smart_guides = false; let m = egui::Modifiers::NONE; assert_eq!(super::offset_readout(&app), None, "no drag"); - crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, m); + // Pressed on the red square (16..32): with Auto-Select (on by default) an empty spot would + // start the layer marquee instead of a move. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 24.0, y: 24.0, pressure: 1.0 }, m); assert_eq!(super::offset_readout(&app), None, "pressed, not moved yet"); - crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 42.4, y: 28.8, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 34.4, y: 20.8, pressure: 1.0 }, m); assert_eq!(super::offset_readout(&app), Some(["10 px".to_string(), "-3 px".to_string()])); - crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 42.4, y: 28.8 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 34.4, y: 20.8 }, m); assert_eq!(super::offset_readout(&app), None, "released"); } From e3f95672df9a24a11a8ed2e2145f44636d1408f9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:35:59 +0300 Subject: [PATCH 0174/1452] i18n(es): drop this PR's copies of rows main's catalog now has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's Spanish catalog (#4306) added New Layer…, Panel Options… and Lock Layers, so bundled_catalogs_are_consistent failed on the duplicates. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 164b533618..5184c370c5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2826,7 +2826,6 @@ curves Smooth Suavizar @id select.all Seleccionar todo Delete Smart Filters Eliminar filtros inteligentes Enable Smart Filters Activar filtros inteligentes - New Layer… Nueva capa… Delete Layer… Eliminar capa… Select Similar Colors Seleccionar colores similares Canvas rotation Rotación del lienzo @@ -2903,7 +2902,6 @@ curves Smooth Suavizar Automatically Create New Snapshot When Saving Crear automáticamente una instantánea nueva al guardar Show New Snapshot Dialog by Default Mostrar de forma predeterminada el cuadro de diálogo Nueva instantánea Clear History Borrar historial - Panel Options… Opciones de panel… View menu Menú Vista Keep view in sync Mantener la vista sincronizada {zoom}% {zoom}% @@ -3814,7 +3812,6 @@ layerLabel No Color Sin color Lens Flare Destello Lighting Effects Efectos de iluminación Liquify Licuar - Lock Layers Bloquear capas Lum Lum Match Color Igualar color Merge to HDR Pro Combinar en HDR Pro From f49564d281bf8892db58ed154767717223aff4f6 Mon Sep 17 00:00:00 2001 From: Bram Heerink <1698441+bramheerink@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:05:48 +0700 Subject: [PATCH 0175/1452] PSD: keep guide resources 1092/1097 only while they match the guides (#5966) Photoshop writes image resource 1092 (guides v2: 1032's guides plus a colour per guide) and 1097 (one GUID per guide) next to 1032. Export regenerated 1032 from the document but wrote 1092 and 1097 back verbatim, so after Clear Guides, New Guide, a move or a recolour the file carried two guide lists that disagree. Both are now written back only while 1092 still describes the document's guides (positions, directions and colours) and 1097 has as many entries; otherwise both are left out and the file carries 1032 alone, as files from before guides v2 do. Import takes the guides' own colours from 1092 (Photoshop's default Cyan stays with the preference colour), the read half of #5967. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/guides_map.rs | 157 +++++++++++++++++++++++++++++++++++ crates/io/src/lib.rs | 1 + crates/io/src/psd_export.rs | 4 + crates/io/src/psd_import.rs | 1 + crates/io/tests/guides_v2.rs | 128 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 6 files changed, 292 insertions(+) create mode 100644 crates/io/src/guides_map.rs create mode 100644 crates/io/tests/guides_v2.rs diff --git a/crates/io/src/guides_map.rs b/crates/io/src/guides_map.rs new file mode 100644 index 0000000000..6137011e2c --- /dev/null +++ b/crates/io/src/guides_map.rs @@ -0,0 +1,157 @@ +//! Guides ⇄ PSD beyond image resource 1032. +//! +//! Photoshop writes two undocumented resources next to 1032 (Grid and Guides), layout +//! inferred from the psd-tools, ag-psd and Photoshop-authored corpora (#5965): +//! +//! * 1092, Grid and Guides version 2: `u32` version 2, `u32` horizontal and vertical grid +//! cycle (576 = 18 px in 1/32 px, as in 1032), `u32` count, then per guide 9 bytes: `i32` +//! position in 1/32 px, `u8` direction (0 vertical, 1 horizontal), one `u8` that is always 0 +//! in the corpora, and the guide's colour as `u8` R, G, B. In every corpus file its guides +//! equal 1032's, in the same order. +//! * 1097: `u32` count (1092's), then per guide a length-prefixed ASCII GUID, a `u32` 0 and a +//! second length-prefixed GUID, always all zeros. +//! +//! Import takes the guides' own colours from 1092 (a guide in Photoshop's default colour keeps +//! following the preference colour). Export keeps both resources only while 1092 still +//! describes the document's guides and colours; otherwise it leaves both out, so the file +//! carries 1032 alone, as Photoshop files before version 2 did (#5966). + +use photocraft_doc::{Document, Guides}; + +/// Grid and Guides, version 2 (positions, directions and colours). +pub const GUIDES_V2: u16 = 1092; +/// One GUID per guide, in 1092's order. +pub const GUIDE_IDS: u16 = 1097; +/// Photoshop's default guide colour (Cyan, `#4affff`), which 1092 also stores for guides +/// that use the preference colour. +pub const DEFAULT_GUIDE_RGB: [u8; 3] = [0x4a, 0xff, 0xff]; + +/// One guide of a 1092 resource. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct GuideV2 { + /// Position in 1/32 px. + pub location: i32, + pub vertical: bool, + pub rgb: [u8; 3], +} + +fn be_u32(data: &[u8], at: usize) -> Option { + let b = data.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// The guides of a 1092 resource, or `None` when it is not version 2 or is cut short. +pub fn parse_v2(data: &[u8]) -> Option> { + if be_u32(data, 0)? != 2 { + return None; + } + let count = usize::try_from(be_u32(data, 12)?).ok()?; + // Each guide takes 9 bytes: a count the data can't hold is not a 1092 we understand. + if count > data.len().saturating_sub(16) / 9 { + return None; + } + (0..count) + .map(|i| { + let at = 16 + i * 9; + let g = data.get(at..at + 9)?; + Some(GuideV2 { location: i32::from_be_bytes([g[0], g[1], g[2], g[3]]), vertical: g[4] != 1, rgb: [g[6], g[7], g[8]] }) + }) + .collect() +} + +/// The number of entries a 1097 resource declares. +pub fn ids_count(data: &[u8]) -> Option { + be_u32(data, 0) +} + +/// The document's guides as 1092 would store them, sorted. +fn expected(guides: &Guides) -> Vec { + let mut v: Vec = [(&guides.vertical, true), (&guides.horizontal, false)] + .into_iter() + .flat_map(|(list, vertical)| { + list.iter().map(move |&p| GuideV2 { location: (p * 32.0).round() as i32, vertical, rgb: guides.color(vertical, p).unwrap_or(DEFAULT_GUIDE_RGB) }) + }) + .collect(); + v.sort(); + v +} + +/// Whether an imported 1092 still describes the document's guides: the same positions, +/// directions and colours, in any order. +pub fn describes(doc: &Document, data: &[u8]) -> bool { + let Some(mut stored) = parse_v2(data) else { return false }; + stored.sort(); + stored == expected(&doc.guides) +} + +/// Whether to write back an imported 1092 or 1097: only while 1092 describes the guides +/// (and, for 1097, holds as many entries as 1092). +pub fn keep_resource(doc: &Document, id: u16, data: &[u8]) -> bool { + match id { + GUIDES_V2 => describes(doc, data), + GUIDE_IDS => { + let v2 = doc.metadata.psd_resources.iter().find(|(rid, _, _)| *rid == GUIDES_V2); + v2.is_some_and(|(_, _, d)| describes(doc, d) && parse_v2(d).map(|g| g.len()).and_then(|n| u32::try_from(n).ok()) == ids_count(data)) + } + _ => true, + } +} + +/// Gives the document's guides the colours an imported 1092 stores. Only guides that 1032 +/// also has are coloured, and Photoshop's default colour is left to the preference. +pub fn apply_colors(doc: &mut Document) { + let Some(stored) = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == GUIDES_V2).and_then(|(_, _, d)| parse_v2(d)) else { + return; + }; + for g in stored { + if g.rgb == DEFAULT_GUIDE_RGB { + continue; + } + let pos = f64::from(g.location) / 32.0; + let list = if g.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; + if list.contains(&pos) { + doc.guides.set_color(g.vertical, pos, Some(g.rgb)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v + } + + #[test] + fn parses_corpus_layout() { + // clipping-mask4.psd (psd-tools): one vertical and one horizontal guide at 16 px. + let d = v2(&[(512, 0, DEFAULT_GUIDE_RGB), (512, 1, DEFAULT_GUIDE_RGB)]); + let g = parse_v2(&d).unwrap(); + assert_eq!(g.len(), 2); + assert!(g[0].vertical && !g[1].vertical); + assert_eq!(g[1].location, 512); + } + + #[test] + fn rejects_other_versions_and_short_or_hostile_data() { + let mut d = v2(&[(512, 0, DEFAULT_GUIDE_RGB)]); + assert!(parse_v2(&d[..d.len() - 1]).is_none()); + d[3] = 1; + assert!(parse_v2(&d).is_none()); + let mut huge = v2(&[]); + huge[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse_v2(&huge).is_none()); + assert!(parse_v2(&[]).is_none()); + assert!(ids_count(&[0, 0]).is_none()); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 378122986e..646a824364 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -44,6 +44,7 @@ pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub mod guides_map; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..8d99a9d895 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1253,6 +1253,10 @@ pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bo if !crate::annotations_map::keep_resource(doc, id) { return false; } + // Guides v2 and guide GUIDs: only while they still describe the document's guides. + if !crate::guides_map::keep_resource(doc, id, data) { + return false; + } // A preserved clipping-path resource is only valid while it names the current one. if id == crate::vector_map::CLIPPING_PATH { let n = usize::from(data.first().copied().unwrap_or(0)); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index c20c1b7fe7..ae4e15d9b4 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -830,6 +830,7 @@ pub(crate) fn psd_to_document_decoded( id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } } + crate::guides_map::apply_colors(&mut doc); for b in &file.global_blocks { doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); } diff --git a/crates/io/tests/guides_v2.rs b/crates/io/tests/guides_v2.rs new file mode 100644 index 0000000000..73884f00f4 --- /dev/null +++ b/crates/io/tests/guides_v2.rs @@ -0,0 +1,128 @@ +//! Image resources 1092 (guides with colours) and 1097 (guide GUIDs) next to 1032 (#5966). + +use photocraft_io::guides_map::{DEFAULT_GUIDE_RGB, GUIDE_IDS, GUIDES_V2}; +use photocraft_io::*; +use photocraft_psd::testgen; +use photocraft_psd::{Compression, ImageResource, PsdFile, Version}; + +const RED: [u8; 3] = [0xff, 0x00, 0x00]; + +type Edit = fn(&mut photocraft_doc::Document); + +fn guides_v1(guides: &[(i32, u8)]) -> Vec { + let mut v = Vec::new(); + for n in [1u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.push(*dir); + } + v +} + +fn guides_v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v +} + +fn guide_ids(n: u32) -> Vec { + let mut v = n.to_be_bytes().to_vec(); + for i in 0..n { + let id = format!("0000000{i}-1111-2222-3333-444444444444"); + let zero = "00000000-0000-0000-0000-000000000000"; + v.extend_from_slice(&36u32.to_be_bytes()); + v.extend_from_slice(id.as_bytes()); + v.extend_from_slice(&0u32.to_be_bytes()); + v.extend_from_slice(&36u32.to_be_bytes()); + v.extend_from_slice(zero.as_bytes()); + } + v +} + +/// A file as Photoshop writes it: a vertical guide at 16 px in the default colour and a red +/// horizontal one at 10 px, in 1032, 1092 and 1097. +fn photoshop_file() -> PsdFile { + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.resources.push(ImageResource::new(1032, guides_v1(&[(512, 0), (320, 1)]))); + f.resources.push(ImageResource::new(GUIDES_V2, guides_v2(&[(512, 0, DEFAULT_GUIDE_RGB), (320, 1, RED)]))); + f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(2))); + f +} + +fn resource(f: &PsdFile, id: u16) -> Option<&[u8]> { + f.resources.iter().find(|r| r.id == id).map(|r| r.data.as_slice()) +} + +#[test] +fn colours_come_from_guides_v2() { + let (d, _) = psd_to_document(&photoshop_file()); + assert_eq!(d.guides.vertical, [16.0]); + assert_eq!(d.guides.horizontal, [10.0]); + assert_eq!(d.guides.color(false, 10.0), Some(RED)); + // The default colour stays with the preference. + assert_eq!(d.guides.color(true, 16.0), None); +} + +#[test] +fn unchanged_guides_keep_both_resources_byte_exact() { + let f = photoshop_file(); + let (d, _) = psd_to_document(&f); + let out = document_to_psd(&d); + assert_eq!(resource(&out, GUIDES_V2), resource(&f, GUIDES_V2)); + assert_eq!(resource(&out, GUIDE_IDS), resource(&f, GUIDE_IDS)); +} + +#[test] +fn edited_guides_drop_both_resources() { + let (base, _) = psd_to_document(&photoshop_file()); + let edits: [(&str, Edit); 4] = [ + ("clear", |d| d.guides = Default::default()), + ("add", |d| d.guides.horizontal.push(40.0)), + ("move", |d| { + d.guides.vertical[0] = 20.0; + }), + ("recolour", |d| d.guides.set_color(true, 16.0, Some(RED))), + ]; + for (name, edit) in edits { + let mut d = base.clone(); + edit(&mut d); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_none(), "{name}: stale 1092 written"); + assert!(resource(&out, GUIDE_IDS).is_none(), "{name}: stale 1097 written"); + let (back, _) = psd_to_document(&out); + assert_eq!(back.guides.vertical, d.guides.vertical, "{name}"); + assert_eq!(back.guides.horizontal, d.guides.horizontal, "{name}"); + } +} + +#[test] +fn mismatched_ids_or_malformed_v2_are_not_kept() { + let mut f = photoshop_file(); + f.resources.retain(|r| r.id != GUIDE_IDS); + f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(3))); + let (d, _) = psd_to_document(&f); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_some()); + assert!(resource(&out, GUIDE_IDS).is_none()); + + let mut f = photoshop_file(); + for r in &mut f.resources { + if r.id == GUIDES_V2 { + r.data.truncate(20); + } + } + let (d, _) = psd_to_document(&f); + assert_eq!(d.guides.color(false, 10.0), None); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_none()); + assert!(resource(&out, GUIDE_IDS).is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b0771c4d62..5c362e922f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 92aa5047cd8865c7806d06be0efb305d4f43445b Mon Sep 17 00:00:00 2001 From: varanus-salvator <262257003+varanus-salvator@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:31:59 +0700 Subject: [PATCH 0176/1452] Bevel & Emboss: keep the Gloss Contour's Anti-aliased (antialiasGloss) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop stores Bevel & Emboss › Gloss Contour › Anti-aliased as `antialiasGloss` in the effect descriptor. The document model had no field for it, so import dropped it and export regenerated the bevel without it: once a bevel was edited (the Layer Style dialog re-applies all its settings), a saved PSD reopened in Photoshop with the gloss contour aliased. Add `Bevel::gloss_anti_alias` (serde default, so older .pcraft files load), read and write `antialiasGloss`, accept `glossAntiAlias` in layer.layerStyle.bevelEmboss and show the Anti-aliased checkbox next to the Gloss Contour in the dialog, as Photoshop does. Rendering is unchanged: contour anti-aliasing is not rendered for any effect yet. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/effects.rs | 1 + crates/compose/src/tests.rs | 4 ++++ crates/doc/src/effects.rs | 3 +++ crates/engine/src/layer_style.rs | 17 +++++++++++++++-- crates/format/tests/roundtrip.rs | 1 + crates/gpu/tests/parity.rs | 1 + crates/io/src/effects_map.rs | 3 +++ crates/ui-egui/examples/bevel_bench.rs | 1 + crates/ui-egui/examples/fx_bench.rs | 1 + crates/ui-egui/src/layer_style.rs | 21 ++++++++++++++++++++- docs/file-format-parity.md | 1 + 11 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..a3d1bd998e 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2462,6 +2462,7 @@ mod tests { altitude: 30.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: photocraft_doc::FxCommon::new(BlendMode::Screen, 0.75), highlight_color: photocraft_color::Color::WHITE, shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index eb4b250728..66a2947c17 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1122,6 +1122,7 @@ fn satin_and_bevel_stay_inside_shape() { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), @@ -1298,6 +1299,7 @@ fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), @@ -1538,6 +1540,7 @@ fn emboss_shades_the_composited_layer() { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0), @@ -2547,6 +2550,7 @@ fn nested_parallel_fx_doc() -> Document { altitude: 32.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 6ae790e26c..d242e0e516 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -213,6 +213,9 @@ pub struct Bevel { pub altitude: f32, pub use_global_light: bool, pub gloss_contour: Contour, + /// Gloss Contour › Anti-aliased (PSD `antialiasGloss`). + #[serde(default)] + pub gloss_anti_alias: bool, pub highlight: FxCommon, pub highlight_color: Color, pub shadow: FxCommon, diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..19d19db26b 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -98,7 +98,7 @@ pub fn effect_defaults(kind: &str) -> Value { } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { - json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) + json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "glossAntiAlias": false, "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) } "satin" => { json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true}) @@ -544,6 +544,7 @@ fn fresh_effect(kind: &str) -> Option { altitude: 30.0, use_global_light: true, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), @@ -789,6 +790,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { bv.use_global_light = x; } set_contour(p, "glossContour", &mut bv.gloss_contour); + set_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias); // The Contour element: `contour` turns it on/off; its curve, Range and // Anti-aliased edit the element in place, so an imported curve survives. match b_of(p, "contour") { @@ -1021,7 +1023,7 @@ pub fn specs() -> Vec { style_cmd!( "bevelEmboss", "Bevel & Emboss…", - r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"## + r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"glossAntiAlias":bool,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"## ), style_cmd!( "satin", @@ -1137,6 +1139,17 @@ mod tests { assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true)); } + #[test] + fn bevel_gloss_anti_alias_is_a_param() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap(); + let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() }; + assert!(b.gloss_anti_alias); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossAntiAlias": false})).unwrap(); + let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() }; + assert!(!b.gloss_anti_alias); + } + #[test] fn every_style_command_adds_its_effect() { for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..28236cf5cc 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -579,6 +579,7 @@ fn channel_restrictions_and_bevel_elements_roundtrip() { altitude: 40.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Screen, 0.7), highlight_color: photocraft_color::Color::WHITE, shadow: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Multiply, 0.6), diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..9e3643fde8 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1330,6 +1330,7 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel { altitude: 35.0, use_global_light: true, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.8), highlight_color: Color::rgb(1.0, 1.0, 0.9), shadow: FxCommon::new(BlendMode::Multiply, 0.7), diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..b8de5a5c37 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -239,6 +239,7 @@ fn parse_bevel(d: &Descriptor) -> Bevel { altitude: f(d, "Lald", 30.0), use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))), gloss_contour: contour(d, "TrnS"), + gloss_anti_alias: bool_of(d, "antialiasGloss"), highlight: FxCommon { enabled: true, blend: blend_of(d, "hglM", BlendMode::Screen), opacity: pct(d, "hglO", 0.75) }, highlight_color: get_desc(d, "hglC").and_then(color_from_desc).unwrap_or(Color::WHITE), shadow: FxCommon { enabled: true, blend: blend_of(d, "sdwM", BlendMode::Multiply), opacity: pct(d, "sdwO", 0.75) }, @@ -482,6 +483,7 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { .with("blur", unit(b"#Pxl", b.size)) .with("bvlD", Value::Enumerated { type_id: Id::new("BESs"), value: Id::new(if b.up { "In " } else { "Out " }) }) .with("TrnS", contour_value(&b.gloss_contour)) + .with("antialiasGloss", Value::Boolean(b.gloss_anti_alias)) .with("Sftn", unit(b"#Pxl", b.soften)); let d = match &b.contour { Some(c) => d @@ -720,6 +722,7 @@ mod tests { altitude: 40.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: true, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.6), diff --git a/crates/ui-egui/examples/bevel_bench.rs b/crates/ui-egui/examples/bevel_bench.rs index 5d2d09dc6b..15c76e6d35 100644 --- a/crates/ui-egui/examples/bevel_bench.rs +++ b/crates/ui-egui/examples/bevel_bench.rs @@ -52,6 +52,7 @@ fn bevel(size: f32) -> Effect { altitude: 32.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index aa2bf61b15..7e77449938 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -59,6 +59,7 @@ fn effects() -> Vec { altitude: 30.0, use_global_light: true, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..1e8402b78d 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -172,7 +172,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("angle", "Angle", P::Angle), ("useGlobalLight", "Use Global Light", P::Check), ("altitude", "Altitude", P::Slider(0.0, 90.0, "°")), - ("glossContour", "Gloss Contour", P::Contour(None)), + ("glossContour", "Gloss Contour", P::Contour(Some("glossAntiAlias"))), // Bevel & Emboss › Contour element. ("contour", "Contour", P::Check), ("contourName", "", P::Contour(Some("contourAntiAlias"))), @@ -355,6 +355,7 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "altitude", json!(b.altitude)); set(&mut v, "direction", json!(if b.up { "up" } else { "down" })); set(&mut v, "glossContour", photocraft_engine::layer_style::contour_param(&b.gloss_contour)); + set(&mut v, "glossAntiAlias", json!(b.gloss_anti_alias)); set(&mut v, "contour", json!(b.contour.is_some())); if let Some(c) = &b.contour { set(&mut v, "contourName", photocraft_engine::layer_style::contour_param(&c.contour)); @@ -1634,6 +1635,24 @@ mod tests { ); } + #[test] + fn bevel_gloss_anti_alias_survives_the_dialog() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + assert_eq!(effects_of(&f)[0]["params"]["glossAntiAlias"], json!(true)); + // Editing another setting keeps the gloss contour's Anti-aliased. + set_param(&mut f, "fx1", "size", json!(12)); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let st = s.active().unwrap(); + let items = &st.doc.layer(id).unwrap().effects.items; + let Some(photocraft_doc::Effect::BevelEmboss(b)) = items.first() else { panic!("{items:?}") }; + assert_eq!(b.size, 12.0); + assert!(b.gloss_anti_alias); + } + #[test] fn preview_applies_the_style_without_touching_the_document() { let s = session(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..fe0283845c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Bevel & Emboss Gloss Contour › Anti-aliased (`antialiasGloss`) is read and written, set by `glossAntiAlias` and shown in the Layer Style dialog; a PSD whose bevel is edited no longer loses it. Rendering still ignores contour anti-aliasing, as for the other effects' contours | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 205c9c173721fadceeeb3695794387eceabe8950 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 09:39:25 +0400 Subject: [PATCH 0177/1452] History Brush: bound the stroke before its dense buffers (#4961) The History Brush renders its coverage and reads its source region densely over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke holds only the tiles it touches, but dense_coverage() materialises the whole bounding box and Region::read allocates that box again for every channel. Neither is admitted first, and both go through the infallible vec!, which aborts the process instead of returning an error. Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke bounds it from the points and the brush size before anything is rendered, reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared) leaves room for a stroke that runs corner to corner across a 24-36 MP canvas. The three were private to that module. Move them to the shared retouch_cmds module and apply them in the History Brush: reject_huge_stroke before the source surface is cloned or any coverage is rendered, reject_bounds before the dense source read. The budget is unchanged, so every stroke that worked before still works. Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion and are left for a follow-up. Test: a stroke whose box dwarfs the canvas is refused with the size error and leaves the pixels as they were, while one that runs corner to corner across the canvas still paints, at 8, 16 and 32 bits. --- crates/engine/src/retouch_cmds.rs | 51 +++++++++++++++++++ .../engine/src/retouch_cmds/pattern_stamp.rs | 46 +---------------- crates/engine/src/retouch_cmds/tests.rs | 21 ++++++++ 3 files changed, 73 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..9726172062 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -35,6 +35,52 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a +/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error +/// instead of allocating. +const MAX_STROKE_PIXELS: u64 = 67_108_864; + +/// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush +/// size alone, before the renderer allocates anything for it. +fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { + let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for q in &stroke.points { + min_x = min_x.min(q.x); + min_y = min_y.min(q.y); + max_x = max_x.max(q.x); + max_y = max_y.max(q.y); + } + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return Err(bad(cmd, "point coordinates must be finite")); + } + let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); + let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); + if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { + return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); + } + let area = (w as u64).saturating_mul(h as u64); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + +fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { + if bounds.is_empty() { + return Ok(()); + } + let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); + let area = w.saturating_mul(h); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { if crate::channel_cmds::edits_channel(s) { @@ -631,6 +677,10 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + // The coverage and the source region below are both dense over the stroke's bounds, so a + // thin stroke across a large canvas would still ask for the whole box. Refuse it from the + // points and brush size, before the source surface is cloned or anything is rendered (#4961). + reject_huge_stroke(CMD, &stroke)?; let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; @@ -642,6 +692,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 0dc9f102ac..3c90c6d2a4 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -13,14 +13,9 @@ use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_bounds, reject_huge_stroke}; use crate::{Result, Session}; -/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a -/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error -/// instead of allocating. -const MAX_STROKE_PIXELS: u64 = 67_108_864; - const STREAM_PHASE_X: u64 = 101; const STREAM_PHASE_Y: u64 = 102; @@ -123,45 +118,6 @@ fn stamp_phase(p: &Value, cmd: &str, aligned: bool, first: &StrokePoint) -> Resu } } -fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for q in &stroke.points { - min_x = min_x.min(q.x); - min_y = min_y.min(q.y); - max_x = max_x.max(q.x); - max_y = max_y.max(q.y); - } - if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { - return Err(bad(cmd, "point coordinates must be finite")); - } - let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); - let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); - if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { - return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); - } - let area = (w as u64).saturating_mul(h as u64); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - fn placement(phase: (f32, f32), scale: f32, angle: f32) -> Placement { Placement::new(Rect::EMPTY, false, phase, scale, angle) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e20706857a..d72924c430 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,6 +544,27 @@ fn history_brush_restores_earlier_pixels() { } } +/// #4961: the History Brush's coverage and its source region are both dense over the stroke's +/// bounds, so a stroke whose box dwarfs the canvas is refused from the points and brush size +/// alone, before the source surface is cloned or any coverage is rendered. The document is left +/// as it was, and ordinary strokes still paint at every depth. +#[test] +fn history_brush_refuses_a_stroke_too_large_to_render() { + for depth in DEPTHS { + let mut s = session(40, 20, depth, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + + let err = s.execute("paint.historyBrush", json!({"points": [[0, 0], [60000, 60000]], "size": 6, "hardness": 100})).unwrap_err(); + assert!(err.to_string().contains("stroke too large"), "depth {depth}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "depth {depth}: a refused stroke leaves the pixels alone"); + + // The guard is sized for real work: a stroke corner to corner across the canvas still runs. + s.execute("paint.historyBrush", json!({"points": [[0, 0], [40, 20]], "size": 6, "hardness": 100})).unwrap(); + assert!(rgba(&s, 20, 10).iter().all(|v| *v > 0.99), "depth {depth}: the Open snapshot is white"); + } +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. From 11e4b407356a9411a057cb9e1ba11cb755efe80c Mon Sep 17 00:00:00 2001 From: Persi Date: Thu, 15 Oct 2026 07:50:38 +0200 Subject: [PATCH 0178/1452] PSD export: check the Background's opacity from its tiles --- crates/io/src/flat.rs | 2 +- crates/io/src/psd_export.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 54fe96090d..3190c8e08c 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -482,7 +482,7 @@ fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4fb952ba25..93982bfa57 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -102,9 +102,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written From 3227bf83660a1dcd99ef57bdef932b7ee3acdac6 Mon Sep 17 00:00:00 2001 From: varanus-salvator <262257003+varanus-salvator@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:55:18 +0700 Subject: [PATCH 0179/1452] Stroke: keep Overprint (overprint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop stores Stroke › Overprint as `overprint` in the FrFX descriptor. StrokeFx had no field for it, so an edited stroke was written back without it and a stroke with Overprint on lost it. Add `StrokeFx::overprint` (serde default, so older .pcraft files load), read and write `overprint`, accept it in layer.layerStyle.stroke and show the Overprint checkbox in the Layer Style dialog, as Photoshop does. Rendering is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/compose/examples/bench_tsly_stroke.rs | 1 + crates/compose/src/advanced_tests.rs | 3 +++ crates/compose/src/stroke_tests.rs | 2 +- crates/compose/src/tests.rs | 3 +++ crates/doc/src/effects.rs | 3 +++ crates/engine/src/layer_style.rs | 19 +++++++++++++++++-- crates/gpu/src/fx.rs | 5 +++-- crates/gpu/tests/parity.rs | 10 ++++++++-- crates/io/src/effects_map.rs | 6 +++++- crates/io/tests/effects_multi.rs | 2 +- crates/io/tests/roundtrip.rs | 2 ++ crates/ui-egui/examples/fx_bench.rs | 1 + crates/ui-egui/examples/huge_doc_bench.rs | 2 ++ crates/ui-egui/src/layer_style.rs | 18 ++++++++++++++++++ crates/ui-egui/src/transform_fx.rs | 1 + crates/ui-egui/tests/support/layout_doc.rs | 1 + docs/file-format-parity.md | 1 + 17 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/compose/examples/bench_tsly_stroke.rs b/crates/compose/examples/bench_tsly_stroke.rs index 97ff9c85b6..ec5a91b19f 100644 --- a/crates/compose/examples/bench_tsly_stroke.rs +++ b/crates/compose/examples/bench_tsly_stroke.rs @@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result> size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)), + overprint: false, })); layer.advanced.transparency_shapes = false; document.layers.push(layer); diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index c19b0dc92f..0b38fd7f5b 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -289,6 +289,7 @@ fn inside_stroke() -> Effect { size: 2.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } @@ -298,6 +299,7 @@ fn outside_stroke() -> Effect { size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } @@ -318,6 +320,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() { size: 6.0, position, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, })]; l.blend = blend; l.fill_opacity = fill; diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index ab265fa208..36c44011fa 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -21,7 +21,7 @@ fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] { } fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect { - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint }) + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint, overprint: false }) } fn blue() -> FxPaint { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index eb4b250728..0e9e70a489 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -874,6 +874,7 @@ fn stroke(size: f32, position: StrokePosition) -> Effect { size, position, paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, }) } @@ -1587,6 +1588,7 @@ fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + overprint: false, }); let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]); assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20)); @@ -2517,6 +2519,7 @@ fn nested_parallel_fx_doc() -> Document { size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, })]; let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0))); glow.opacity = 0.5; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 6ae790e26c..ede32e1145 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -166,6 +166,9 @@ pub struct StrokeFx { pub size: f32, pub position: StrokePosition, pub paint: FxPaint, + /// Overprint (PSD `overprint`). + #[serde(default)] + pub overprint: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..6027daebf3 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -91,7 +91,7 @@ pub fn effect_defaults(kind: &str) -> Value { "innerGlow" => { json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) } - "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), + "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), "gradientOverlay" => { json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) @@ -510,6 +510,7 @@ fn fresh_effect(kind: &str) -> Option { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }), "colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }, "gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }, @@ -690,6 +691,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { _ => StrokePosition::Outside, }; } + set_bool(p, "overprint", &mut s.overprint); if p.get("from").is_some() { s.paint = FxPaint::Gradient(gradient(p)); } else if let Some(c) = color_of(p, "color") { @@ -1005,7 +1007,7 @@ pub fn specs() -> Vec { style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( @@ -1137,6 +1139,17 @@ mod tests { assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true)); } + #[test] + fn stroke_overprint_is_a_param() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap(); + let Effect::Stroke(st) = &effects(&s)[0] else { panic!() }; + assert!(st.overprint); + s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap(); + let Effect::Stroke(st) = &effects(&s)[0] else { panic!() }; + assert!(!st.overprint); + } + #[test] fn every_style_command_adds_its_effect() { for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { @@ -1313,6 +1326,7 @@ mod tests { size: 7.0, position: StrokePosition::Outside, paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }), + overprint: false, }); let r = s .execute( @@ -1349,6 +1363,7 @@ mod tests { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::BLACK), + overprint: false, }); for p in [ json!({}), diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..7815f42569 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -852,8 +852,9 @@ mod tests { use photocraft_color::{BlendMode, Color}; use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition}; let light = GlobalLight::default(); - let stroke = - |size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) }); + let stroke = |size, position| { + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK), overprint: false }) + }; // Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646. let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false); assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields); diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..8fbf93382d 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -855,7 +855,13 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() { let paint = FxPaint::Gradient(gradient.clone()); let effects = [ Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false }, - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, position: StrokePosition::Inside, paint: paint.clone() }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 1.0, + position: StrokePosition::Inside, + paint: paint.clone(), + overprint: false, + }), Effect::InnerGlow(glow(paint.clone(), GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)), Effect::OuterGlow(glow(paint, GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)), ]; @@ -1340,7 +1346,7 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel { } fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> StrokeFx { - StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint } + StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint, overprint: false } } fn checker_pattern() -> Pattern { diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..5ee9996351 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -213,6 +213,7 @@ fn parse_stroke(d: &Descriptor) -> StrokeFx { _ => StrokePosition::Outside, }, paint, + overprint: bool_of(d, "overprint"), } } @@ -404,7 +405,8 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { }; let mut d = with_common(Descriptor::new("FrFX"), &s.common) .with("Styl", Value::Enumerated { type_id: Id::new("FStl"), value: Id::new(styl) }) - .with("Sz ", unit(b"#Pxl", s.size)); + .with("Sz ", unit(b"#Pxl", s.size)) + .with("overprint", Value::Boolean(s.overprint)); d = match &s.paint { FxPaint::Color(c) => { d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }).with("Clr ", Value::Descriptor(color_to_desc(c))) @@ -680,12 +682,14 @@ mod tests { size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)), + overprint: false, }), Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Multiply, 0.5), size: 6.0, position: StrokePosition::Center, paint: FxPaint::Gradient(g.clone()), + overprint: true, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/effects_multi.rs index 4eda6ad81b..112754f10d 100644 --- a/crates/io/tests/effects_multi.rs +++ b/crates/io/tests/effects_multi.rs @@ -8,7 +8,7 @@ use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, export, import}; fn stroke(size: f32, position: StrokePosition, paint: FxPaint, opacity: f32) -> Effect { - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint }) + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint, overprint: false }) } fn roundtrip(doc: &Document) -> Document { diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 6502079f20..dcb7bce62a 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -340,12 +340,14 @@ fn effects_roundtrip_typed_and_raw() { size: 3.0, position: StrokePosition::Center, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)), + overprint: false, }), Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5), size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + overprint: false, }), ]; let back = roundtrip(&d); diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index aa2bf61b15..80666b0a12 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -46,6 +46,7 @@ fn effects() -> Vec { size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.95, 0.85, 0.2)), + overprint: false, }), Effect::BevelEmboss(Bevel { enabled: true, diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 0454aefc3f..dffe9b89c0 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -97,6 +97,7 @@ fn text(name: &str, r: Rect) -> Layer { size: 4.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)), + overprint: false, }), ]; l @@ -144,6 +145,7 @@ fn build(w: u32, h: u32) -> Document { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)), + overprint: false, }), ]; d.layers.push(band); diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..9d355c3fbe 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -141,6 +141,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("position", "Position", P::Choice(POS)), ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("overprint", "Overprint", P::Check), ("color", "Color", P::Color), ], "colorOverlay" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%"))], @@ -266,6 +267,7 @@ fn values_of(e: &Effect, light: f32) -> Value { _ => "outside", }), ); + set(&mut v, "overprint", json!(s.overprint)); if let FxPaint::Color(c) = &s.paint { set(&mut v, "color", json!(hex(c))); } else { @@ -1634,6 +1636,22 @@ mod tests { ); } + #[test] + fn stroke_overprint_survives_the_dialog() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#000000", "overprint": true})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + assert_eq!(effects_of(&f)[0]["params"]["overprint"], json!(true)); + // Editing another setting keeps Overprint. + set_param(&mut f, "fx1", "size", json!(5)); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let strokes = strokes_of(&s); + assert_eq!(strokes[0].size, 5.0); + assert!(strokes[0].overprint); + } + #[test] fn preview_applies_the_style_without_touching_the_document() { let s = session(); diff --git a/crates/ui-egui/src/transform_fx.rs b/crates/ui-egui/src/transform_fx.rs index 3b56225efa..2cb7afb0b7 100644 --- a/crates/ui-egui/src/transform_fx.rs +++ b/crates/ui-egui/src/transform_fx.rs @@ -429,6 +429,7 @@ mod tests { size, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } diff --git a/crates/ui-egui/tests/support/layout_doc.rs b/crates/ui-egui/tests/support/layout_doc.rs index f44fac16a6..f79523d505 100644 --- a/crates/ui-egui/tests/support/layout_doc.rs +++ b/crates/ui-egui/tests/support/layout_doc.rs @@ -88,6 +88,7 @@ fn stroke_fx(size: f32, c: [f32; 3]) -> Effect { size, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(c[0], c[1], c[2])), + overprint: false, }) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..d59a58541c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Stroke › Overprint (`overprint`) is read and written, set by the stroke command's `overprint` and shown in the Layer Style dialog; an edited stroke no longer drops it. Rendering ignores it for now | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From c77899b2a70b9d35db9b2d7fb568cde506d182a1 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 09:57:02 +0400 Subject: [PATCH 0180/1452] Text: admit the rasterizer's peak working bytes (#4979) rasterize_warped guarded its size with MAX_PIXELS alone, which says nothing about bytes. The buffers it keeps live at once are all as deep as the format: the f32 accumulator and the output surface are both stride deep, a finished coverage buffer sits beside them for the colour being drawn, and each faux-bold pass holds a second Coverage and the vector its finish builds. A CMYK bold render at f32 therefore reserves several times what a plain grey one does for the same pixel count, and every one of those buffers went through vec! or Vec::with_capacity, which abort instead of returning. Admit the peak before reserving any of it: fits_raster_budget derives it from the stride, the sample size and whether a bold pass runs, refuses an overflowing size rather than wrapping, and is checked against MAX_RASTER_BYTES (4 GiB, 1 GiB where usize is 32 bits, so the wasm build keeps room for the document). Coverage now reserves fallibly and finish returns an Option, and the accumulator goes through a fallible zeroed(). A refusal returns the empty Rendered the oversized path already returned, so callers see the existing "too big to draw" result rather than a crash, and the document and its cached appearance are untouched. Ordinary text is unaffected: the budget only binds above what MAX_PIXELS already allowed for narrow formats. Test: the peak tracks channels, sample width and faux bold against small injected allowances, an overflowing size is refused, and a buffer that cannot be served reserves nothing. --- crates/text/src/raster.rs | 30 ++++++++------ crates/text/src/render.rs | 83 +++++++++++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index bca52e38cf..4f32e9941e 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -76,8 +76,13 @@ pub struct Coverage { } impl Coverage { - pub fn new(width: usize, height: usize) -> Self { - Self { width, height, acc: vec![0.0; width * height + 2] } + /// A zeroed accumulator, or `None` when its bytes cannot be reserved. + pub fn try_new(width: usize, height: usize) -> Option { + let n = width.checked_mul(height)?.checked_add(2)?; + let mut acc = Vec::new(); + acc.try_reserve_exact(n).ok()?; + acc.resize(n, 0.0); + Some(Self { width, height, acc }) } /// Adds a line segment (pixel coordinates, y down). @@ -148,15 +153,18 @@ impl Coverage { } /// Resolves the accumulation into per-pixel coverage in 0..=1. - pub fn finish(self) -> Vec { + /// The accumulated coverage, or `None` when the output cannot be reserved. The input + /// accumulator is still live while this builds, so both count toward a caller's budget. + pub fn finish(self) -> Option> { let n = self.width * self.height; - let mut out = Vec::with_capacity(n); + let mut out = Vec::new(); + out.try_reserve_exact(n).ok()?; let mut sum = 0.0f32; for v in &self.acc[..n] { sum += v; out.push(sum.abs().min(1.0)); } - out + Some(out) } } @@ -240,9 +248,9 @@ mod tests { #[test] fn square_area_exact() { - let mut c = Coverage::new(10, 10); + let mut c = Coverage::try_new(10, 10).unwrap(); rect_to(&mut c, &Xform::IDENTITY, 2.25, 3.5, 6.75, 8.0); - let v = c.finish(); + let v = c.finish().unwrap(); assert!((sum(&v) - 4.5 * 4.5).abs() < 1e-3, "{}", sum(&v)); assert_eq!(v[5 * 10 + 4], 1.0); assert_eq!(v[0], 0.0); @@ -250,13 +258,13 @@ mod tests { #[test] fn triangle_area_and_clipping() { - let mut c = Coverage::new(8, 8); + let mut c = Coverage::try_new(8, 8).unwrap(); // Right triangle partly outside on the left and bottom. let pts = [(-4.0, 2.0), (6.0, 2.0), (-4.0, 12.0)]; for i in 0..3 { c.line(pts[i], pts[(i + 1) % 3]); } - let v = c.finish(); + let v = c.finish().unwrap(); // Visible part: region x in [0,6], y in [2,8] under the hypotenuse x + y <= 8. // Area = ∫_{y=2}^{8} (8 - y) dy = 18. assert!((sum(&v) - 18.0).abs() < 1e-3, "{}", sum(&v)); @@ -266,7 +274,7 @@ mod tests { #[test] fn circle_area_via_pen() { use skrifa::outline::OutlinePen; - let mut c = Coverage::new(40, 40); + let mut c = Coverage::try_new(40, 40).unwrap(); { let mut p = Pen::new(&mut c, Xform([1.0, 0.0, 0.0, -1.0, 20.0, 20.0])); // Cubic circle approximation, r = 15. @@ -278,7 +286,7 @@ mod tests { p.curve_to(k, -r, r, -k, r, 0.0); p.close(); } - let a = sum(&c.finish()); + let a = sum(&c.finish().unwrap()); let exact = std::f32::consts::PI * 225.0; assert!((a - exact).abs() / exact < 0.005, "{a} vs {exact}"); } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..51b9e1e5db 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -18,6 +18,38 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0; pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the +/// accumulator and the output surface are both `stride` deep, and a faux-bold pass holds a second +/// coverage buffer, so a CMYK bold render reserves several times what a plain grey one does for +/// the same pixels. +const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + +/// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the +/// output surface (both `stride` deep), the finished coverage of the colour being drawn and, with +/// faux bold, the second coverage buffer and the vector its `finish` builds beside it. `false` +/// when the arithmetic overflows, so an absurd size is refused rather than wrapping. +fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool { + let f32s = size_of::() as u64; + let peak = (w as u64).checked_mul(h as u64).and_then(|px| { + let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?; + let coverage = px.checked_mul(if bold { 3 } else { 1 })?.checked_mul(f32s)?; + deep.checked_add(coverage) + }); + peak.is_some_and(|b| b <= max_bytes) +} + +/// A zeroed buffer, or `None` when its bytes cannot be reserved. +fn zeroed(len: usize) -> Option> { + let mut v = Vec::new(); + v.try_reserve_exact(len).ok()?; + v.resize(len, 0.0); + Some(v) +} + +/// What a refused or unrenderable layout returns. +fn nothing(format: PixelFormat) -> Rendered { + Rendered { surface: Surface::new(format), rect: Rect::new(0, 0, 0, 0) } +} /// Rendered text: pixels in document space plus the covered rectangle. pub struct Rendered { @@ -252,8 +284,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } let n = format.mode.color_channels(); let stride = n + 1; + let bold = layout.glyphs.iter().any(|g| is_bold(layout, g)); + if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) { + return nothing(format); + } // Premultiplied accumulation. - let mut acc = vec![0.0f32; w * h * stride]; + let Some(mut acc) = zeroed(w * h * stride) else { + return nothing(format); + }; let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); let mut colors: Vec = Vec::new(); for st in &layout.styles { @@ -262,14 +300,23 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } } for c in &colors { - let mut cov = Coverage::new(w, h); + let Some(mut cov) = Coverage::try_new(w, h) else { + return nothing(format); + }; draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); - let mut cov = cov.finish(); - if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + let Some(mut cov) = cov.finish() else { + return nothing(format); + }; + if bold { for k in 0..BOLD_OFFSETS.len() { - let mut bc = Coverage::new(w, h); + let Some(mut bc) = Coverage::try_new(w, h) else { + return nothing(format); + }; draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); - for (a, b) in cov.iter_mut().zip(bc.finish()) { + let Some(bold_cov) = bc.finish() else { + return nothing(format); + }; + for (a, b) in cov.iter_mut().zip(bold_cov) { *a = a.max(b); } } @@ -398,7 +445,8 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> #[cfg(test)] mod tests { - use super::{MAX_PIXELS, rect_from_bounds}; + use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed}; + use photocraft_color::SampleType; use photocraft_geom::Rect; #[test] @@ -419,4 +467,25 @@ mod tests { assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}"); } } + + /// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel + /// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers. + /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 4 = 14 bytes a pixel live. + #[test] + fn the_byte_budget_follows_channels_depth_and_faux_bold() { + let px = 100 * 100; + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14)); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14 - 1), "the allowance is exact"); + + // The same pixels cost more with more channels, a wider sample, or a bold pass. + assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 14), "CMYK is deeper"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 14), "f32 output is wider"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 14), "bold holds two more buffers"); + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel"); + + // A size whose peak overflows is refused rather than wrapping to a small number. + assert!(!fits_raster_budget(usize::MAX, usize::MAX, 5, SampleType::F32, true, u64::MAX)); + // Nothing is reserved for a buffer that cannot be served. + assert!(zeroed(usize::MAX).is_none()); + } } From 181bd3be396ce03a02c8cd77a5c308fe44c5561d Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Thu, 15 Oct 2026 07:14:15 +0100 Subject: [PATCH 0181/1452] fix(count): reject unknown target groups before adding markers --- crates/engine/src/analysis_cmds.rs | 7 +++- crates/engine/src/analysis_cmds/tests.rs | 44 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..d4bd57b8c0 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -755,7 +755,12 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if !at[0].is_finite() || !at[1].is_finite() { return Err(bad("count.add", "coordinates must be finite")); } - let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize); + let gi = p.get("group").and_then(Value::as_u64).map(|v| usize::try_from(v).map_err(|_| bad("count.add", "no such group"))).transpose()?; + // The first marker creates group zero, but an explicit target must never be redirected. + let group_count = doc(s)?.measurement.count_groups.len().max(1); + if gi.is_some_and(|i| i >= group_count) { + return Err(bad("count.add", "no such group")); + } let n = s.edit("Count", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..4fdd9d64c3 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -449,3 +449,47 @@ fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); assert_eq!(s.active().unwrap().history.past_len(), past); } + +#[test] +fn count_add_rejects_unknown_groups_without_changing_the_document() { + for groups in [0, 1, 3] { + for group in [(groups as u64).max(1), u64::MAX] { + let mut s = session(10, 10, 8); + for _ in 0..groups { + s.execute("count.newGroup", json!({})).unwrap(); + } + let before = d(&s); + let history = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err(); + assert!(error.to_string().contains("no such group"), "{error}"); + assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker"); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.journal.len(), journal); + } + } +} + +#[test] +fn count_add_keeps_first_group_creation_and_targets_the_requested_group() { + let mut s = session(10, 10, 8); + // Group zero can be requested before the first marker creates it. + let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap(); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]])); + s.execute("count.newGroup", json!({})).unwrap(); + let before = d(&s).measurement.clone(); + let history = s.active().unwrap().history.past_len(); + let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]])); + assert_eq!(r["groups"][1]["count"], 0); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = d(&s).measurement.clone(); + assert!(s.undo()); + assert_eq!(d(&s).measurement, before); + assert!(s.redo()); + assert_eq!(d(&s).measurement, after); + // Omitting the group still uses the active group. + let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap(); + assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]])); +} From 5b39b2b3af04c84311fe715775acdfa6ef49eaab Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:18:19 +0200 Subject: [PATCH 0182/1452] =?UTF-8?q?Help=20=E2=80=BA=20Search:=20typed=20?= =?UTF-8?q?letters=20go=20to=20the=20field,=20not=20to=20menu=20mnemonics?= =?UTF-8?q?=20(#5996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an open menu a plain letter runs the row with that mnemonic. Typing into Help › Search did too: "S" ran System Info and "A" opened the ArtCraft website, so only "De" of "Deselect" could be typed. Mnemonics now wait while a text field in the menu has the keyboard. --- crates/ui-egui/src/menu_keys.rs | 6 ++++-- crates/ui-egui/src/menus.rs | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..7fe6c5e388 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -207,8 +207,10 @@ impl Keys { return; } if open { - // A letter runs the row with that mnemonic in the level the arrows are in. - let letter = events.iter().find_map(|e| match e { + // A letter runs the row with that mnemonic in the level the arrows are in, unless it is + // typed into a field in the menu: Help › Search took "S" for System Info (#5996). + let typing = ctx.text_edit_focused(); + let letter = events.iter().filter(|_| !typing).find_map(|e| match e { Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { key_char(*key).map(|c| (*key, c)) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..4db0d61855 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1956,6 +1956,44 @@ mod tests { } } + /// #5996: a real key press comes as a key and its text. A letter typed into Help › Search is + /// text for the field, not a mnemonic: "S" ran System Info and "A" the ArtCraft website. + #[test] + fn help_search_letters_are_not_mnemonics() { + use egui::{Event, Key, Modifiers, PointerButton}; + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) + }); + h.run_steps(4); + let help = h.get_by_label("Help").rect().center(); + h.event(Event::PointerMoved(help)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: help, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "Help opened"); + for c in "DeSelect all".chars() { + let key = Key::from_name(&c.to_ascii_uppercase().to_string()); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } + h.event(Event::Text(c.to_string())); + h.run_steps(1); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + } + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "typing keeps Help open"); + let query: String = h.ctx.data(|d| d.get_temp(egui::Id::new("help-menu-search"))).unwrap_or_default(); + assert_eq!(query, "DeSelect all", "every letter reached the field"); + assert!(h.state().ui.dialogs.is_empty(), "no menu command ran: {:?}", h.state().ui.dialogs); + } + #[test] fn dropdown_items_have_room() { use egui_kittest::{Harness, kittest::Queryable}; From 494308a038d6c4c31b4c05d444f67fa9ac06978c Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:28:55 +0200 Subject: [PATCH 0183/1452] Image Size: Indexed Color resamples with Nearest Neighbor only (#6019) An Indexed Color document holds only its colour table's colours, and a save quantises to them. Interpolating methods showed blended colours on screen that the saved file then lost. image.imageSize now resamples an Indexed document with Nearest Neighbor whatever method it is given, and the dialog offers only Automatic and Nearest Neighbor for one. --- crates/engine/src/image_cmds.rs | 27 ++++++++++++++++++++++ crates/ui-egui/src/sizing.rs | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..5f37a2eaa8 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -171,6 +171,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); + // An Indexed Color document holds only its colour table's colours, and a save quantises to + // them: interpolation showed blends that the saved file didn't have (#6019). + let resample = match resample { + Method::Filter(_) | Method::Automatic if d.doc.mode == ColorMode::Indexed => Method::Filter(Resample::Nearest), + m => m, + }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. @@ -943,6 +949,27 @@ mod tests { /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands /// without a budget. It is refused before any allocation and the document is untouched; a /// change of resolution only, and an ordinary resize, still work. + #[test] + fn image_size_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in ["bicubic", "automatic", "bilinear", "lanczos", "preserveDetails"] { + s.execute("image.imageSize", json!({"width": 8, "resample": resample})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + #[test] fn image_size_refuses_a_raster_past_the_budget() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 3c48e041f4..c3ec7a199b 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -70,7 +70,7 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); - let resample = pref_resample(app.session.prefs().general.image_interpolation); + let mut resample = pref_resample(app.session.prefs().general.image_interpolation); let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); @@ -88,6 +88,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); if command == "image.imageSize" { + // Indexed Color resamples with Nearest Neighbor only, as the command does (#6019). + let indexed = d.mode == photocraft_color::ColorMode::Indexed; + if indexed && resample != "automatic" { + resample = "nearest"; + } + f.insert("__indexed".into(), json!(indexed)); f.insert("__constrain".into(), json!(true)); f.insert("resolution".into(), json!(res)); f.insert("resample".into(), json!(resample)); @@ -321,6 +327,10 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { ("bilinear".to_string(), tl!("Bilinear")), ("lanczos".to_string(), tl!("Lanczos (sharp)")), ]; + // Indexed Color: every other method blends colours the colour table doesn't have, + // which the command doesn't use and a save quantises away (#6019). + let indexed = f.get("__indexed").and_then(Value::as_bool) == Some(true); + let opts: Vec<_> = opts.into_iter().filter(|(k, _)| !indexed || k == "automatic" || k == "nearest").collect(); if crate::widgets::dropdown(ui, "is-resample", &mut cur, &opts, 200.0) { f.insert("resample".into(), json!(cur)); } @@ -517,6 +527,34 @@ mod tests { /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). + #[test] + fn indexed_color_resamples_with_nearest_neighbor_only() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.execute("image.mode.indexedColor", json!({})).unwrap(); + for (pref, method) in [("bicubic", "nearest"), ("bicubicAutomatic", "automatic"), ("nearestNeighbor", "nearest")] { + app.session.execute("prefs.set", json!({"path": "general.imageInterpolation", "value": pref})).unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f.get("resample"), Some(&json!(method)), "{pref}"); + assert_eq!(f.get("__indexed"), Some(&json!(true))); + app.ui.close_dialog(id); + } + // The open list offers only Automatic and Nearest Neighbor. + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), "image.imageSize").unwrap(); + h.run_steps(3); + let combo = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Nearest Neighbor (hard edges)")); + combo.expect("the Resample list shows Nearest Neighbor").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("Automatic").is_some(), "Automatic is offered"); + for gone in ["Bicubic", "Bilinear", "Lanczos", "Preserve Details"] { + assert!(h.query_by_label_contains(gone).is_none(), "{gone} is not offered for Indexed Color"); + } + } + #[test] fn resample_follows_the_interpolation_preference() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From d743c2532da53ac69a85da9fcf01a6663cb7481b Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 17:07:13 +0800 Subject: [PATCH 0184/1452] perf(compose): bevel maps shade, chisel and mix rows on threads, results unchanged A chisel bevel size change rebuilds the bevel maps of the whole layer region. The distance fields already run on threads (#2119), but the passes after them still ran on one thread over every pixel: the chisel height from the two distances, the texture term, the highlight/shadow shading (once, or twice for emboss styles) and the emboss inside/outside mix. Each of these computes a pixel from its own inputs only (the height map, the shape, the distances), so they now go row by row through par_units / par_fill like the fields: the same arithmetic in the same order per pixel, so the maps are bit for bit the old ones whatever the thread count. Maps under 65536 pixels and wasm stay on the calling thread. Test: bevel_maps_do_not_depend_on_the_threads (4 shapes x 5 styles x 3 techniques, with soften and a texture, 1 vs 2, 5 and 16 threads, every map bit-equal). --- crates/compose/src/effects.rs | 136 ++++++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..ad6b763136 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1511,11 +1511,15 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern } else { let sharp = sharp_edges(shape); let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); - let mut h = Map::new(shape.w, shape.h, 0.0); let soft = g.chisel_soft > 0.0; - for i in 0..h.v.len() { - h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); - } + let w = shape.w; + let v = par_fill(w, shape.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); + } + }); + let mut h = Map { w, h: shape.h, v }; // Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel // Hard's across a straight edge); emboss styles keep the blur until measured. if soft && g.paint == BevelPaint::Both { @@ -1536,14 +1540,23 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0); let k = t.depth / unit; let (x0, y0) = (tex.rect.x0, tex.rect.y0); - for y in 0..h.h { - for x in 0..h.w { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); - let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; - h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l }; - } - } + let w = h.w; + par_units( + h.v.len(), + &mut h.v, + w, + &mut [] as &mut [()], + 0, + || (), + |_, y, row, _| { + for (x, o) in row.iter_mut().enumerate() { + let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); + let p = tile.sample(u, v); + let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; + *o += k * if t.invert { 1.0 - l } else { l }; + } + }, + ); } if b.soften >= 1.0 { tent(&mut h, b.soften); @@ -1625,23 +1638,33 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat }; let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) { let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); - for y in 0..shape.h as i64 { - for x in 0..shape.w as i64 { - let i = y as usize * shape.w + x as usize; - let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; - let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; - let n = [-gx, -gy, 1.0]; - let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); - let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; - let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; - let k = shade - se; - if k > 0.0 { - hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; - } else { - sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + // Rows on threads: every pixel reads only `hmap` and `shape`. + par_units( + hi.v.len(), + &mut hi.v, + shape.w, + &mut sh.v, + shape.w, + || (), + |_, y, hi_row, sh_row| { + let y = y as i64; + for x in 0..shape.w as i64 { + let i = y as usize * shape.w + x as usize; + let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; + let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; + let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; + let k = shade - se; + if k > 0.0 { + hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; + } else { + sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + } } - } - } + }, + ); (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) }; let maps = match g.paint { @@ -1657,7 +1680,13 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let (hi, sh) = shade_into(g.depth, 0); let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1); let mix = |a: Map, b: Map| -> Map { - let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect(); + let v = par_fill(a.w, a.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * a.w + x; + let (x, y, s) = (a.v[i], b.v[i], shape.v[i]); + *o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0)); + } + }); Map { w: a.w, h: a.h, v } }; vec![mix(hi, ho), mix(sh, so)] @@ -2932,6 +2961,55 @@ mod tests { } } + #[test] + fn bevel_maps_do_not_depend_on_the_threads() { + // Every style and technique, softened or not, and a texture; one thread (in order) is the reference. + let mut patchy = aa_disc(101, 101, 40.0); + for (i, v) in patchy.v.iter_mut().enumerate() { + if (i % 101 / 13 + i / 101 / 17) % 5 == 0 { + *v *= 0.6; + } + } + let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy]; + let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]); + sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]); + let pats = [Pattern::new("stripes", sf, 4, 4)]; + let light = GlobalLight::default(); + let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss]; + for (k, shape) in shapes.iter().enumerate() { + let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) }; + for style in styles { + for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] { + for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] { + let mut b = bevel_of(style, technique); + (b.size, b.soften) = (size, soften); + if textured { + b.texture = Some(photocraft_doc::BevelTexture { + name: "stripes".into(), + id: String::new(), + scale: 1.0, + depth: 1.0, + invert: false, + link: true, + phase: (0.0, 0.0), + }); + } + let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0; + let one = with_threads(1, run); + for threads in [2, 5, 16] { + let many = with_threads(threads, run); + assert_eq!(one.len(), many.len()); + for (j, (a, m)) in one.iter().zip(&many).enumerate() { + assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads"); + } + } + } + } + } + } + } + /// The four fields that use the distance transform, of `m`. fn distance_field_of(m: &Map, reach: f32) -> Vec> { [FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside] From 5a93e93a4481aff65d495bf49ac05adcfe0ea4bb Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 19:13:21 +0800 Subject: [PATCH 0185/1452] perf(gpu): a chisel bevel's two fields find their sharp edges once On the GPU canvas a bevel size change recomputes BevelInside and BevelOutside on the CPU, one distance_field call each, and each call ran sharp_edges (edge pixels and refined normals) on the same window again; the CPU compositor already shares them (bevel_height). sharp_edges was ~17% of the CPU time of those size changes. When both fields are recomputed over the same rectangle at the same reach (a size change, a new shape), sync_fx now computes them together: compose::effects::bevel_distance_fields finds the sharp edges once and returns both. Same window, same functions, so both fields are bit for bit the ones computed one at a time; the chamfer fields, wasm and a single bevel field keep the old path. Test: paired_bevel_fields_equal_the_fields_one_at_a_time (reach 3, 9, 40; whole region and two parts; bit-equal to field() of each kind). --- crates/compose/src/effects.rs | 8 +++++++ crates/gpu/src/fx.rs | 39 +++++++++++++++++++++++++++++++++++ crates/gpu/src/lib.rs | 31 ++++++++++++++++++---------- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index ad6b763136..d4f65dcb40 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2319,6 +2319,14 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac } } +/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its +/// [`distance_field`], with the sharp edges they both start from found once. +pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { + let s = Map { w, h, v: alpha }; + let sharp = sharp_edges(&s); + [bevel_dist_inside(&s, &sharp, reach), bevel_dist_outside(&s, &sharp, reach)] +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..4b4ae5d575 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -558,6 +558,21 @@ fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: R parts.concat() } +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`: one window, +/// whose sharp edges are found once instead of once per field. +pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { + let [inside, outside] = [FieldKind::BevelInside, FieldKind::BevelOutside]; + if !threaded_inside(inside) { + return [inside, outside].map(|k| field(k, reach, shape, region, out)); + } + let win = out.inflate(field_radius(inside, reach) + 1).intersect(®ion); + if win.is_empty() { + return [Vec::new(), Vec::new()]; + } + let f = photocraft_compose::effects::bevel_distance_fields(crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); + if win == out { f } else { f.map(|f| crop(&f, win, out)) } +} + /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec { if r.is_empty() { @@ -842,6 +857,30 @@ mod tests { assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + /// Both bevel fields from one window (sharp edges found once) are bit for bit the fields + /// computed one at a time, over the whole region and over a part of it. + #[test] + fn paired_bevel_fields_equal_the_fields_one_at_a_time() { + let region = Rect::new(-20, -10, 300, 290); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut shape = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0); + let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos(); + shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; + } + } + let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); + for reach in [3, 9, 40] { + for out in [region, Rect::new(40, 30, 170, 120), Rect::new(-20, 200, 300, 290)] { + let [inside, outside] = bevel_fields(reach, &shape, region, out); + assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); + assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + } + } + } + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8f8839b9bd..a90258b4b3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1814,19 +1814,28 @@ impl Compositor { layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); let want = field_reaches(&progs); e.fields.retain(|k, _| want.contains_key(k)); - for (&kind, &reach) in &want { + // Each field to recompute: over which rectangle, exact up to which reach. + let jobs: Vec<(FieldKind, Rect, i32)> = want + .iter() + .filter_map(|(&kind, &reach)| match e.fields.get(&kind).map(|(r, _)| *r) { + Some(r) if r >= reach => (!damage.is_empty()).then(|| (kind, damage.inflate(fx::field_radius(kind, r)).intersect(®ion), r)), + _ => Some((kind, region, reach)), + }) + .collect(); + // A chiselled bevel's two fields over the same rectangle share their window's sharp edges. + let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); + let mut bevel = match (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)) { + (Some((out, reach)), b) if b == Some((out, reach)) => fx::bevel_fields(reach, &e.shape_cpu, region, out).map(Some), + _ => [None, None], + }; + for (kind, out, reach) in jobs { let have = e.fields.get(&kind).map(|(r, _)| *r); - let out = match have { - Some(r) if r >= reach => { - if damage.is_empty() { - continue; - } - damage.inflate(fx::field_radius(kind, r)).intersect(®ion) - } - _ => region, + let pair = match kind { + FieldKind::BevelInside => bevel[0].take(), + FieldKind::BevelOutside => bevel[1].take(), + _ => None, }; - let reach = have.filter(|r| *r >= reach).unwrap_or(reach); - let v = fx::field(kind, reach, &e.shape_cpu, region, out); + let v = pair.unwrap_or_else(|| fx::field(kind, reach, &e.shape_cpu, region, out)); if have.is_none_or(|r| r < reach) { e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); } From 900968367138c6ffb00fb5ca0114bab684dc61b3 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 19:27:16 +0800 Subject: [PATCH 0186/1452] refactor(gpu): bevel fields are asked for, windowed and checked as one pair - the bevel program asks for both fields with B::bevel_fields, so they always come with the same reach; - field and bevel_fields share one windowing (fields_in_windows: one window, or bands on wasm), so wasm pairs them per band instead of taking a separate path; - compose's bevel_height and bevel_distance_fields share bevel_dists; - sync_fx recomputes the pair together and returns Unsupported when the two are not over the same rectangle at the same reach, instead of falling back to one at a time. Test: paired_bevel_fields_equal_the_fields_one_at_a_time also checks the banded path. --- crates/compose/src/effects.rs | 13 +++--- crates/gpu/src/fx.rs | 82 ++++++++++++++++++++++++----------- crates/gpu/src/lib.rs | 54 +++++++++++++---------- 3 files changed, 94 insertions(+), 55 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d4f65dcb40..650651e963 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1509,8 +1509,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern tent(&mut h, g.width); h } else { - let sharp = sharp_edges(shape); - let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); + let [din, dout] = bevel_dists(shape, size); let soft = g.chisel_soft > 0.0; let w = shape.w; let v = par_fill(w, shape.h, 0.0, |y, row| { @@ -2322,9 +2321,13 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac /// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its /// [`distance_field`], with the sharp edges they both start from found once. pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { - let s = Map { w, h, v: alpha }; - let sharp = sharp_edges(&s); - [bevel_dist_inside(&s, &sharp, reach), bevel_dist_outside(&s, &sharp, reach)] + bevel_dists(&Map { w, h, v: alpha }, reach) +} + +/// The inside and outside bevel distances of `s`, from one set of sharp edges. +fn bevel_dists(s: &Map, reach: f32) -> [Vec; 2] { + let sharp = sharp_edges(s); + [bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)] } #[cfg(test)] diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 4b4ae5d575..35691c2114 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -203,6 +203,11 @@ impl B { self.fields.push((kind, reach_for(width))); In::Field(kind) } + /// A chiselled bevel's inside and outside fields, always asked for together ([`bevel_fields`] + /// computes them as one). + fn bevel_fields(&mut self, width: f32) -> [In; 2] { + [FieldKind::BevelInside, FieldKind::BevelOutside].map(|k| self.field(k, width)) + } /// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map /// are skipped. fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In { @@ -374,8 +379,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, let mut h = if bv.technique == BevelTechnique::Smooth { b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) } else { - let din = b.field(FieldKind::BevelInside, size); - let dout = b.field(FieldKind::BevelOutside, size); + let [din, dout] = b.bevel_fields(size); let soft = g.chisel_soft > 0.0; let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0)); // As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp. @@ -542,35 +546,51 @@ fn threaded_inside(kind: FieldKind) -> bool { /// fields run on all threads inside `distance_field`, from one window. That is also the window the CPU /// compositor computes them from, so a rebuilt field is exactly the one it gives. fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect, halo: i32) -> Vec { - if threaded_inside(kind) { - let win = out.inflate(halo).intersect(®ion); - if win.is_empty() { - return Vec::new(); - } - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - return if win == out { f } else { crop(&f, win, out) }; - } - let parts = par_map(bands(out, BAND.max(halo)), |band| { - let win = band.inflate(halo).intersect(®ion); - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - crop(&f, win, band) + let [f] = fields_in_windows(threaded_inside(kind), shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] }); - parts.concat() + f } -/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`: one window, -/// whose sharp edges are found once instead of once per field. +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`, with the +/// sharp edges of each window found once (the two kinds have the same radius and threading). pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { - let [inside, outside] = [FieldKind::BevelInside, FieldKind::BevelOutside]; - if !threaded_inside(inside) { - return [inside, outside].map(|k| field(k, reach, shape, region, out)); + let kind = FieldKind::BevelInside; + fields_in_windows(threaded_inside(kind), shape, region, out, field_radius(kind, reach) + 1, |s, w, h| { + photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32) + }) +} + +/// The fields `compute` gives for a `w × h` window of the region-sized `shape`, over `out` (inside +/// `region`), each window grown by `halo`: `out` as one window when `whole` (the field spreads over +/// threads itself), else bands of it in parallel. +fn fields_in_windows( + whole: bool, + shape: &[f32], + region: Rect, + out: Rect, + halo: i32, + compute: impl Fn(Vec, usize, usize) -> [Vec; N] + Sync + Send, +) -> [Vec; N] { + let part = |r: Rect| -> [Vec; N] { + let win = r.inflate(halo).intersect(®ion); + if win.is_empty() { + return std::array::from_fn(|_| Vec::new()); + } + let f = compute(crop(shape, region, win), win.width() as usize, win.height() as usize); + if win == r { f } else { f.map(|f| crop(&f, win, r)) } + }; + if whole { + return part(out); } - let win = out.inflate(field_radius(inside, reach) + 1).intersect(®ion); - if win.is_empty() { - return [Vec::new(), Vec::new()]; - } - let f = photocraft_compose::effects::bevel_distance_fields(crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - if win == out { f } else { f.map(|f| crop(&f, win, out)) } + let parts = par_map(bands(out, BAND.max(halo)), part); + std::array::from_fn(|k| { + let mut f = Vec::with_capacity(parts.iter().map(|p| p[k].len()).sum()); + for p in &parts { + f.extend_from_slice(&p[k]); + } + f + }) } /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. @@ -877,6 +897,16 @@ mod tests { let [inside, outside] = bevel_fields(reach, &shape, region, out); assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + // The banded path (wasm) pairs them per band the same way. + let halo = field_radius(FieldKind::BevelInside, reach) + 1; + let banded = + fields_in_windows(false, &shape, region, out, halo, |s, w, h| photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32)); + for (k, kind) in [FieldKind::BevelInside, FieldKind::BevelOutside].into_iter().enumerate() { + let [one] = fields_in_windows(false, &shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] + }); + assert_eq!(bits(&banded[k]), bits(&one), "banded {kind:?}, reach {reach}, {out:?}"); + } } } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a90258b4b3..87e7963d71 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1073,7 +1073,7 @@ impl Compositor { let aprons: Vec = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect(); for (f, &pg) in plan.fx.iter().zip(&paged) { if !pg { - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?; } } let fr = self.frame_res(device, queue, canvas, &plan); @@ -1104,7 +1104,7 @@ impl Compositor { continue; } let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region); - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?; } let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); @@ -1715,7 +1715,7 @@ impl Compositor { key: FxKey, region: Rect, stats: &mut Stats, - ) { + ) -> Result<(), Unsupported> { let layer = f.layer; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); @@ -1786,13 +1786,13 @@ impl Compositor { } // Present: kept or (re)inserted above. let stamp = self.stamp; - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; e.last_used = frame; e.stamp = stamp; e.doc = doc.id; e.tiles = [content_src.map(fx::snapshot), mask_src.map(fx::snapshot)]; if region.is_empty() { - return; + return Ok(()); } let t_trace = web_time_now(); @@ -1822,28 +1822,33 @@ impl Compositor { _ => Some((kind, region, reach)), }) .collect(); - // A chiselled bevel's two fields over the same rectangle share their window's sharp edges. + // A chiselled bevel's two fields are asked for and kept together (`fx::bevel_fields`), so they + // are recomputed together, over one rectangle at one reach. let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); - let mut bevel = match (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)) { - (Some((out, reach)), b) if b == Some((out, reach)) => fx::bevel_fields(reach, &e.shape_cpu, region, out).map(Some), - _ => [None, None], - }; + let (bevel_in, bevel_out) = (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)); + if bevel_in != bevel_out { + return Err(Unsupported(format!("bevel fields of `{}` out of step: inside {bevel_in:?}, outside {bevel_out:?}", layer.name))); + } for (kind, out, reach) in jobs { - let have = e.fields.get(&kind).map(|(r, _)| *r); - let pair = match kind { - FieldKind::BevelInside => bevel[0].take(), - FieldKind::BevelOutside => bevel[1].take(), - _ => None, + let fields = match kind { + FieldKind::BevelInside => { + let [inside, outside] = fx::bevel_fields(reach, &e.shape_cpu, region, out); + vec![(FieldKind::BevelInside, inside), (FieldKind::BevelOutside, outside)] + } + // Computed with BevelInside, over the same rectangle at the same reach (checked above). + FieldKind::BevelOutside => continue, + _ => vec![(kind, fx::field(kind, reach, &e.shape_cpu, region, out))], }; - let v = pair.unwrap_or_else(|| fx::field(kind, reach, &e.shape_cpu, region, out)); - if have.is_none_or(|r| r < reach) { - e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + for (kind, v) in fields { + if e.fields.get(&kind).is_none_or(|(r, _)| *r < reach) { + e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + } + if let Some((_, t)) = e.fields.get(&kind) { + t.write_r32(queue, region, out, &v); + } + staged += v.len() as u64 * 4; + stats.fx_pixels += out.width() as u64 * out.height() as u64; } - if let Some((_, t)) = e.fields.get(&kind) { - t.write_r32(queue, region, out, &v); - } - staged += v.len() as u64 * 4; - stats.fx_pixels += out.width() as u64 * out.height() as u64; } if let Some(t) = t_trace.filter(|_| !damage.is_empty()) { @@ -1858,7 +1863,7 @@ impl Compositor { if prog.maps == 0 { continue; } - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; let same = e.progs[i].key == prog.key && e.progs[i].maps.len() == prog.maps; let d = if same { damage } else { region }; if d.is_empty() { @@ -1873,6 +1878,7 @@ impl Compositor { stats.fx_programs += 1; self.run_program(device, queue, encoder, key, i, prog, d, stats); } + Ok(()) } /// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`. From 510636b19cea3c0e3bcac860c92dee7808eec7d1 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:34:09 +0200 Subject: [PATCH 0187/1452] Type: Korean paragraph text wraps between words, not syllables (#5959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode's line-breaking rules allow a break between any two Hangul syllables, so Korean box text split words (설 / 정이나). Korean is set with breaks at the spaces between words, as Photoshop does: runs of Hangul now lay out with WordBreak::KeepAll. Chinese and Japanese keep breaking between characters. --- crates/text/src/layout.rs | 22 ++++++++++++++++++++++ crates/text/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..a09e323ab5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -629,6 +629,11 @@ impl Layouter { for (range, size) in &synthetic_small_caps { b.push(StyleProperty::FontSize(*size), range.clone()); } + // Korean breaks lines at spaces, between words, as Photoshop sets it; Unicode's + // default rules break between any two Hangul syllables, mid-word (#5959). + for range in hangul_runs(&ptext) { + b.push(StyleProperty::WordBreak(parley::WordBreak::KeepAll), range); + } for (&at, &spacing) in tabs.iter().zip(tab_spacing) { b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } @@ -1375,6 +1380,23 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option { best } +/// Hangul (syllables and jamo). +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7AF}' | '\u{D7B0}'..='\u{D7FF}') +} + +/// The byte ranges of `text` made of Hangul, each as long as it runs. +pub(crate) fn hangul_runs(text: &str) -> Vec> { + let mut runs: Vec> = Vec::new(); + for (i, c) in text.char_indices().filter(|&(_, c)| is_hangul(c)) { + match runs.last_mut() { + Some(r) if r.end == i => r.end = i + c.len_utf8(), + _ => runs.push(i..i + c.len_utf8()), + } + } + runs +} + #[cfg(test)] mod tests { use super::{SmallCapsMode, feature_list, small_caps_mode, variation_list}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..83d195b517 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1452,6 +1452,23 @@ fn japanese_box_text_preserves_wrap_boundaries() { assert_eq!(end, text.text.len()); } +/// #5959: Korean box text broke between Hangul syllables, mid-word ("설 / 정이나"). Lines break at +/// the spaces between words, as Photoshop sets Korean; a word longer than the box still breaks. +#[test] +fn korean_box_text_wraps_between_words() { + let mut engine = TextEngine::new(); + let mut text = point("여기서는 각종 설정이나 캐릭터 제작을 진행합니다.", 12.0); + text.shape = TextShape::Box { x: 0.0, y: 0.0, width: 60.0, height: 1000.0 }; + let layout = engine.layout(&text, 72.0); + assert!(layout.lines.len() > 2, "{}", layout.lines.len()); + let words: Vec<&str> = text.text.split(' ').collect(); + for line in &layout.lines { + let content = text.text.get(line.range.clone()).expect("UTF-8 line boundaries").trim(); + assert!(content.split(' ').all(|w| words.contains(&w)), "a line holds whole words only: {content:?}"); + } + assert_eq!(super::layout::hangul_runs("a 한국어 b"), vec![2..11]); +} + /// Which craft-fonts family drew the glyphs of `l` (matched by the embedded bytes). fn craft_family_of(l: &crate::TextLayout) -> Vec<&'static str> { let mut v = Vec::new(); From 116a60769712c7624e94b90b1553abc139807f16 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:41:16 +0200 Subject: [PATCH 0188/1452] =?UTF-8?q?Window=20=E2=80=BA=20Arrange:=20tiles?= =?UTF-8?q?=20keep=20their=20documents,=20and=20every=20tile=20has=20ruler?= =?UTF-8?q?s=20(#6017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With documents tiled side by side, a click into the other one moved it to the first tile, swapping the two, and only the active tile showed rulers. Each document now keeps its tile (with more documents than tiles, the shown run slides just enough to include the active one), and inactive tiles and floating windows draw their own document's rulers. Guides and the zero point are still dragged in the active view. --- crates/ui-egui/src/canvas.rs | 14 +++++-- crates/ui-egui/src/rulers.rs | 42 ++++++++++++++++---- crates/ui-egui/src/view_cmds/tests.rs | 57 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..6e9a65b072 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2020,9 +2020,11 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option = (0..n).map(|k| (idx + k) % n).zip(cells).collect(); - shown.sort_by_key(|(d, _)| *d); - Some(shown) + // Each document keeps its tile: a click into another one activates it where it is (#6017; it + // used to move to the first tile). With more documents than tiles, the shown run slides + // only as far as it must to include the active one. + let start = (idx + 1).saturating_sub(cells.len()); + Some((start..n).zip(cells).collect()) } fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -2904,7 +2906,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::monitor_status::check_window(app, &ctx); } let full = rect; - let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect }; + // Every document view has its rulers, the tiles of Window › Arrange and floating windows + // too, as in Photoshop (#6017). + let rect = crate::rulers::content_rect(app, rect); let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; if view.doc_size != size { @@ -4087,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } if primary && app.ui.extras.rulers { crate::rulers::draw_rulers(app, ui, full, &xf); + } else if app.ui.extras.rulers { + crate::rulers::paint_rulers(app, ui, full, &xf, &doc); } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 61ea25d96f..004b4cb321 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -329,10 +329,14 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64, zero: [f6 /// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks /// sit at tenths of the step. fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) { + ruler_scale_of(app, app.session.active().map(|d| &*d.doc), xf, y_axis) +} + +/// [`ruler_scale`] for the view of `doc`. +fn ruler_scale_of(app: &PhotocraftApp, doc: Option<&photocraft_doc::Document>, xf: &ViewXform, y_axis: bool) -> (f64, f64) { let ur = &app.session.prefs().units_and_rulers; let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); - let (dpi, size) = - app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); + let (dpi, size) = doc.map_or((72.0, [1.0, 1.0]), |d| (d.resolution_dpi.max(1.0) as f64, [d.size.width as f64, d.size.height as f64])); let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9); // The top ruler runs along document x, which pixel aspect correction stretches. let zoom = if y_axis { xf.zoom } else { xf.zoom_x() }; @@ -356,9 +360,16 @@ fn ruler_label(v: f64, step: f64, whole: bool) -> String { if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } } -/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the -/// pointer position marked; dragging out of a ruler creates a guide. -pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { +/// The rulers of `doc`'s view in `full`: background, ticks and labels from its zero point. +/// Returns the top, left and corner rects, the painter, the zero point and the minor tick sizes. +#[allow(clippy::type_complexity)] +fn paint( + app: &PhotocraftApp, + ui: &egui::Ui, + full: Rect, + xf: &ViewXform, + doc: &photocraft_doc::Document, +) -> (Rect, Rect, Rect, egui::Painter, [f64; 2], [f64; 2]) { let t = Tokens::get(ui.ctx()); let top = Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); let left = Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); @@ -376,11 +387,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let unit = app.session.prefs().units_and_rulers.rulers; let whole = unit == Unit::Pixels; // The zero point (#2955): rulers read 0 there and count the distance from it. - let origin = app.session.active().map_or([0.0, 0.0], |d| d.doc.ruler_origin); + let origin = doc.ruler_origin; let mut ticks = [1.0; 2]; for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); - let (px_per_unit, step) = ruler_scale(app, xf, vertical); + let (px_per_unit, step) = ruler_scale_of(app, Some(doc), xf, vertical); ticks[usize::from(vertical)] = step * px_per_unit / 10.0; let (u0, u1) = if vertical { ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit, origin) @@ -413,6 +424,23 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & v += step; } } + (top, left, corner, p, origin, ticks) +} + +/// Rulers for a document view that isn't the active one (another tile of Window › Arrange, a +/// floating window): shown as the active view's are, but guides and the zero point are dragged +/// in the active view (#6017). +pub fn paint_rulers(app: &PhotocraftApp, ui: &egui::Ui, full: Rect, xf: &ViewXform, doc: &photocraft_doc::Document) { + paint(app, ui, full, xf, doc); +} + +/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the +/// pointer position marked; dragging out of a ruler creates a guide. +pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let (top, left, corner, p, origin, ticks) = paint(app, ui, full, xf, &doc); + let t = Tokens::get(ui.ctx()); + let unit = app.session.prefs().units_and_rulers.rulers; // Pointer position markers. if let Some(h) = ui.ctx().pointer_hover_pos().filter(|h| full.contains(*h)) { let m = Stroke::new(1.0, t.text); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..ba1b61aa29 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,60 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +/// #6017: a click into another tile activated that document and moved it to the first tile, so +/// the two side-by-side documents swapped places. Each document keeps its tile; with more +/// documents than tiles, the shown run slides just enough to include the active one. +#[test] +fn arranged_documents_keep_their_tiles() { + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let (mut app, _) = app_with(3); + app.ui.view.arrange = "tileAllVertically".into(); + for active in 0..3 { + app.session.set_active(active); + let shown = crate::canvas::arranged_cells(&app, rect).unwrap(); + let order: Vec = shown.iter().map(|(d, _)| *d).collect(); + assert_eq!(order, vec![0, 1, 2], "active {active}"); + assert!(shown.windows(2).all(|w| w[0].1.left() < w[1].1.left()), "document 0 on the left"); + } + app.ui.view.arrange = "twoUpVertical".into(); + let docs = |app: &mut PhotocraftApp, active: usize| { + app.session.set_active(active); + crate::canvas::arranged_cells(app, rect).unwrap().iter().map(|(d, _)| *d).collect::>() + }; + assert_eq!(docs(&mut app, 0), vec![0, 1]); + assert_eq!(docs(&mut app, 1), vec![0, 1], "the second tile's document stays on the right"); + assert_eq!(docs(&mut app, 2), vec![1, 2], "the third document slides in next to the second"); +} + +/// #6017: rulers showed in the active tile only; the other tile had none and no room for them. +#[test] +fn every_arranged_tile_has_rulers() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "tileAllVertically".into(); + app.ui.extras.rulers = true; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let right = h.state().last_canvas_rect.center().x; + // Ruler labels are 9 pt text: some lie in each half of the document area. + let mut labels = [0, 0]; + for s in &h.output().shapes { + if let egui::Shape::Text(t) = &s.shape + && t.galley.job.sections.first().is_some_and(|sec| sec.format.font_id.size == 9.0) + && t.galley.text().chars().all(|c| c.is_ascii_digit()) + { + labels[usize::from(t.pos.x > right)] += 1; + } + } + assert!(labels[0] > 0 && labels[1] > 0, "ruler labels per tile: {labels:?}"); +} From 3d95193ef6da13fcdfefa66c2d50647c470cd115 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Thu, 15 Oct 2026 14:51:12 +0700 Subject: [PATCH 0189/1452] fix(ui): keep Clone Stamp source preview crisp when zoomed in (#5995) --- crates/ui-egui/examples/snapshot.rs | 12 ++++++++++-- crates/ui-egui/src/canvas.rs | 27 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 31e4c60301..48d1f1c81f 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -9,6 +9,7 @@ //! `--system-theme light|dark|none` supplies a deterministic OS appearance for theme captures. //! //! `--safe-gpu` draws the canvas on the CPU path, like the app's `--safe-gpu` launch. +//! `--hardware-filtering` respects texture samplers; the default predictable renderer forces bilinear filtering. //! `--wayland-notice` previews the native file drag-and-drop guidance shown in Wayland sessions. //! `--custom-titlebar` draws the Windows/Linux title bar (caption buttons in the top bar). //! @@ -66,8 +67,15 @@ fn main() { let custom_titlebar = args.iter().any(|a| a == "--custom-titlebar"); // `--settle-ms N`: keep rendering frames for N ms before the capture (e.g. mid-job). let settle_ms: u64 = arg(&args, "--settle-ms").and_then(|s| s.parse().ok()).unwrap_or(0); - let mut harness = - egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| { + let mut render_options = eframe::egui_wgpu::RendererOptions::PREDICTABLE; + render_options.predictable_texture_filtering = !args.iter().any(|a| a == "--hardware-filtering"); + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(w, h)) + .with_pixels_per_point(scale) + .with_max_steps(64) + .with_render_options(render_options) + .wgpu() + .build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut services = services; services.is_wayland = wayland_notice; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..303a0260f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -88,10 +88,10 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie let image = display_image(display.as_deref(), &buf); match app.clone_preview.as_mut() { Some(cache) => { - cache.texture.set(image, TextureOptions::LINEAR); + cache.texture.set(image, canvas_texture_options(1)); cache.key = key; } - None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, TextureOptions::LINEAR) }), + None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }), } } let Some(cache) = &app.clone_preview else { return }; @@ -6925,6 +6925,29 @@ mod tests { assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_)))); } + #[test] + fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = 20.0; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + for invert in [false, true] { + app.session.presets.clone.overlay.invert = invert; + let mut output = ctx.run_ui(Default::default(), |ui| { + draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None); + }); + let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload"); + let options = upload.options; + output.textures_delta.clear(); + assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}"); + assert_eq!(options.minification, egui::TextureFilter::Linear, "zoomed-out previews remain filtered"); + } + } + #[test] fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { let ctx = egui::Context::default(); From ec2627dbbbfc5a6a6a9348999f31abbaf45a6319 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:57:59 +0700 Subject: [PATCH 0190/1452] fix(path): validate Make Selection parameters before rasterization --- crates/engine/src/vector_cmds.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..15c14e5a1c 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1091,9 +1091,25 @@ fn path_rename(s: &mut Session, p: &Value) -> Result { fn path_to_selection(s: &mut Session, p: &Value) -> Result { let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let feather = f64p(p, "feather").unwrap_or(0.0) as f32; - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let mode = SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace")); + // Validate before rasterizing or touching selection history. An unchecked feather value + // could silently become zero (negative/string), or expand into an excessive blur. + let feather = match p.get("feather") { + None => 0.0, + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, + }; + let aa = match p.get("antiAlias") { + None => true, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), + }; + let mode = match p.get("mode") { + None => SelectionMode::Replace, + Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), + _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), + }; let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); if !aa { From caceb01dda29c38e625bc6788751a808384a27e0 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:59:18 +0700 Subject: [PATCH 0191/1452] test(path): cover invalid Make Selection params and open-path modes --- crates/engine/src/vector_cmds/tests.rs | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..afbaf74495 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,6 +335,64 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } + +#[test] +fn path_make_selection_rejects_invalid_options_without_changing_history() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60], [12, 60]]}]}})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 12, "height": 12})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for opts in [ + json!({"feather": -1}), + json!({"feather": 1001}), + json!({"feather": 1e100}), + json!({"feather": "4"}), + json!({"feather": true}), + json!({"antiAlias": "false"}), + json!({"antiAlias": 1}), + json!({"mode": "union"}), + json!({"mode": 42}), + ] { + assert!(s.execute("path.toSelection", opts.clone()).is_err(), "accepted invalid Make Selection options: {opts}"); + assert_eq!(*doc(&s), before, "invalid Make Selection modified the document: {opts}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "invalid Make Selection changed history: {opts}"); + } +} + +#[test] +fn open_pen_paths_make_the_same_selection_as_closed_paths() { + // The pen's unfinished work path should be implicitly closed when rasterized into a selection. + let mut s = session(80, 80, 8); + let knots = json!([[12, 12], [52, 12], [52, 52], [12, 52]]); + for (aa, feather) in [(false, 0.0), (true, 0.0), (false, 3.0), (true, 3.0)] { + let mut masks = Vec::new(); + for closed in [false, true] { + s.execute("path.set", json!({"path": {"subpaths": [{"closed": closed, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"antiAlias": aa, "feather": feather, "mode": "replace"})).unwrap(); + masks.push(selection_mask(&s)); + } + assert_eq!(masks[0], masks[1], "open and closed paths differ (antiAlias={aa}, feather={feather})"); + assert!(masks[0][32 * 80 + 32] > 0.99); + assert_eq!(masks[0][4 * 80 + 4], 0.0); + if feather > 0.0 { + assert!(masks[0].iter().any(|v| *v > 0.0 && *v < 1.0), "feather did not soften the selection"); + } + } + + // All boolean operations should use the same selection semantics for an open Pen path. + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.rect", json!({"x": 30, "y": 30, "width": 34, "height": 25})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"mode": mode, "antiAlias": false})).unwrap(); + let mask = selection_mask(&s); + let at = |x: usize, y: usize| mask[y * 80 + x]; + assert_eq!(at(20, 20), if matches!(mode, "replace" | "add") { 1.0 } else { 0.0 }, "{mode}: path-only"); + assert_eq!(at(60, 40), if matches!(mode, "add" | "subtract") { 1.0 } else { 0.0 }, "{mode}: previous-only"); + assert_eq!(at(40, 40), if mode == "subtract" { 0.0 } else { 1.0 }, "{mode}: overlap"); + } +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); From 35b4b30f2d2422385a8fbe4fd6f7c0d07ad9c0fd Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:00:44 +0700 Subject: [PATCH 0192/1452] style(path): keep regression cases consistently spaced --- crates/engine/src/vector_cmds/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index afbaf74495..a2e8047115 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,7 +335,6 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } - #[test] fn path_make_selection_rejects_invalid_options_without_changing_history() { let mut s = session(80, 80, 8); From 827e4e70949d0620164b5d69791923be069fc189 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Thu, 15 Oct 2026 01:01:37 -0700 Subject: [PATCH 0193/1452] Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982) An entry's value only has to lie inside the file, so many Exif entries can point at the same bytes: a 62 KB TIFF with 1,000 such entries made the reader copy 50 MB (and the block writer copy it again), on every TIFF import and on export of a document carrying an EXIF block. read() now has a copy budget shared by IFD0's descriptive tags and the GPS and Exif directories: at most 4 MiB, and never more than the stream's own length, since the values of a well-formed stream are distinct bytes of it. An entry that does not fit is left out and later, smaller ones are still kept; the small directories are read first so a huge maker note cannot crowd out the GPS position. Maker notes of 200 KB and 1 MB round-trip as before. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh --- crates/codecs/src/codecs/tiff_exif.rs | 46 ++++++++++--- crates/codecs/tests/tiff_exif.rs | 94 +++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs index d07f555f30..00d47d0702 100644 --- a/crates/codecs/src/codecs/tiff_exif.rs +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -6,7 +6,9 @@ //! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own //! directories, writing one copies the block's directories into the file. Values are copied as //! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive. -//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. +//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is +//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can +//! overlap (see [`read`]). use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind}; use tiff::tags::{Tag, Type}; @@ -27,6 +29,11 @@ const TAG_INTEROP_IFD: u16 = 40965; const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432]; /// At most this many entries are copied from one directory. const MAX_FIELDS: usize = 1024; +/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF +/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for +/// that and for a few such notes. A stream never yields more than its own length either (see +/// [`read`]). +const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024; /// One entry with its value bytes in the byte order of the stream it came from. #[derive(Clone, Debug, PartialEq)] @@ -53,13 +60,22 @@ impl ExifDirs { } } -fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec { - ifd.entries - .iter() - .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) - .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() })) - .take(MAX_FIELDS) - .collect() +/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has +/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may +/// still be copied (shared by all the directories of one stream), and an entry whose value does +/// not fit in what is left is skipped, so later, smaller entries can still be kept. +fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec { + let mut out = Vec::new(); + for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) { + if out.len() >= MAX_FIELDS { + break; + } + let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue }; + let Some(left) = budget.checked_sub(value.len()) else { continue }; + *budget = left; + out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() }); + } + out } fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { @@ -69,14 +85,24 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { /// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its /// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories. +/// +/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values +/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries +/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what +/// is left are left out (the small descriptive directories come first, so a huge maker note +/// cannot crowd out the GPS position). pub(crate) fn read(b: &[u8]) -> Option { let f = File::parse(b)?; if f.big { return None; } let ifd0 = f.ifd(f.first_ifd)?; - let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default(); - Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) }) + let mut budget = MAX_COPIED_BYTES.min(b.len()); + let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t)); + let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default(); + let gps = dir(TAG_GPS_IFD); + let exif = dir(TAG_EXIF_IFD); + Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps }) } /// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs. diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs index 92e8c91df0..7e06a0e5b8 100644 --- a/crates/codecs/tests/tiff_exif.rs +++ b/crates/codecs/tests/tiff_exif.rs @@ -166,3 +166,97 @@ fn no_exif_is_written_without_embed_metadata() { let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap(); assert_eq!(decode(&tiff).unwrap().meta.exif, None); } + +// --------------------------------------------------------------------------- +// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have +// many entries point at the same bytes. +// --------------------------------------------------------------------------- + +/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes +/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to +/// copy `entries * count`. +fn overlapping_tiff(entries: usize, count: u32) -> Vec { + let entry = |tag: u16, ty: u16, count: u32, value: u32| { + [tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat() + }; + let at_exif = 8 + 2 + 12 * 10 + 4; + let data_at = at_exif + 2 + 12 * entries + 4; + let mut out = b"II".to_vec(); + out.extend(42u16.to_le_bytes()); + out.extend(8u32.to_le_bytes()); + out.extend(10u16.to_le_bytes()); + for (tag, ty, value) in + [(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)] + { + out.extend(entry(tag, ty, 1, value)); + } + out.extend(0u32.to_le_bytes()); + out.extend((entries as u16).to_le_bytes()); + for i in 0..entries { + out.extend(entry(i as u16 + 1, 7, count, data_at as u32)); + } + out.extend(0u32.to_le_bytes()); + out.extend(vec![0x5A; count as usize]); + out +} + +#[test] +fn overlapping_field_values_are_bounded() { + // About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes. + let tiff = overlapping_tiff(1000, 50_000); + assert!(tiff.len() < 64 * 1024); + let img = decode(&tiff).expect("the image itself is fine"); + assert_eq!(img.dimensions(), (1, 1)); + let exif = img.meta.exif.expect("the first entries are kept"); + assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len()); +} + +#[test] +fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() { + // The same block as an image's EXIF: the writer reads it with the same code. + let block = overlapping_tiff(1000, 50_000); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len()); +} + +/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference. +fn block_with_maker_note(le: bool, len: usize) -> Vec { + let b = Block { le }; + b.build( + vec![(271, 2, 4, b"Cam\0".to_vec())], + vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())], + vec![(1, 2, 2, b"N\0".to_vec())], + ) +} + +#[test] +fn large_maker_notes_still_survive() { + // Nikon maker notes run from 100 KB to nearly 1 MB. + for len in [200 * 1024, 1000 * 1024] { + let block = block_with_maker_note(true, len); + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (ble, _, bexif, bgps) = dirs(&back); + let (ole, _, oexif, ogps) = dirs(&block); + assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note"); + assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole)); + } +} + +#[test] +fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() { + let block = block_with_maker_note(false, 6 * 1024 * 1024); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len()); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (_, ifd0, exif, gps) = dirs(&back); + assert_eq!(exif.iter().map(|e| e.0).collect::>(), [33434], "the exposure time stays, the 6 MiB maker note goes"); + assert_eq!(gps.iter().map(|e| e.0).collect::>(), [1]); + assert_eq!(ifd0.iter().map(|e| e.0).collect::>(), [271]); +} From eae5eecab5577867e625948283a1c8a43119f2e6 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:59:13 +0300 Subject: [PATCH 0194/1452] Smart filter preview: don't scale the placement and mask twice Main's proxy_document now scales a smart object's placement, perspective and filter mask with the proxy (#5779). The smart-edit preview still did it again, so a reduced preview drew the object and its mask at a quarter size (reduced_preview_scales_placement_stack_and_mask_without_adding_filters). Only the filter lengths and the mask's feather are left to scale here. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/filter_dialog.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 018eaa21cf..08cab91741 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -904,18 +904,10 @@ pub(crate) fn preview_document_for_target( let layer = photocraft_doc::LayerId(params.get("layer")?.as_u64()?); let index = usize::try_from(params.get("index")?.as_u64()?).ok()?; let photocraft_doc::LayerContent::Smart(sm) = &mut proxy.layer_mut(layer)?.content else { return None }; - // Proxy_document scales the visible cache only. Editing a filter re-renders its source: - // scale the placement, every filter's lengths and the shared mask as well. + // Proxy_document scales the cache, placement and shared mask (#5766). Editing a filter + // re-renders its source: scale every filter's lengths and the mask's feather as well. if run_k > 1 { - let divisor = f64::from(run_k); - sm.transform.m = sm.transform.m.map(|v| v / divisor); - if let Some(h) = &mut sm.perspective { - for v in h.iter_mut().take(6) { - *v /= divisor; - } - } if let Some(mask) = &mut sm.filter_mask { - mask.surface = crate::proxy::downsample(&mask.surface, run_k); mask.feather /= run_k as f32; } for f in &mut sm.smart_filters { From 869b2556d303bd12bc351ce7a513e677e92a8cc1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 11:49:50 +0200 Subject: [PATCH 0195/1452] io: drop the Photoshop version from the gradient opacity comments (#1954) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/effects_map.rs | 4 ++-- crates/io/src/gradient_bake.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 38d22e3729..79e6fe936e 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -813,7 +813,7 @@ mod tests { /// A Gradient Overlay `lfx2` laid out as Photoshop writes it: one black colour, opacity /// 100 → 0 % with the opacity midpoint `mid` (%), Smoothness `intr` (0..4096) and the method - /// enum `gs99` (Photoshop 25.1's key in layer effects; codes `Gcls`, `Lnr `, `Perc`). + /// enum `gs99` (Photoshop's key in layer effects; codes `Gcls`, `Lnr `, `Perc`). fn overlay_lfx2(mid: i32, intr: f64, method: &str) -> Vec { let black = || Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); let clr = |loc: i32| { @@ -856,7 +856,7 @@ mod tests { } /// #1954: the overlay's transparency follows Smoothness, the opacity midpoints and the method - /// like a gradient fill layer's (Photoshop 25.1 renders both identically; the values are its + /// like a gradient fill layer's (Photoshop renders both identically; the values are its /// composites at t = 0.25 and 0.5). #[test] fn gradient_overlay_opacity_follows_smoothness_midpoint_and_method() { diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index ca4c82e9f5..6c7aaf32f4 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -188,7 +188,7 @@ pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method /// Exponent of Photoshop's opacity response for Perceptual and Linear gradients: the opacity /// stops enter the method's space as if they were sRGB grey levels (Linear: linear light; /// Perceptual: Oklab L), and the interpolated linear-light value `y` comes out as opacity -/// `y^0.65`. Fitted on Photoshop 25.1 (scripted Gradient Overlays and gradient fill layers, which +/// `y^0.65`. Fitted on Photoshop (scripted Gradient Overlays and gradient fill layers, which /// render identical pixels: 100 → 0 % and 100 → 20 → 90 % ramps with Smoothness 0 and 100 and /// midpoints 25 and 50 %, within 0.015 of every 8-bit sample; a flat 60 % renders as 47 %). const OPACITY_RESPONSE: f32 = 0.65; @@ -196,7 +196,7 @@ const OPACITY_RESPONSE: f32 = 0.65; /// Dense linear opacity stops reproducing Photoshop's interpolation of a gradient's transparency /// (Smoothness, opacity midpoints and the method, see [`OPACITY_RESPONSE`]). Returns the input /// unchanged when it already interpolates linearly. Smooth keeps Classic's opacity curve: it was -/// not measured (Photoshop 25.1 has no Smooth method). +/// not measured (the Photoshop build it was fitted on has no Smooth method). pub fn bake_opacity(stops: Vec<(f32, f32)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, f32)> { let method = if method == Method::Smooth { Method::Classic } else { method }; let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); @@ -246,7 +246,7 @@ mod tests { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } - /// Photoshop 25.1 Gradient Overlay samples (black, Normal, over white; a 256 px wide linear + /// Photoshop Gradient Overlay samples (black, Normal, over white; a 256 px wide linear /// gradient, pixel `x` at t = (x + 0.5) / 256): opacity stops, midpoints, Smoothness, method, /// and (x, opacity) pairs read from the composite. #[test] From 24a86a3f6fa6e24d8f0d695915caaa366007b09b Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 11:54:35 +0200 Subject: [PATCH 0196/1452] Image: Bitmap dialog with Output resolution and per-method settings (#5559) Image > Mode > Bitmap now opens a dialog laid out like Photoshop's: Resolution (Input, read-only; Output in pixels/inch or pixels/centimeter), Method (50% Threshold, Pattern Dither, Diffusion Dither, Halftone Screen, Custom Pattern) with a pattern picker enabled for Custom Pattern, and the Halftone Screen settings (Frequency, Angle, Shape) shown only for that method. The image.mode.bitmap command gains `resolution` (resamples to the Output resolution keeping the print size, then sets it) and the `customPattern` method (threshold map ranked from the pattern's lightness, tiled from the top-left). The halftone screen is laid out at the output resolution. The live preview keeps the canvas size. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/algo/src/quantize.rs | 77 ++++++++++ crates/engine/src/image_cmds.rs | 2 +- crates/engine/src/mode_cmds.rs | 68 ++++++++- crates/engine/src/mode_cmds/tests.rs | 126 ++++++++++++++++ crates/ui-egui/src/bitmap_dialog.rs | 166 ++++++++++++++++++++++ crates/ui-egui/src/bitmap_dialog_tests.rs | 116 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/filter_dialog.rs | 10 ++ crates/ui-egui/src/i18n/cs.tsv | 11 ++ crates/ui-egui/src/i18n/de.tsv | 11 ++ crates/ui-egui/src/i18n/el.tsv | 11 ++ crates/ui-egui/src/i18n/es.tsv | 11 ++ crates/ui-egui/src/i18n/fr.tsv | 12 ++ crates/ui-egui/src/i18n/hu.tsv | 11 ++ crates/ui-egui/src/i18n/id.tsv | 12 ++ crates/ui-egui/src/i18n/it.tsv | 11 ++ crates/ui-egui/src/i18n/ja.tsv | 11 ++ crates/ui-egui/src/i18n/ko.tsv | 11 ++ crates/ui-egui/src/i18n/nl.tsv | 11 ++ crates/ui-egui/src/i18n/pl.tsv | 11 ++ crates/ui-egui/src/i18n/pt-br.tsv | 11 ++ crates/ui-egui/src/i18n/ru.tsv | 13 ++ crates/ui-egui/src/i18n/sr.tsv | 11 ++ crates/ui-egui/src/i18n/tr.tsv | 11 ++ crates/ui-egui/src/i18n/uk.tsv | 11 ++ crates/ui-egui/src/i18n/vi.tsv | 11 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 13 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 13 ++ crates/ui-egui/src/lib.rs | 1 + 29 files changed, 795 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/bitmap_dialog.rs create mode 100644 crates/ui-egui/src/bitmap_dialog_tests.rs diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index 589c9f196d..0d428f3086 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -562,6 +562,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { } } +/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major): +/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel +/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly +/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does +/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give +/// exactly those levels). +pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec { + let n = lightness.len(); + let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + let mut order: Vec = (0..n).collect(); + order.sort_by(|a, b| key(*a).total_cmp(&key(*b))); + let mut out = vec![0.5; n]; + let mut start = 0; + while start < n { + let v = order.get(start).map(|i| key(*i)); + let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n); + // Ranks start..end share their mean. + let rank = (start + end - 1) as f32 / 2.0; + let t = 1.0 - (rank + 0.5) / n as f32; + for i in order.get(start..end).unwrap_or_default() { + if let Some(o) = out.get_mut(*i) { + *o = t; + } + } + start = end; + } + out +} + +/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it +/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the +/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%. +pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) { + let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len()); + for (i, v) in gray.iter_mut().enumerate() { + let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 }; + *v = if *v >= t { 1.0 } else { 0.0 }; + } +} + #[cfg(test)] mod tests { use super::*; @@ -722,4 +762,41 @@ mod tests { } } } + + /// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white. + fn first_white(t: f32) -> u32 { + (0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256) + } + + #[test] + fn custom_pattern_thresholds_match_photoshop() { + // Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white + // level per pattern pixel, measured against defined patterns. + let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::>(); + let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240])); + assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::>(), [237, 201, 164, 128, 92, 55, 19]); + let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150])); + assert_eq!(five.iter().map(|t| first_white(*t)).collect::>(), [230, 77, 179, 26, 128]); + // Equal pixels share their rank: 100, 100 both sit at 50%. + let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50])); + assert_eq!(ties.iter().map(|t| first_white(*t)).collect::>(), [128, 128, 32, 224]); + assert!(custom_pattern_thresholds(&[]).is_empty()); + assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2); + } + + #[test] + fn custom_pattern_tiles_from_the_top_left() { + // A 2x1 map: even columns need 75%, odd ones 25%. + let map = [0.75, 0.25]; + let mut g = vec![0.5; 6]; + to_bitmap_custom(&mut g, 3, &map, 2, 1); + assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]); + // A map that doesn't match its size, or an empty image width, is a 50% threshold. + let mut g = vec![0.4, 0.6]; + to_bitmap_custom(&mut g, 2, &map, 3, 1); + assert_eq!(g, [0.0, 1.0]); + let mut g = vec![0.6]; + to_bitmap_custom(&mut g, 0, &map, 2, 1); + assert_eq!(g, [1.0]); + } } diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..0982a387eb 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -244,7 +244,7 @@ pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution /// mode. Callers check [`check_resample_budget`] first. -fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { +pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..2bcb2e4cf9 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -388,9 +388,48 @@ fn color_table(s: &mut Session, p: &Value) -> Result { // ---------- Bitmap ---------- +/// Bitmap's Output resolution range (ppi): what a new document may have. +const BITMAP_RESOLUTION: std::ops::RangeInclusive = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION; + +/// Bitmap's Custom Pattern: a pattern's threshold map (see +/// [`quantize::custom_pattern_thresholds`]) with its width and height. +fn custom_pattern_map(s: &Session, cmd: &str, p: &Value) -> Result<(Vec, usize, usize)> { + if p.get("pattern").is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad(cmd, "`pattern` must be a pattern id or name")); + } + let pat = crate::pattern_cmds::resolve_param(s, cmd, p)?; + // A pattern keeps its own colours: its lightness is what it paints in a Grayscale document, + // over white where it is transparent. + let px = photocraft_compose::pattern::pattern_rgba(&pat, ColorMode::Grayscale); + if px.is_empty() { + return Err(bad(cmd, format!("the pattern \"{}\" is empty or too large", pat.display_name()))); + } + let lightness: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]]) * q[3] + (1.0 - q[3])).collect(); + Ok((quantize::custom_pattern_thresholds(&lightness), pat.width as usize, pat.height as usize)) +} + fn bitmap(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.bitmap"; - let dpi = s.active().ok_or(EngineError::NoDocument)?.doc.resolution_dpi.max(1.0); + let d = s.active().ok_or(EngineError::NoDocument)?; + let input = f64::from(d.doc.resolution_dpi.max(1.0)); + let (ow, oh) = (d.doc.size.width, d.doc.size.height); + // Output resolution (ppi): Photoshop resamples the image to it, keeping the print size, + // before converting, and the document takes it as its resolution. + let output = finite_num(p, CMD, "resolution", input)?; + if !BITMAP_RESOLUTION.contains(&output) { + return Err(bad(CMD, format!("resolution {output} is outside {}..{} pixels/inch", BITMAP_RESOLUTION.start(), BITMAP_RESOLUTION.end()))); + } + let (nw, nh) = if (output - input).abs() < 1e-6 { + (ow, oh) + } else { + let side = |v: u32| (f64::from(v) * output / input).round().clamp(1.0, 300_000.0) as u32; + (side(ow), side(oh)) + }; + if (nw, nh) != (ow, oh) { + crate::image_cmds::check_resample_budget(CMD, &d.doc, nw, nh, ", or a lower Output resolution")?; + } + let dpi = output as f32; + let mut custom = None; let method = match str_or(p, "method", "diffusion") { "threshold" | "threshold50" => BitmapMethod::Threshold, "pattern" => BitmapMethod::Pattern, @@ -403,21 +442,40 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, format!("angle {angle} is outside -180..180"))); } let shape = HalftoneShape::from_id(str_or(p, "shape", "round")).ok_or_else(|| bad(CMD, "shape: round|ellipse|line|square|diamond|cross"))?; + // The screen is laid out at the output resolution. BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: angle as f32, shape } } + "customPattern" => { + custom = Some(custom_pattern_map(s, CMD, p)?); + BitmapMethod::Threshold + } m => { - return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); + return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone|customPattern)"))); } }; + if (nw, nh) != (ow, oh) { + // Color Sampler points scale with the pixels, as in Image Size. + let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); + s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + } s.edit("Bitmap", |doc, active| { + if (nw, nh) != (doc.size.width, doc.size.height) { + crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); + } + doc.resolution_dpi = dpi; let px = composite(doc, true); let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); - quantize::to_bitmap(&mut gray, doc.size.width as usize, method); + let w = doc.size.width as usize; + match &custom { + Some((map, mw, mh)) => quantize::to_bitmap_custom(&mut gray, w, map, *mw, *mh), + None => quantize::to_bitmap(&mut gray, w, method), + } let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect(); single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true); Ok(()) })?; - Ok(Value::Null) + let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; + Ok(json!({"width": size.width, "height": size.height, "resolution": dpi})) } // ---------- Duotone ---------- @@ -523,7 +581,7 @@ pub fn specs() -> Vec { "image.mode.bitmap", "Bitmap…", ["Image", "Mode"], - r##"{"method":"diffusion|threshold|pattern|halftone"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round"} (halftone frequency in lines/inch; flattens)"##, + r##"{"resolution":1..30000=document,"method":"diffusion|threshold|pattern|halftone|customPattern"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round","pattern":id|name=Patterns panel selection} (resolution: Output in pixels/inch, resamples first; frequency/angle/shape: Halftone Screen, frequency in lines/inch; pattern: Custom Pattern; flattens)"##, |s| mode_is(s, &[ColorMode::Grayscale], "Bitmap needs a Grayscale image (Image › Mode › Grayscale first)"), bitmap ), diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..2a556adde0 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -323,6 +323,132 @@ fn bitmap_halftone_rejects_bad_angles_and_frequencies_without_editing_the_docume assert_eq!(doc(&s).mode, ColorMode::Bitmap); } +/// A grayscale document `w` x `h` whose column x holds the 8-bit level `x` (a 0..255 ramp). +fn ramp(w: u32, h: u32, depth: u64, dpi: f32) -> Session { + let mut s = session(w, h, depth, "grayscale"); + paint(&mut s, |x, _| [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0]); + s.edit("dpi", |doc, _| { + doc.resolution_dpi = dpi; + Ok(()) + }) + .unwrap(); + s +} + +/// The first column of row `y` whose pixel is white among columns `x % m == k`. +fn first_white(d: &Document, y: i32, m: i32, k: i32) -> Option { + let surf = d.layers[0].surface().unwrap(); + (0..d.size.width as i32).filter(|x| x % m == k).find(|x| surf.pixel(*x, y)[0] > 0.5) +} + +fn add_pattern(s: &mut Session, name: &str, w: u32, h: u32, levels: &[u8]) { + let mut surf = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let data: Vec = levels.iter().map(|v| f32::from(*v) / 255.0).collect(); + surf.write_region(Rect::new(0, 0, w as i32, h as i32), &data); + let pat = photocraft_doc::pattern::Pattern::new(name, surf, w, h); + s.edit("pattern", |doc, _| { + doc.patterns.push(pat); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn bitmap_output_resolution_resamples_and_sets_the_resolution() { + // Photoshop: a 256 x 4 ramp at 72 ppi converted with Output 144 ppi is 512 x 8 at 144 ppi, + // its 50% threshold at column 256; at 36 ppi it is 128 x 2, the threshold at column 64. + for depth in [8, 16] { + let mut s = ramp(256, 4, depth, 72.0); + let r = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 144})).unwrap(); + assert_eq!(r, json!({"width": 512, "height": 8, "resolution": 144.0}), "{depth}"); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height, d.resolution_dpi, d.mode), (512, 8, 144.0, ColorMode::Bitmap), "{depth}"); + assert!(first_white(d, 3, 1, 0).is_some_and(|x| (255..=257).contains(&x)), "{depth}: {:?}", first_white(d, 3, 1, 0)); + // One step back is the grayscale document at its own size and resolution. + s.execute("edit.undo", json!({})).unwrap(); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height, d.resolution_dpi, d.mode), (256, 4, 72.0, ColorMode::Grayscale), "{depth}"); + s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 36})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (128, 2, 36.0), "{depth}"); + } + // Without `resolution`, the input resolution: the size stays. + let mut s = ramp(256, 4, 8, 300.0); + s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).resolution_dpi), (256, 300.0)); + assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128)); +} + +#[test] +fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() { + // The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the + // output equals the 72 ppi screen of the image upsampled first. + let mut a = ramp(64, 64, 8, 72.0); + a.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 10, "angle": 0, "resolution": 144})).unwrap(); + let mut b = ramp(64, 64, 8, 72.0); + b.execute("image.imageSize", json!({"resolution": 144, "resample": "bicubic", "keepPrintSize": true})).unwrap(); + b.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 10, "angle": 0})).unwrap(); + let (da, db) = (doc(&a), doc(&b)); + assert_eq!(da.size, db.size); + let (sa, sb) = (da.layers[0].surface().unwrap(), db.layers[0].surface().unwrap()); + let r = da.bounds(); + assert_eq!(sa.read_region(r), sb.read_region(r)); +} + +#[test] +fn bitmap_custom_pattern_thresholds_against_the_tiled_pattern() { + // Measured in Photoshop on a 0..255 ramp: a 7x1 pattern 0, 40 .. 240 turns column x white + // from level 237, 201, 164, 128, 92, 55, 19 (by x % 7): the lighter the pattern pixel, the + // sooner. A 1x3 pattern 30 / 220 / 120 does the same down the rows. + for depth in [8, 16] { + let mut s = ramp(256, 3, depth, 72.0); + add_pattern(&mut s, "Ramp 7", 7, 1, &[0, 40, 80, 120, 160, 200, 240]); + add_pattern(&mut s, "Rows 3", 1, 3, &[30, 220, 120]); + let r = s.execute("image.mode.bitmap", json!({"method": "customPattern", "pattern": "Ramp 7"})).unwrap(); + assert_eq!(r["width"], 256); + let d = doc(&s); + assert_eq!(d.mode, ColorMode::Bitmap); + let firsts: Vec = (0..7).map(|k| first_white(d, 0, 7, k).unwrap()).collect(); + // The first column at or past each level in its residue class. + let expect: Vec = [237, 201, 164, 128, 92, 55, 19].iter().enumerate().map(|(k, t)| (k as i32..256).step_by(7).find(|x| x >= t).unwrap()).collect(); + assert_eq!(firsts, expect, "{depth}"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({"method": "customPattern", "pattern": "Rows 3"})).unwrap(); + let rows: Vec = (0..3).map(|y| first_white(doc(&s), y, 1, 0).unwrap()).collect(); + assert_eq!(rows, [213, 43, 128], "{depth}"); + } +} + +#[test] +fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() { + let mut s = ramp(64, 4, 16, 72.0); + let before = s.active().unwrap().history.past_len(); + let bad = [ + json!({"resolution": 0}), + json!({"resolution": -72}), + json!({"resolution": 0.5}), + json!({"resolution": 30_001}), + json!({"resolution": 1e39}), + json!({"resolution": "high"}), + json!({"resolution": [72]}), + json!({"method": "customPattern", "pattern": "no such pattern"}), + json!({"method": "customPattern", "pattern": 7}), + json!({"method": "custom"}), + ]; + for p in bad { + assert!(s.execute("image.mode.bitmap", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{p}"); + let d = doc(&s); + assert_eq!((d.mode, d.size.width, d.resolution_dpi), (ColorMode::Grayscale, 64, 72.0), "{p}"); + } + // NaN can't be written in JSON; null is "not given" (the input resolution). + s.execute("image.mode.bitmap", json!({"resolution": null})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).resolution_dpi), (64, 72.0)); + s.execute("edit.undo", json!({})).unwrap(); + // The bounds themselves are fine. + s.execute("image.mode.bitmap", json!({"resolution": 1})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (1, 1, 1.0)); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); diff --git a/crates/ui-egui/src/bitmap_dialog.rs b/crates/ui-egui/src/bitmap_dialog.rs new file mode 100644 index 0000000000..04ef5b1d79 --- /dev/null +++ b/crates/ui-egui/src/bitmap_dialog.rs @@ -0,0 +1,166 @@ +//! Image › Mode › Bitmap… (#5559), laid out like Photoshop's dialog: Resolution (Input, read-only, +//! and Output, in pixels/inch or pixels/centimeter), Method (Use: 50% Threshold, Pattern Dither, +//! Diffusion Dither, Halftone Screen, Custom Pattern) with the Custom Pattern picker, enabled only +//! for Custom Pattern. The Halftone Screen settings (Frequency in lines/inch or lines/centimeter, +//! Angle, Shape), which Photoshop asks for in a second dialog after OK, show here only while +//! Halftone Screen is the method. +//! +//! It is the schema dialog of `image.mode.bitmap` with its own body: the fields are the command's +//! params (`resolution` in pixels/inch, `frequency` in lines/inch), so OK, the live preview, +//! `ui.dialog.set` and `ui.dialog.confirm` work as for every schema dialog. The units are view +//! state (`__resUnit`, `__freqUnit`). + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub(crate) const COMMAND: &str = "image.mode.bitmap"; + +/// Centimetres per inch. +const CM: f32 = 2.54; +/// The command's Output resolution range (pixels/inch). +const RESOLUTION: (f32, f32) = (1.0, 30_000.0); +/// The command's Halftone Screen frequency range (lines/inch). +const FREQUENCY: (f32, f32) = (1.0, 999.0); + +const METHODS: [(&str, &str); 5] = [ + ("threshold", "50% Threshold"), + ("pattern", "Pattern Dither"), + ("diffusion", "Diffusion Dither"), + ("halftone", "Halftone Screen"), + ("customPattern", "Custom Pattern"), +]; + +/// Photoshop's order of the Halftone Screen shapes. +const SHAPES: [(&str, &str); 6] = + [("round", "Round"), ("diamond", "Diamond"), ("ellipse", "Ellipse"), ("line", "Line"), ("square", "Square"), ("cross", "Cross")]; + +/// Is this dialog the Bitmap dialog? (A plug-in's own `__spec` dialog never is.) +pub(crate) fn owns(f: &Map) -> bool { + f.get("__filter").and_then(Value::as_bool) == Some(true) && f.get("__command").and_then(Value::as_str) == Some(COMMAND) && !f.contains_key("__spec") +} + +/// Open defaults: Output starts at the document's resolution (Input, as in Photoshop), Custom +/// Pattern at the Patterns panel's pattern. +pub(crate) fn defaults(app: &PhotocraftApp, f: &mut Map) { + let input = app.session.active().map_or(72.0, |d| d.doc.resolution_dpi); + f.insert("__inputResolution".into(), json!(input)); + f.insert("resolution".into(), json!(input)); + match photocraft_engine::pattern_cmds::resolve(&app.session, "") { + Some(p) => f.insert("pattern".into(), json!(p.id)), + None => f.remove("pattern"), + }; + f.entry("__resUnit").or_insert_with(|| json!("ppi")); + f.entry("__freqUnit").or_insert_with(|| json!("lpi")); +} + +fn num(f: &Map, key: &str, default: f32) -> f32 { + f.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) +} + +fn text(f: &Map, key: &str, default: &str) -> String { + f.get(key).and_then(Value::as_str).unwrap_or(default).to_string() +} + +fn label(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_sized([96.0, 20.0], egui::Label::new(egui::RichText::new(text).color(t.text_dim)).truncate()); +} + +/// A value stored in per-inch units, edited in per-inch or per-centimeter ones: `field` holds the +/// per-inch value, `unit` the view's unit (`per_cm` when its value is per centimeter). +fn per_unit_row(ui: &mut egui::Ui, f: &mut Map, field: &str, range: (f32, f32), default: f32, unit: &str, units: [(&str, &str); 2]) { + let per_inch = num(f, field, default); + let mut cur = text(f, unit, units[0].0); + let per_cm = cur == units[1].0; + let k = if per_cm { CM } else { 1.0 }; + let mut shown = per_inch / k; + if crate::widgets::value_field(ui, &mut shown, range.0 / k..=range.1 / k, "", 70.0).changed() { + f.insert(field.into(), json!(((shown * k).clamp(range.0, range.1) * 1000.0).round() / 1000.0)); + } + let opts: Vec<(String, &str)> = units.iter().map(|(id, l)| (id.to_string(), *l)).collect(); + if crate::widgets::dropdown(ui, &format!("bitmap-{unit}"), &mut cur, &opts, 150.0) { + f.insert(unit.into(), json!(cur)); + } +} + +/// The dialog body. +pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing.y = 6.0; + let res_units = [("ppi", tl!("Pixels/Inch")), ("ppcm", tl!("Pixels/Centimeter"))]; + + crate::widgets::section_label(ui, tl!("Resolution")); + crate::widgets::hairline(ui); + ui.horizontal(|ui| { + label(ui, tl!("Input:")); + let input = num(f, "__inputResolution", 72.0); + let (v, unit) = if text(f, "__resUnit", "ppi") == "ppcm" { (input / CM, res_units[1].1) } else { (input, res_units[0].1) }; + ui.label(egui::RichText::new(format!("{} {unit}", crate::widgets::fmt_num_prec(f64::from(v), 3))).color(t.text)); + }); + ui.horizontal(|ui| { + label(ui, tl!("Output:")); + let input = num(f, "__inputResolution", 72.0); + per_unit_row(ui, f, "resolution", RESOLUTION, input, "__resUnit", res_units); + }); + + ui.add_space(4.0); + crate::widgets::section_label(ui, tl!("Method")); + crate::widgets::hairline(ui); + let mut method = text(f, "method", "diffusion"); + ui.horizontal(|ui| { + label(ui, tl!("Use:")); + let opts: Vec<(String, &str)> = METHODS.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect(); + if crate::widgets::dropdown(ui, "bitmap-method", &mut method, &opts, 190.0) { + f.insert("method".into(), json!(method)); + } + }); + // Photoshop greys the pattern out unless the method is Custom Pattern. + ui.horizontal(|ui| { + ui.add_enabled_ui(method == "customPattern", |ui| { + label(ui, tl!("Custom Pattern:")); + let pat = text(f, "pattern", ""); + if app.session.patterns.items.is_empty() { + ui.label(egui::RichText::new(tl!("No patterns")).color(t.text_faint)); + } else if let Some(id) = crate::preset_panels::pattern_picker(app, ui, &pat) { + f.insert("pattern".into(), json!(id)); + } + }); + }); + + if method == "halftone" { + ui.add_space(4.0); + crate::widgets::section_label(ui, tl!("Halftone Screen")); + crate::widgets::hairline(ui); + ui.horizontal(|ui| { + label(ui, tl!("Frequency:")); + per_unit_row(ui, f, "frequency", FREQUENCY, 53.0, "__freqUnit", [("lpi", tl!("Lines/Inch")), ("lpcm", tl!("Lines/Centimeter"))]); + }); + ui.horizontal(|ui| { + label(ui, tl!("Angle:")); + let mut angle = num(f, "angle", 45.0); + if crate::widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 70.0).changed() { + f.insert("angle".into(), json!((angle.clamp(-180.0, 180.0) * 10.0).round() / 10.0)); + } + }); + ui.horizontal(|ui| { + label(ui, tl!("Shape:")); + let mut shape = text(f, "shape", "round"); + let opts: Vec<(String, &str)> = SHAPES.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect(); + if crate::widgets::dropdown(ui, "bitmap-shape", &mut shape, &opts, 150.0) { + f.insert("shape".into(), json!(shape)); + } + }); + } + + if let Some(mut preview) = f.get("__preview").and_then(Value::as_bool) { + ui.add_space(4.0); + crate::widgets::checkbox(ui, &mut preview, tl!("Preview")); + f.insert("__preview".into(), json!(preview)); + } +} + +#[cfg(test)] +#[path = "bitmap_dialog_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/bitmap_dialog_tests.rs b/crates/ui-egui/src/bitmap_dialog_tests.rs new file mode 100644 index 0000000000..8c7a45b12f --- /dev/null +++ b/crates/ui-egui/src/bitmap_dialog_tests.rs @@ -0,0 +1,116 @@ +use serde_json::{Map, Value, json}; + +use super::*; + +fn app(dpi: f64, depth: u64) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 40, "height": 20, "mode": "grayscale", "depth": depth, "resolution": dpi})).unwrap(); + app +} + +fn open(app: &mut PhotocraftApp) -> u64 { + crate::filter_dialog::open(app, COMMAND).unwrap() +} + +fn fields(app: &PhotocraftApp, id: u64) -> Map { + app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() +} + +/// The text the body draws for `f`, and `f` after the frame. +fn drawn(app: &PhotocraftApp, mut f: Map) -> (Vec, Map) { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(shape) => text.push(shape.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| body(app, ui, &mut f)); + out.textures_delta.clear(); + let mut text = Vec::new(); + for s in &out.shapes { + collect(&s.shape, &mut text); + } + (text, f) +} + +#[test] +fn opens_with_output_at_the_input_resolution_and_a_pattern_id() { + let mut app = app(300.0, 16); + let id = open(&mut app); + let f = fields(&app, id); + assert!(owns(&f)); + assert_eq!(f["__inputResolution"].as_f64(), Some(300.0)); + assert_eq!(f["resolution"].as_f64(), Some(300.0)); + assert!(f["pattern"].is_string(), "the schema's placeholder 0 is replaced by a pattern id: {:?}", f["pattern"]); + let p = crate::filter_dialog::params_of(&f); + assert!(p.as_object().unwrap().keys().all(|k| !k.starts_with("__")), "{p}"); +} + +#[test] +fn halftone_settings_show_only_for_halftone_screen() { + let mut app = app(72.0, 8); + let id = open(&mut app); + let mut f = fields(&app, id); + for (method, halftone) in [("diffusion", false), ("threshold", false), ("pattern", false), ("customPattern", false), ("halftone", true)] { + f.insert("method".into(), json!(method)); + let (text, after) = drawn(&app, f.clone()); + for label in ["Frequency:", "Angle:", "Shape:"] { + assert_eq!(text.iter().any(|t| t == label), halftone, "{method}: {label} in {text:?}"); + } + for label in ["Input:", "Output:", "Use:", "Custom Pattern:"] { + assert!(text.iter().any(|t| t == label), "{method}: {label} in {text:?}"); + } + // Drawing the dialog changes no value (no unit round trip drift). + assert_eq!(crate::filter_dialog::params_of(&after), crate::filter_dialog::params_of(&f), "{method}"); + } +} + +#[test] +fn pixels_per_centimeter_show_the_same_resolution() { + let mut app = app(254.0, 8); + let id = open(&mut app); + let mut f = fields(&app, id); + f.insert("__resUnit".into(), json!("ppcm")); + let (text, after) = drawn(&app, f.clone()); + assert!(text.iter().any(|t| t.starts_with("100 ")), "Input 254 ppi is 100 px/cm: {text:?}"); + assert_eq!(after["resolution"].as_f64(), Some(254.0), "the param stays in pixels/inch"); +} + +#[test] +fn ok_resamples_to_the_output_resolution() { + for depth in [8, 16] { + let mut app = app(72.0, depth); + let id = open(&mut app); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("method".into(), json!("threshold")); + d.fields.insert("resolution".into(), json!(144)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height, doc.resolution_dpi, doc.mode), (80, 40, 144.0, photocraft_color::ColorMode::Bitmap), "{depth}"); + } +} + +#[test] +fn ok_with_custom_pattern_and_halftone_screen_converts() { + for method in ["customPattern", "halftone"] { + let mut app = app(72.0, 8); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("method".into(), json!(method)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size.width, doc.mode), (40, photocraft_color::ColorMode::Bitmap), "{method}"); + } +} + +#[test] +fn the_preview_keeps_the_canvas_size() { + let app = app(72.0, 8); + let st = app.session.active().unwrap(); + let p = json!({"method": "threshold", "resolution": 144}); + let shown = crate::filter_dialog::preview_document(&st.doc, st.active_layer, COMMAND, &p, 1).unwrap(); + assert_eq!((shown.size.width, shown.size.height), (40, 20)); + assert_eq!(shown.mode, photocraft_color::ColorMode::Bitmap); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2930889bab..2e1e224757 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -324,7 +324,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // parameters reserves its own space (#3769). let room = (ctx.content_rect().height() - DIALOG_CHROME - crate::filter_preview_ui::reserved_height(&fields)).max(120.0); let body = |ui: &mut egui::Ui, fields: &mut serde_json::Map| { - egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, fields)); + egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| { + if crate::bitmap_dialog::owns(fields) { + crate::bitmap_dialog::body(app, ui, fields); + } else { + crate::filter_dialog::body(ui, fields); + } + }); // The in-dialog preview window sits under the scrolling parameters (#3769), // so it never shifts them. crate::filter_preview_ui::window(app, d.id, ui, fields); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 3da0735204..7e6183a4ce 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -388,6 +388,9 @@ fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map Date: Thu, 15 Oct 2026 05:50:54 +0300 Subject: [PATCH 0197/1452] History panel: scroll to the current state on document change. Fixes storytold/photocraft#5909 when new edits land off-screen in a short dock. Co-authored-by: Cursor --- .../ui-egui/src/history_panel_select_tests.rs | 28 +++++++++++++++++++ crates/ui-egui/src/panels.rs | 11 ++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs index 92c458e37f..7c97dca106 100644 --- a/crates/ui-egui/src/history_panel_select_tests.rs +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -58,3 +58,31 @@ fn clicking_a_state_jumps_there_with_one_command() { h.run_steps(2); assert_eq!(state(&h), (4, 0, rev + 2)); } + +#[test] +fn new_history_steps_scroll_the_current_state_into_view() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + for n in 0..18 { + app.run("layer.new.layer", json!({"name": format!("Layer {n}")})).unwrap(); + } + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(280.0, 120.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + history(app, ui); + }, + app, + ); + h.run_steps(4); + let row = h.get_all_by_label("New Layer").last().expect("the latest New Layer state").rect(); + let view = h.ctx.input(|i| i.viewport_rect()); + assert!( + view.contains_rect(row), + "the current history row must scroll into view: row {row:?} view {view:?}", + ); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..46a0d4a66b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3088,6 +3088,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let doc = st.doc.clone(); + let revision = st.revision; let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); @@ -3118,10 +3119,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; let max_h = (ui.available_height() - footer).max(40.0); + let follow_id = ui.id().with(("history-follow-rev", doc.id)); + let follow = ui.ctx().data(|d| d.get_temp::(follow_id)) != Some(revision); egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { // Snapshots head the list and scroll with the states, so many of them never push the // footer out of a short dock. snap_cmd = snapshot_rows(app, ui, selected_snap); + let mut current_rect = None; for (i, (e, is_redo)) in all.enumerate() { // The current state shows selected unless a snapshot is (Photoshop highlights one row). let selected = i == current && selected_snap.is_none(); @@ -3132,6 +3136,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.drag_started() { ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i))); } + if i == current { + current_rect = Some(rect); + } if selected { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { @@ -3161,6 +3168,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(selected_snapshot_key(doc.id))); } } + if follow && let Some(rect) = current_rect { + ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); + } + ui.ctx().data_mut(|d| d.insert_temp(follow_id, revision)); }); if let Some(drag) = drag && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) From 579e0f1d287c4b64798fbda9b1ca94225dc8d940 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Wed, 14 Oct 2026 19:38:14 +0300 Subject: [PATCH 0198/1452] =?UTF-8?q?Workspace:=20list=20saved=20workspace?= =?UTF-8?q?s=20in=20Window=20=E2=80=BA=20Workspace=20and=20the=20switcher?= =?UTF-8?q?=20(storytold/photocraft#5610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved workspaces get dynamic Window › Workspace items after the presets (window.workspace.saved.), checked while current and selecting the workspace; the top-bar dropdown lists them after the presets. Co-authored-by: Cursor --- crates/ui-egui/src/menus.rs | 16 +++++++ crates/ui-egui/src/panels.rs | 12 +---- crates/ui-egui/src/workspace_ui.rs | 72 +++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..11c949f027 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1101,6 +1101,22 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { items.insert(after + 1 + k, it); } } + // Window › Workspace: saved workspaces follow the presets, checked while current. + let saved = crate::workspace_ui::saved_names(app); + if !saved.is_empty() + && let Some(after) = items.iter().position(|i| i.id == "window.workspace.motion") + { + let wp: Vec = vec!["Window".into(), "Workspace".into()]; + let separator = MenuItem { id: "---".into(), label: "---".into(), path: wp.clone(), shortcut: None, enabled: false, checked: None, color: None }; + let entries = saved.into_iter().enumerate().map(|(i, name)| { + let id = format!("{}{i}", crate::workspace_ui::SAVED); + let checked = checked(app, &id); + MenuItem { id, label: name, path: wp.clone(), shortcut: None, enabled: true, checked, color: None } + }); + for (k, it) in std::iter::once(separator).chain(entries).enumerate() { + items.insert(after + 1 + k, it); + } + } // Help: the link items, a separator, then System Info and About. if let Some(at) = items.iter().position(|i| i.id == "help.systemInfo" || i.id == "help.about") { items.insert( diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..d0787cafbd 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -642,16 +642,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ui = &mut group_ui; ui.spacing_mut().item_spacing.x = 6.0; let mut ws = app.ui.workspace.clone(); - let opts = [ - // Keys stay English (`apply_workspace` matches them); the dropdown - // shows the labels in the UI language. - ("Essentials".to_string(), tl!("Essentials")), - ("Photography".to_string(), tl!("Photography")), - ("Painting".to_string(), tl!("Painting")), - ("Graphic and Web".to_string(), tl!("Graphic and Web")), - ("Pixel Art".to_string(), tl!("Pixel Art")), - ("Motion".to_string(), tl!("Motion")), - ]; + let options = crate::workspace_ui::switcher_options(app); + let opts: Vec<(String, &str)> = options.iter().map(|(k, l)| (k.clone(), l.as_str())).collect(); // A narrow bar drops what is also in a menu, the theme toggle first // (Preferences), then search (Edit › Search), and narrows the switcher // (Window › Workspace) before anything runs over. diff --git a/crates/ui-egui/src/workspace_ui.rs b/crates/ui-egui/src/workspace_ui.rs index 1c93272631..4fb9140021 100644 --- a/crates/ui-egui/src/workspace_ui.rs +++ b/crates/ui-egui/src/workspace_ui.rs @@ -45,13 +45,45 @@ const IDS: [&str; 7] = [ "view.show.showExtrasOptions", ]; +/// Window › Workspace items of saved workspaces: `window.workspace.saved.`, an index into +/// [`saved_names`]. +pub const SAVED: &str = "window.workspace.saved."; + /// Menu ids handled here (live menu items). pub fn handles(id: &str) -> bool { - IDS.contains(&id) + IDS.contains(&id) || id.starts_with(SAVED) +} + +/// Saved workspace names, in menu order. +pub fn saved_names(app: &PhotocraftApp) -> Vec { + app.session.prefs().workspaces.keys().cloned().collect() +} + +fn saved_name(app: &PhotocraftApp, id: &str) -> Option { + let i = id.strip_prefix(SAVED)?.parse::().ok()?; + app.session.prefs().workspaces.keys().nth(i).cloned() +} + +/// The top-bar workspace switcher's options as (key, label): the presets, then saved workspaces. +pub fn switcher_options(app: &PhotocraftApp) -> Vec<(String, String)> { + // Keys stay English (`apply_workspace` matches them); the dropdown shows the preset labels + // in the UI language. + let presets = [ + ("Essentials", tl!("Essentials")), + ("Photography", tl!("Photography")), + ("Painting", tl!("Painting")), + ("Graphic and Web", tl!("Graphic and Web")), + ("Pixel Art", tl!("Pixel Art")), + ("Motion", tl!("Motion")), + ]; + let mut opts: Vec<(String, String)> = presets.iter().map(|(k, l)| ((*k).to_string(), (*l).to_string())).collect(); + opts.extend(saved_names(app).into_iter().map(|n| (n.clone(), n))); + opts } pub fn checked(app: &PhotocraftApp, id: &str) -> Option { match id { + i if i.starts_with(SAVED) => Some(saved_name(app, i).is_some_and(|n| n == app.ui.workspace)), "window.panel.modifierKeys" => Some(app.ui.shell.modifier_keys), "window.workspace.lockWorkspace" => Some(app.session.prefs().workspace_locked), "view.pixelAspectRatio.custom" => Some(app.ui.view.pixel_aspect.starts_with("custom:")), @@ -140,6 +172,10 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option delete_workspace(app, params), "window.workspace.select" => select_workspace(app, params), + i if i.starts_with(SAVED) => match saved_name(app, i) { + Some(name) => select_workspace(app, &json!({"name": name})), + None => Err(format!("no saved workspace for `{i}`")), + }, "view.pixelAspectRatio.custom" if no_params(params) => open(app, "customPar", json!({"name": "", "ratio": 1.0})), "view.pixelAspectRatio.custom" => custom_par(app, params), "view.show.showExtrasOptions" if no_params(params) => { @@ -609,6 +645,40 @@ mod tests { assert!(s2.prefs().workspaces.contains_key("Mine")); } + #[test] + fn saved_workspaces_are_listed_checked_and_selectable() { + let (mut app, ctx) = app(); + let ws_items = + |app: &PhotocraftApp| crate::menus::menu_items(app).into_iter().filter(|i| i.path == ["Window", "Workspace"] && i.id != "---").collect::>(); + assert!(!ws_items(&app).iter().any(|i| i.id.starts_with(SAVED))); + app.ui.panels.history = true; + crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Retouch"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Comp"})).unwrap(); + // Listed right after the presets, in name order; only the current one is checked. + let items = ws_items(&app); + let motion = items.iter().position(|i| i.id == "window.workspace.motion").unwrap(); + let saved: Vec<(&str, Option, bool)> = items[motion + 1..].iter().take(2).map(|i| (i.label.as_str(), i.checked, i.enabled)).collect(); + assert_eq!(saved, [("Comp", Some(true), true), ("Retouch", Some(false), true)]); + assert!(items.iter().filter(|i| !i.id.starts_with(SAVED)).all(|i| i.checked != Some(true) || i.id == "window.workspace.lockWorkspace")); + assert!(crate::menus::is_live(&format!("{SAVED}1"))); + // Switch to a preset, then back through the menu item. + crate::menus::invoke(&mut app, &ctx, "window.workspace.painting", json!({})).unwrap(); + assert!(!app.ui.panels.history); + let retouch = ws_items(&app).into_iter().find(|i| i.label == "Retouch").unwrap(); + crate::menus::invoke(&mut app, &ctx, &retouch.id, json!({})).unwrap(); + assert_eq!(app.ui.workspace, "Retouch"); + assert!(app.ui.panels.history); + assert_eq!(checked(&app, &retouch.id), Some(true)); + // Stale or malformed ids fail instead of panicking. + for bad in ["9", "-1", "x", ""] { + assert!(crate::menus::invoke(&mut app, &ctx, &format!("{SAVED}{bad}"), json!({})).is_err(), "{bad}"); + } + // The top-bar switcher lists them after the presets. + let keys: Vec = switcher_options(&app).into_iter().map(|(k, _)| k).collect(); + assert_eq!(keys[6..], ["Comp", "Retouch"]); + assert!(keys.contains(&app.ui.workspace)); + } + #[test] fn lock_workspace_toggles_and_persists() { let (mut app, ctx) = app(); From 8be3269fe1bf3baaa6cf76fbabf4c16de9b25974 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 13:51:51 +0300 Subject: [PATCH 0199/1452] style: rustfmt --- crates/ui-egui/src/history_panel_select_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs index 7c97dca106..5e744794f3 100644 --- a/crates/ui-egui/src/history_panel_select_tests.rs +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -81,8 +81,5 @@ fn new_history_steps_scroll_the_current_state_into_view() { h.run_steps(4); let row = h.get_all_by_label("New Layer").last().expect("the latest New Layer state").rect(); let view = h.ctx.input(|i| i.viewport_rect()); - assert!( - view.contains_rect(row), - "the current history row must scroll into view: row {row:?} view {view:?}", - ); + assert!(view.contains_rect(row), "the current history row must scroll into view: row {row:?} view {view:?}",); } From 3b97dc4f2c586d6e5b6b59782478e7da073e421e Mon Sep 17 00:00:00 2001 From: Hasan Date: Thu, 15 Oct 2026 11:02:37 +0000 Subject: [PATCH 0200/1452] Free Transform: picking another tool or layer applies a background commit (#5665, #5667) In the desktop app Free Transform commits as a background job. When another tool or layer was picked, end_if_left committed the box (#1400) and then, seeing it still open while the job ran, cancelled it as if the engine had refused the commit. Cancelling the job threw the transform away: an existing layer went back to where it was, and a dropped file was taken back, or, after New Layer, kept untransformed because its place was no longer the latest step (#5667). The tests commit inline, so they never saw it. A commit still running now ends the session when it lands. If the engine refuses it then, the box ends as Esc would, as an inline refusal already did. A transformed place folds into its Place Embedded step only when the place is the step just before the transform. After New Layer, the place, the new layer and the transform stay three steps, instead of the new layer being folded into the transform and labelled Place Embedded. --- crates/ui-egui/src/transform_tool.rs | 158 +++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d46ef189bc..0a1fdd2937 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -412,12 +412,16 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) { /// After the transform: the step that made the layer and the transform become one history step, /// the transform's for a copy and Place Embedded for a placed file. -fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { +fn fold_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId, made: MadeLayer) { // A background commit lands in its own document, which need not be the active one by then. let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return }; let active = app.session.active_index(); app.session.set_active(index); - if let Some(st) = app.session.active_mut() { + // Only when the step before the transform made the layer: a step taken while the box was + // open (New Layer, which also ends the box, #5667) stays its own, as does the made layer's. + if let Some(st) = app.session.active_mut() + && st.history.past_len().checked_sub(2).and_then(|i| st.history.state(i)).is_some_and(|before| before.layer(layer).is_none()) + { st.history.purge_last(); if made == MadeLayer::Place { st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED); @@ -1082,7 +1086,7 @@ pub fn commit(app: &mut PhotocraftApp) { fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made: Option, landed: bool) { let (Some(made), Some(document)) = (made, document) else { return }; if landed { - fold_made(app, document, made); + fold_made(app, document, layer, made); } else { take_back_made(app, document, layer); } @@ -1100,10 +1104,14 @@ pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { return; } let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; - // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. + // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline, + // unless picking another tool or layer started it: then the box ends, as Esc would. if let JobOutcome::Failed(msg) = &e.outcome { pv.committing = None; keep_open(app, t, Some(pv), msg.clone()); + if switched_away(app) { + cancel(app); + } return; } finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_))); @@ -1132,7 +1140,6 @@ pub fn end_if_left(app: &mut PhotocraftApp) { let layer = LayerId(t.layer); let made = t.made; - let selection = t.selection; let preview_doc_id = app.transform_preview.as_ref().map(|pv| pv.doc.id); let switched_document = preview_doc_id.is_some_and(|id| id != st.doc.id); @@ -1146,17 +1153,25 @@ pub fn end_if_left(app: &mut PhotocraftApp) { app.transform_preview = None; } else if switched_document || st.doc.layer(layer).is_none() { cancel(app); - } else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) - || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) - { + } else if switched_away(app) { commit(app); // Picking another tool or layer can't leave a refused transform open: it ends, as Esc would. - if app.ui.transform.is_some() { + // A commit still running in the background isn't refused: cancelling it would throw the + // transform away (and a dropped file with it, #5665). It ends the session when it lands, + // or ends it as Esc would if it is refused then (`on_job_event`). + if app.ui.transform.is_some() && committing(app).is_none() { cancel(app); } } } +/// Another tool picked, or (outside Transform Selection) another layer selected, since the box +/// opened: the session ends ([`end_if_left`], #1400). +fn switched_away(app: &PhotocraftApp) -> bool { + let (Some(t), Some(st)) = (&app.ui.transform, app.session.active()) else { return false }; + app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) || (!t.selection && st.active_layer.is_some_and(|active| active.0 != t.layer)) +} + /// Edits made while the box is open show in the preview at once, not only after the commit /// (#1384): the moving layer's opacity, fill and visibility tint the moving pixels, and the /// document under them (other layers, and the part of the layer a selection leaves behind) is @@ -3496,6 +3511,131 @@ mod tests { assert!(app.transform_preview.is_none(), "the transform preview should end"); } + /// Polls the app's background jobs until they have all landed. + fn land_jobs(app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + for _ in 0..2000 { + crate::jobs_ui::tick(app, &ctx); + if !app.session.has_jobs() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the background jobs never landed"); + } + + /// A smart object placed the way a dropped file is (the last step made it), in Free Transform + /// and moved 8 px right, with commits running in the background as in the desktop app: + /// (app, the placed layer, its frame before the move, history steps before the place). + fn moved_place() -> (PhotocraftApp, LayerId, photocraft_geom::Rect, usize) { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.session.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + app.sync_views(); + let st = app.session.active().unwrap(); + let placed = st.active_layer.unwrap(); + let frame = photocraft_engine::transform_cmds::transform_frame(&st.doc, st.doc.layer(placed).unwrap()); + begin_placed(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 8.0, y]); + app.background_jobs = true; + (app, placed, frame, steps) + } + + fn frame_of(app: &PhotocraftApp, layer: LayerId) -> Option { + let st = app.session.active()?; + Some(photocraft_engine::transform_cmds::transform_frame(&st.doc, st.doc.layer(layer)?)) + } + + /// #5665: picking another tool or selecting another layer while a dropped file is in Free + /// Transform applies the transform when the commit runs in the background too. Ending the + /// box used to cancel that commit, which took the dropped file back (or left a transformed + /// layer where it was). + #[test] + fn another_tool_or_layer_applies_a_transform_committed_in_the_background() { + for by_layer in [false, true] { + let (mut app, placed, frame, steps) = moved_place(); + if by_layer { + let below = app.session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|id| *id != placed).unwrap(); + app.run("layer.select", json!({"layer": below.0})).unwrap(); + } else { + app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush }; + } + end_if_left(&mut app); + assert!(committing(&app).is_some(), "the commit runs in the background"); + land_jobs(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box ended"); + assert_eq!(frame_of(&app, placed), Some(frame.translate(8, 0)), "the dropped file stays, moved (by layer: {by_layer})"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "the place and its move are one step"); + assert_eq!(st.history.undo_label(), Some(photocraft_engine::file_cmds::PLACE_EMBEDDED)); + } + } + + /// #5667: New Layer while a dropped file is in Free Transform applies the transform as it + /// shows (in the background too), and the place, the new layer and the transform each stay + /// their own step: the new layer isn't folded into the place. + #[test] + fn new_layer_applies_the_open_transform() { + let (mut app, placed, frame, steps) = moved_place(); + let layers = app.session.active().unwrap().doc.layers.len(); + app.run("layer.new.layer", json!({})).unwrap(); + end_if_left(&mut app); + land_jobs(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box ended"); + assert_eq!(frame_of(&app, placed), Some(frame.translate(8, 0)), "applied as it showed, not the original"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers + 1); + assert_eq!(st.history.past_len(), steps + 3, "place, New Layer, Free Transform"); + assert_eq!(st.history.undo_label(), Some("Free Transform")); + app.session.undo(); + assert_eq!(frame_of(&app, placed), Some(frame), "one undo takes back the move only"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the new layer stays"); + } + + /// #5665, the report's second case through the UI with background jobs on, as in the desktop + /// app: Free Transform on a layer, move it, then click another layer's row in the Layers panel. + #[test] + fn clicking_another_layer_row_applies_the_transform() { + use egui_kittest::kittest::{NodeT, Queryable}; + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let before = frame_of(h.state(), layer).unwrap(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + let t = h.state_mut().ui.transform.as_mut().expect("in Free Transform"); + t.quad = t.quad.map(|[x, y]| [x + 8.0, y]); + h.state_mut().background_jobs = true; + h.run_steps(2); + let row = h + .query_all_by_label("Background") + .find(|n| n.accesskit_node().role() == egui::accesskit::Role::Button) + .expect("the Background row") + .rect() + .center(); + h.hover_at(row); + h.run_steps(1); + h.drag_at(row); + h.run_steps(1); + h.drop_at(row); + for _ in 0..400 { + h.run_steps(1); + if h.state().ui.transform.is_none() && !h.state().session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_ne!(h.state().session.active().unwrap().active_layer, Some(layer), "the click selected the Background"); + assert!(h.state().ui.transform.is_none(), "the box ended"); + assert_eq!(frame_of(h.state(), layer), Some(before.translate(8, 0)), "the move was applied"); + } + /// Large documents need scales past 10000% and positions past 30000 px (#1267). #[test] fn transform_fields_reach_large_documents() { From c4f383e19089c1b627916be1901c9202805bdd17 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:18:39 +0800 Subject: [PATCH 0201/1452] test(compose): reproduce noise in empty effect blur tails --- crates/compose/src/effects.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..394ee47f45 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -3193,6 +3193,27 @@ mod tests { assert!(heavy.v.iter().any(|v| *v < 0.5) && heavy.v.iter().any(|v| *v > 0.5), "heavy noise spans the range",); } + #[test] + fn effect_noise_does_not_light_up_empty_blur_tails() { + // A sloping antialiased shape leaves rounding residue in a running-sum blur. + // The direct finite-support convolution is the oracle for untouched pixels. + for size in [5.0, 5.5, 13.0] { + let mut shape = Map::new(96, 64, 0.0); + for y in 8..48 { + for x in 8..48 { + shape.v[y * shape.w + x] = ((x as f32 - y as f32 * 0.7) * 0.3).clamp(0.0, 1.0); + } + } + let mut direct = shape.clone(); + tent_direct(&mut direct, size); + let mut actual = shape; + blur(&mut actual, size); + noise(&mut actual, 0.26, 0, 0); + let stray = direct.v.iter().zip(&actual.v).filter(|(reference, noisy)| **reference == 0.0 && **noisy > 1e-5).count(); + assert_eq!(stray, 0, "noise created {stray} visible pixels outside a size-{size} blur"); + } + } + #[test] fn glow_noise_follows_the_contour_and_range() { let g = |noise: f32| Glow { From 0e431aaf6dcc692a17dfc296210c552d08d65671 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:21:26 +0800 Subject: [PATCH 0202/1452] fix(compose): keep empty blur tails empty before effect noise --- crates/compose/src/effects.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 394ee47f45..d864128d2c 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1329,11 +1329,23 @@ fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) { } return; } - let mut inner: f64 = (x0 - (r - 1)..=x0 + (r - 1)).map(at).sum(); + let window = x0 - (r - 1)..=x0 + (r - 1); + let mut inner: f64 = window.clone().map(at).sum(); + let mut nonzero = window.map(at).filter(|v| *v != 0.0).count(); for (k, d) in dst.iter_mut().enumerate() { let x = x0 + k as i64; *d = (inner + f * (at(x - r) + at(x + r))) * norm; - inner += at(x + r) - at(x - r + 1); + let entering = at(x + r); + let leaving = at(x - r + 1); + inner += entering - leaving; + nonzero += usize::from(entering != 0.0); + nonzero -= usize::from(leaving != 0.0); + // Cancellation can leave positive residue after the last covered pixel leaves. + // Noise amplifies even that residue into visible speckles (#5638). Reset only + // truly empty windows, preserving arbitrarily faint real coverage. + if nonzero == 0 { + inner = 0.0; + } } } @@ -3193,6 +3205,16 @@ mod tests { assert!(heavy.v.iter().any(|v| *v < 0.5) && heavy.v.iter().any(|v| *v > 0.5), "heavy noise spans the range",); } + #[test] + fn box_blur_preserves_faint_coverage_and_empty_gaps() { + let src = [0.0, 0.3, 0.7, 0.0, 0.0, 0.0, 0.0, 1e-20, 0.0, 0.0, 0.0]; + let mut actual = [0.0; 11]; + box_line(&src, 0, &mut actual, 3.0); + assert_eq!(actual[5], 0.0, "an empty window must be exactly empty"); + assert_eq!(actual[10], 0.0, "the second tail must also be empty"); + assert!((actual[7] - 1e-20 / 3.0).abs() < 1e-30, "real faint coverage is not rounded away"); + } + #[test] fn effect_noise_does_not_light_up_empty_blur_tails() { // A sloping antialiased shape leaves rounding residue in a running-sum blur. From de095be6cb18192e5fe548d989ceaf534b9b3de7 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:26:04 +0800 Subject: [PATCH 0203/1452] perf(compose): track the last covered sample to reset empty sums --- crates/compose/src/effects.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d864128d2c..ed7ba5fc59 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1331,19 +1331,19 @@ fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) { } let window = x0 - (r - 1)..=x0 + (r - 1); let mut inner: f64 = window.clone().map(at).sum(); - let mut nonzero = window.map(at).filter(|v| *v != 0.0).count(); + let mut last_covered = window.rev().find(|&x| at(x) != 0.0).unwrap_or(i64::MIN); for (k, d) in dst.iter_mut().enumerate() { let x = x0 + k as i64; *d = (inner + f * (at(x - r) + at(x + r))) * norm; let entering = at(x + r); - let leaving = at(x - r + 1); - inner += entering - leaving; - nonzero += usize::from(entering != 0.0); - nonzero -= usize::from(leaving != 0.0); + inner += entering - at(x - r + 1); + if entering != 0.0 { + last_covered = x + r; + } // Cancellation can leave positive residue after the last covered pixel leaves. // Noise amplifies even that residue into visible speckles (#5638). Reset only // truly empty windows, preserving arbitrarily faint real coverage. - if nonzero == 0 { + if last_covered < x - r + 2 { inner = 0.0; } } From f116c3230a27fff30bc31b79b72c2cfb0f0ba074 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 15:47:38 +0400 Subject: [PATCH 0204/1452] Retouch: guard every stroke command, not only Pattern Stamp (#6110) Pattern Stamp was the only retouch command that refused a stroke it could not render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same dense conversion: coverage is accumulated in tiles, but dense_coverage() materialises the whole bounding box, and the retouch paths read a Region over that box for every channel. The box comes from the stroke's own points, which are validated against MAX_COORD rather than the document, so it is not bounded by the canvas. Both allocations go through the infallible vec!, which aborts instead of returning an error. With the guards now shared in retouch_cmds, apply them to the rest: reject_huge_stroke before anything is rendered, and reject_bounds before the dense region read where the command can return one. spot_heal_surface returns a Rect, so Spot Healing charges the stroke in the command; Remove builds a list, so each stroke is charged as it is parsed. The allowance is unchanged, so no stroke that worked before starts failing. Test: every retouch stroke refuses the oversized box, leaves the pixels alone, and still accepts a stroke across the whole canvas. --- crates/engine/src/retouch_cmds.rs | 5 +++ crates/engine/src/retouch_cmds/remove.rs | 2 + crates/engine/src/retouch_cmds/tests.rs | 47 ++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9726172062..b462d37519 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -358,6 +358,7 @@ fn clamp_samples(fmt: &PixelFormat, data: &mut [f32]) { fn clone_stamp(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.cloneStamp"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -383,6 +384,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -638,6 +640,7 @@ fn heal_region(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool]) -> fn healing_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.healingBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -645,6 +648,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -821,6 +825,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: fn spot_healing(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.spotHealing"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let kind = match string(p, "type", "contentAware") { "contentAware" => SpotType::ContentAware, "createTexture" => SpotType::CreateTexture, diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 4e256fb7fc..55c53d3af7 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -71,6 +71,7 @@ fn stroke_bounds(stroke: &Stroke) -> Rect { fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option)> { let Some(list) = p.get("strokes") else { let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; return Ok((vec![stroke], id)); }; let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?; @@ -86,6 +87,7 @@ fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option o.insert("points".into(), points.clone()); } let (stroke, layer) = parse_brush(s, &one, CMD)?; + reject_huge_stroke(CMD, &stroke)?; strokes.push(stroke); id = layer; } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index d72924c430..35f6e81efd 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,10 +544,10 @@ fn history_brush_restores_earlier_pixels() { } } -/// #4961: the History Brush's coverage and its source region are both dense over the stroke's -/// bounds, so a stroke whose box dwarfs the canvas is refused from the points and brush size -/// alone, before the source surface is cloned or any coverage is rendered. The document is left -/// as it was, and ordinary strokes still paint at every depth. +/// #4961 and #6110: every retouch stroke renders its coverage densely over the stroke's bounds +/// and reads a source region over the same box, so a stroke whose box dwarfs the canvas is +/// refused from the points and brush size alone, before anything is cloned or rendered. The +/// document is left as it was, and ordinary strokes still paint at every depth. #[test] fn history_brush_refuses_a_stroke_too_large_to_render() { for depth in DEPTHS { @@ -565,6 +565,45 @@ fn history_brush_refuses_a_stroke_too_large_to_render() { } } +/// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke and +/// leaves the pixels alone, and a stroke across the whole canvas still works. +#[test] +fn every_retouch_stroke_refuses_a_box_it_cannot_render() { + let huge = json!([[0, 0], [900_000, 900_000]]); + let ok = json!([[0, 0], [40, 20]]); + let cases: [(&str, Value); 5] = [ + ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.spotHealing", json!({"size": 8, "hardness": 100})), + ("paint.historyBrush", json!({"size": 8, "hardness": 100})), + ("paint.patternStamp", json!({"size": 8, "hardness": 100})), + ]; + for (cmd, base) in cases { + let mut s = session(40, 20, 8, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + let with = |points: &Value| { + let mut v = base.clone(); + if let Some(o) = v.as_object_mut() { + o.insert("points".into(), points.clone()); + } + v + }; + let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{cmd}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); + // The allowance still covers a stroke across the whole canvas. + let across = s.execute(cmd, with(&ok)); + assert!(across.is_ok() || !format!("{across:?}").contains("stroke too large"), "{cmd}: {across:?}"); + } + + // Remove takes a list of strokes; each one is charged as it is parsed. + let mut s = session(40, 20, 8, "rgb"); + let err = s.execute("paint.remove", json!({"strokes": [[[0, 0], [900_000, 900_000]]], "size": 8})).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "paint.remove: {err}"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. From 26cdfae307c3aed3d6464862bba50bdf0eac2515 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 22:55:05 +1100 Subject: [PATCH 0205/1452] IO: route declared Photoshop extensions through is_psd_input. The helpers-only use made cargo clippy -p photocraft-io --all-targets flag is_psd_input as never used; import_stages now calls it for the extension branch, keeping the magic-first order that lets an Affinity file renamed .psd stay with the Affinity loader. --- crates/io/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 7c67ab4970..7050cffd77 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -302,7 +302,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); } - if PSD_EXTS.iter().any(|e| has_extension(name, e)) { + // Same predicate as above; the magic case already returned, so this routes the declared + // `.psd`/`.psb`/`.psdt` extensions that are not Affinity containers. + if is_psd_input(name, bytes) { return import_psd(name, bytes, ctl); } if ora::is_ora(bytes) || has_extension(name, "ora") { From e23840f51615fbf4566790744da6a1f602574dd5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 13:57:54 +0200 Subject: [PATCH 0206/1452] History panel menu: review fixes (floating steps, cleared-state memory, clean undo, New Snapshot) - Step Backward in the History panel puts a floating selection back like Edit > Undo; Step Forward and Edit > Redo put it back and step instead of dropping it first (which recorded a Move and wiped the redo states); with nothing to step to the piece keeps floating. - Memory figures and the byte budget count the states Clear History keeps for its undo. - Undoing Clear History keeps a saved document clean. - New Snapshot... in the panel menu runs history.newSnapshot. - Tests: Clear on one document leaves the others alone, and each fix above. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/float_cmds.rs | 13 ++-- crates/engine/src/float_cmds/tests.rs | 37 +++++++++++ crates/engine/src/history_menu_cmds.rs | 37 +++++++++++ crates/engine/src/lib.rs | 12 ++++ crates/ops/src/lib.rs | 88 ++++++++++++++++++++++---- crates/ui-egui/src/history_menu_ui.rs | 21 ++++-- 6 files changed, 187 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index e5450b2339..f17a163be0 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -218,14 +218,17 @@ pub(crate) fn before_command(s: &mut Session, id: &str) -> Result> return Ok(None); } match id { - "edit.undo" | "edit.stepBackward" => { + // Edit › Undo and the History panel's Step Backward step back over the move itself. + "edit.undo" | "edit.stepBackward" | "history.stepBackward" => { st.floating = None; Ok(Some(json!({"floating": "returned"}))) } - // Returning to a History state puts the piece back too (it never reached the document), - // and then moves the history: dropping it first would add a step and discard the redo - // states the jump may be heading for. - "history.selectState" => { + // Redo / Step Forward with nothing to step to leave the piece floating (and are refused). + "edit.redo" | "history.stepForward" if !st.history.can_redo() => Ok(None), + // Returning to a History state, or stepping forward to one, puts the piece back too (it + // never reached the document), and then moves the history: dropping it first would add + // a step and discard the redo states the step is heading for. + "history.selectState" | "edit.redo" | "history.stepForward" => { st.floating = None; Ok(None) } diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index bb2ba5b91c..d0766553fd 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -65,6 +65,43 @@ fn any_other_command_drops_it_and_undo_puts_it_back() { assert!(alpha(&st.doc, id, 12, 12) == 0.0 && alpha(&st.doc, id, 35, 20) == 1.0); } +/// The History panel's Step Backward / Step Forward treat a floating piece like Edit › Undo / +/// Redo: no Move step is recorded and the redo states survive. +#[test] +fn history_panel_steps_put_the_piece_back_and_keep_the_redo_states() { + for back in ["edit.undo", "history.stepBackward"] { + let (mut s, id) = session(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + assert_eq!(s.execute(back, json!({})).unwrap()["floating"], json!("returned"), "{back}"); + let st = s.active().unwrap(); + assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0, "{back}: piece back"); + assert_eq!((st.history.past_len(), st.history.can_redo()), (steps, false), "{back}: no Move step"); + } + for forward in ["edit.redo", "history.stepForward"] { + let (mut s, id) = session(); + // A redo state (the deselection), then a moved piece over the selection undo restored. + s.execute("select.deselect", json!({})).unwrap(); + s.undo(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + s.execute(forward, json!({})).unwrap(); + let st = s.active().unwrap(); + assert!(floating(st).is_none(), "{forward}: piece back"); + assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "{forward}: the move never reached the document"); + assert!(st.doc.selection.is_none(), "{forward}: stepped forward to the deselection"); + assert_eq!((st.history.past_len(), st.history.can_redo()), (steps + 1, false), "{forward}: no Move step"); + // Nothing to step forward to: refused, and the piece keeps floating. + let (mut s, _) = session(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute(forward, json!({})).is_err(), "{forward}: nothing to redo"); + let st = s.active().unwrap(); + assert_eq!(floating(st).map(|f| f.offset), Some((20, 0)), "{forward}: still floating"); + assert_eq!(st.history.past_len(), steps); + } +} + #[test] fn needs_a_selection_and_an_unlocked_pixel_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/history_menu_cmds.rs b/crates/engine/src/history_menu_cmds.rs index 7154addbfb..5d079f7cf4 100644 --- a/crates/engine/src/history_menu_cmds.rs +++ b/crates/engine/src/history_menu_cmds.rs @@ -191,6 +191,43 @@ mod tests { assert!(!s.is_enabled("edit.undo"), "the cleared states are gone"); } + #[test] + fn clear_history_and_its_undo_keep_a_saved_document_clean() { + let mut s = session(); + let st = s.active_mut().unwrap(); + st.saved_revision = st.revision; + s.execute("history.clear", json!({})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "Clear History is no edit"); + let revision = s.active().unwrap().revision; + assert!(s.execute("edit.undo", json!({})).unwrap().as_bool().unwrap()); + let st = s.active().unwrap(); + assert!(!st.is_dirty(), "nor is undoing it"); + assert!(st.revision > revision, "the panels still see the list change"); + // A modified document stays modified. + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("history.clear", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + } + + #[test] + fn clear_history_leaves_other_documents_alone() { + let mut s = session(); + let first = entries(&s); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.active_index(), Some(1)); + s.execute("history.clear", json!({})).unwrap(); + assert_eq!(entries(&s).len(), 1); + let other = &s.documents()[0]; + assert_eq!(other.history.entries(), first, "the other document keeps its states"); + assert!(!other.history.is_cleared() && other.history.can_undo()); + assert!(s.set_active(0)); + assert!(s.is_enabled("history.clear") && s.is_enabled("history.stepBackward")); + s.execute("history.stepBackward", json!({})).unwrap(); + assert_eq!(entries(&s).len(), first.len() - 1); + } + #[test] fn history_menu_commands_refuse_without_a_document_and_ignore_odd_params() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 36d52d0fbc..cad74af31d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -844,6 +844,18 @@ impl Session { let Some(st) = self.active_mut() else { return false }; st.coalesce = None; match st.history.undo(st.doc.clone()) { + // Undo Clear History: the list comes back, the document is the same one. Not an + // edit: keep a clean document clean (the revision still moves for the panels). + Some((d, layers)) if Arc::ptr_eq(&d, &st.doc) => { + restore_target(st, layers); + let clean = !st.is_dirty(); + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + true + } Some((mut d, layers)) => { // Save As changes file identity outside history, just like the saved path. if d.name != st.doc.name { diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 5e8610ac1d..b3898429c8 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -91,10 +91,13 @@ impl History { if self.max_bytes == 0 { self.tiles = None; } + // The tile counter also counted the cleared states this step forgets: rebuild it lazily. + if self.cleared.take().is_some() { + self.tiles = None; + } if let Some(tiles) = &mut self.tiles { tiles.add(&before); } - self.cleared = None; let prev_label = std::mem::replace(&mut self.current_label, label.into()); let prev_layers = std::mem::replace(&mut self.current_layers, layers); self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers }); @@ -195,6 +198,7 @@ impl History { pub fn purge_last(&mut self) -> bool { self.clear_redo(); if self.cleared.take().is_some() { + self.tiles = None; return true; } let Some(state) = self.undo.pop_back() else { return false }; @@ -241,11 +245,26 @@ impl History { self.cleared.is_some() } + /// The undo and redo states history holds: the cleared ones while Clear History can still be + /// undone (the list is then empty), so memory figures and the byte budget count them. + fn held(&self) -> (&VecDeque, &Vec) { + match &self.cleared { + Some(c) => (&c.undo, &c.redo), + None => (&self.undo, &self.redo), + } + } + + /// Every state history holds, cleared ones included (see [`Self::held`]). + fn held_states(&self) -> impl Iterator { + let (undo, redo) = self.held(); + undo.iter().chain(redo.iter()) + } + /// Approximate unique pixel bytes held by history (tiles not shared with `current`). pub fn unique_bytes(&self, current: &Document) -> usize { let mut seen = HashSet::new(); tile_bytes(current, &mut seen); - self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum() + self.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum() } /// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`] @@ -256,7 +275,7 @@ impl History { } let mut seen = HashSet::new(); let own = tile_bytes(current, &mut seen); - self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) + self.held_states().fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) } /// Bytes of the tiles only `extra` documents hold (History panel snapshots, #4530): not @@ -267,7 +286,7 @@ impl History { } let mut seen = HashSet::new(); tile_bytes(current, &mut seen); - for s in self.undo.iter().chain(self.redo.iter()) { + for s in self.held_states() { tile_bytes(&s.doc, &mut seen); } extra.iter().fold(0, |n, d| n.saturating_add(tile_bytes(d, &mut seen))) @@ -281,7 +300,7 @@ impl History { for d in keep { tile_bytes(d, &mut seen); } - self.undo.iter().chain(self.redo.iter()).fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) + self.held_states().fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) } /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the @@ -296,7 +315,13 @@ impl History { /// snapshots, which outlive the states) against the budget too, so they can't take memory /// beyond it unseen (#4530). The most recent state is still always kept. pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize { - if self.max_bytes == 0 || self.undo.len() <= 1 { + // While Clear History can be undone, the cleared states are what history holds: the + // oldest of them go first, as the list's would. + let (undo, redo) = match &mut self.cleared { + Some(c) => (&mut c.undo, &c.redo), + None => (&mut self.undo, &self.redo), + }; + if self.max_bytes == 0 || undo.len() <= 1 { return 0; } // An upper bound of what only `pinned` holds (some of it may be in states too): if the @@ -310,7 +335,7 @@ impl History { }; let tiles = self.tiles.get_or_insert_with(|| { let mut tiles = tiles::Tiles::default(); - for state in self.undo.iter().chain(&self.redo) { + for state in undo.iter().chain(redo) { tiles.add(&state.doc); } tiles @@ -323,19 +348,19 @@ impl History { for d in pinned { total = total.saturating_add(tile_bytes(d, &mut seen)); } - for s in self.redo.iter().rev() { + for s in redo.iter().rev() { total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); } let mut keep = 0; - for s in self.undo.iter().rev() { + for s in undo.iter().rev() { total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); if total > self.max_bytes && keep >= 1 { break; } keep += 1; } - let drop = self.undo.len() - keep; - for state in self.undo.drain(..drop) { + let drop = undo.len() - keep; + for state in undo.drain(..drop) { tiles.remove(&state.doc); } drop @@ -659,7 +684,7 @@ mod tests { assert_eq!(counter.bytes_with(current), total as u128); assert_eq!(h.pixel_bytes(current), total); let mut seen = HashSet::new(); - let retained: usize = h.undo.iter().chain(&h.redo).map(|s| tile_bytes(&s.doc, &mut seen)).sum(); + let retained: usize = h.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum(); let empty = Document::new("empty", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); assert_eq!(counter.bytes_with(&empty), retained as u128); } @@ -924,6 +949,45 @@ mod tests { assert!(!h.is_cleared()); } + #[test] + fn cleared_states_still_count_in_the_memory_figures_and_the_budget() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = 256 * 256 * 4; + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + cur = h.undo(cur).unwrap().0; + let (unique, pixels) = (h.unique_bytes(&cur), h.pixel_bytes(&cur)); + assert_eq!(unique, 5 * tile, "four undo states and one redo state"); + assert!(h.clear_states()); + assert_eq!(h.unique_bytes(&cur), unique, "Clear History frees nothing while it can be undone"); + assert_eq!(h.pixel_bytes(&cur), pixels); + assert_eq!(h.extra_bytes(&cur, &[&base()]), tile, "a snapshot's own tile, not the cleared states'"); + assert_eq!(h.unique_bytes_besides(&cur, &[]), unique); + // The budget counts them too: the oldest cleared states go first. + h.max_bytes = 4 * tile; + assert_eq!(h.trim(&cur), 2); + assert_counter(&h, &cur); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared"); + h.undo(cur.clone()).unwrap(); + assert_eq!(h.entries(), ["paint 1", "paint 2", "paint 3"], "undo brings back what the budget kept"); + assert_eq!(h.redo_labels().collect::>(), ["paint 4"]); + // A new step after Clear History forgets the cleared states, and the counter with them. + assert!(h.clear_states()); + h.trim(&cur); + edit(&mut h, &mut cur, "paint 5", |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]); + }); + assert_eq!(h.unique_bytes(&cur), tile, "only the state before the new step"); + assert_eq!(h.pixel_bytes(&cur), 2 * tile); + } + #[test] fn a_new_step_or_purge_forgets_the_cleared_states() { let mut h = History::new(10); diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index e0768f5ba6..cf95f18a01 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -12,8 +12,9 @@ pub const ITEMS: &[(&str, Option<&str>)] = &[ ("Step Forward", Some("history.stepForward")), ("Step Backward", Some("history.stepBackward")), ("---", None), - // Snapshots are not implemented yet. - ("New Snapshot…", None), + // Takes a Full Document snapshot at once; switches to the New Snapshot dialog when that + // lands (#3567). + ("New Snapshot…", Some("history.newSnapshot")), ("Delete", Some("history.deleteState")), ("Clear History", Some("history.clear")), ("New Document", Some("history.newDocument")), @@ -108,7 +109,8 @@ mod tests { let mut h = harness(app()); assert_eq!(states(&h), (4, 0)); assert!(is_disabled(&h, "Step Forward") && !is_disabled(&h, "Step Backward")); - assert!(is_disabled(&h, "New Snapshot…") && is_disabled(&h, "History Options…"), "not built yet: greyed"); + assert!(is_disabled(&h, "History Options…"), "not built yet: greyed"); + assert!(!is_disabled(&h, "New Snapshot…")); click(&mut h, "Step Backward"); assert_eq!(states(&h), (3, 1)); assert!(!is_disabled(&h, "Step Forward")); @@ -146,11 +148,22 @@ mod tests { let ctx = h.ctx.clone(); crate::transform_tool::begin(h.state_mut(), &ctx).unwrap(); h.run_steps(2); - for label in ["Step Backward", "Delete", "Clear History", "New Document"] { + for label in ["Step Backward", "New Snapshot…", "Delete", "Clear History", "New Document"] { assert!(is_disabled(&h, label), "{label}"); } } + #[test] + fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { + let mut h = harness(app()); + let snaps = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().snapshots.len(); + let (before, steps) = (snaps(&h), states(&h)); + click(&mut h, "New Snapshot…"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(snaps(&h), before + 1); + assert_eq!(states(&h), steps, "a snapshot is no history step"); + } + /// With a snapshot selected in the panel, Delete removes the snapshot and leaves the states, /// as the panel's trash does (#5324); without one it removes the current state. #[test] From 8435a4586f9398612be769c8e5c2e44cde041011 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 13:58:10 +0200 Subject: [PATCH 0207/1452] History Brush source: snapshot sources, size check, purge and budget fixes - Edit > Purge > Histories resets a state source to the default instead of leaving every document without one (History::clear). - The pinned source state counts against the history byte budget first; the newest states fill what remains, so trimming stays within max_bytes. - The History Brush and Fill > History refuse a source of another canvas size (after Crop, Canvas Size or Image Size) instead of painting shifted pixels. - Snapshots can be the source: history.setSource {snapshot}, a source column on snapshot rows, HistorySource::State|Snapshot in the engine; deleting the snapshot leaves no source, Purge > Histories keeps it. - paint.historyBrush and Fill > History take History panel rows for `state` (redo rows included), as history.setSource does, and accept `snapshot`. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 2 +- crates/engine/src/fill_cmds.rs | 13 +- crates/engine/src/history_source_cmds.rs | 240 +++++++++++++++++++---- crates/engine/src/lib.rs | 4 + crates/engine/src/retouch_cmds.rs | 15 +- crates/ops/src/lib.rs | 91 +++++++-- crates/ui-egui/src/panels.rs | 41 +++- 7 files changed, 329 insertions(+), 77 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index dd7e784752..6f04a8a651 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -503,7 +503,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index ba3c92638c..13bbe8e78a 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -120,17 +120,8 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { Source::Pixels(area, photocraft_compose::pattern::render(&tile, &place, area)) } "history" => { - let explicit = match p.get("state") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().ok_or_else(|| bad(format!("`state` must be a history state index, not {v}")))? as usize), - }; - let past = st.history.past_len(); - let source_doc = match explicit { - Some(i) if i == past => st.doc.clone(), - Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?, - // The History Brush's source (History panel), by default the oldest state held. - None => crate::history_source_cmds::source_doc(st)?, - }; + // A History panel row `state` or a `snapshot`, else the History Brush's source. + let source_doc = crate::history_source_cmds::paint_source(st, p, CMD)?; let src = crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p) .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?; Source::Pixels(area, read_rgba(src, area, "reading Fill's history source")?) diff --git a/crates/engine/src/history_source_cmds.rs b/crates/engine/src/history_source_cmds.rs index 6457dd2569..c84cbe8400 100644 --- a/crates/engine/src/history_source_cmds.rs +++ b/crates/engine/src/history_source_cmds.rs @@ -1,14 +1,21 @@ //! The History Brush's source (#2966): the History panel's source column, at the left of every -//! state, sets which state the History Brush (and Fill › History) restores from. Exactly one +//! state and snapshot, sets what the History Brush (and Fill › History) restores from. Exactly one //! source per document; by default the oldest state held (Photoshop's default is the first //! snapshot, the document as opened, which that state is until it falls off the history). //! -//! The source is kept by state, not by index ([`photocraft_ops::History::set_brush_source`]), so -//! it stays on its state while older states fall off, and that state is kept when it would fall -//! off the history itself. Once it is deleted, or discarded by a new step after stepping back, -//! there is no source until one is set, and the History Brush refuses to paint without an -//! explicit `state`, as Photoshop does. (Photoshop keeps a source discarded by a new step in the -//! list; that needs states outside the linear history, so here it is gone.) +//! A state source is kept by state, not by index ([`photocraft_ops::History::set_brush_source`]), +//! so it stays on its state while older states fall off, and that state is kept when it would fall +//! off the history itself. A snapshot source is kept by snapshot id ([`HistorySource::Snapshot`]), +//! a separate namespace from the states. Once the source state or snapshot is deleted, or a redo +//! state source is discarded by a new step, there is no source until one is set, and the History +//! Brush refuses to paint without an explicit `state` or `snapshot`, as Photoshop does. +//! (Photoshop keeps a source discarded by a new step in the list; that needs states outside the +//! linear history, so here it is gone.) Edit › Purge › Histories keeps the snapshots (and a +//! snapshot source) and resets a state source to the default. +//! +//! Rows: `state` is a History panel row everywhere here (this command, `paint.historyBrush`, Fill +//! › History and `historySource` in `doc_inspect`): the states (0 = oldest), the current one, then +//! the greyed redo states, the next redo first. use std::sync::Arc; @@ -24,41 +31,110 @@ fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } } -/// The document the History Brush paints from when no `state` is passed: the source state set in -/// the History panel, or the default (the oldest state held). -pub(crate) fn source_doc(st: &DocState) -> Result> { - st.history - .brush_source() - .and_then(|i| st.history.row_doc(i, &st.doc)) - .ok_or_else(|| EngineError::Other("the History Brush source state was deleted: set a new source in the History panel".into())) +/// What the History Brush paints from: a History panel state row, or a snapshot by id. The two +/// are separate, so snapshot 1 and state row 1 never stand for each other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HistorySource { + /// A History panel row ([`photocraft_ops::History::row_id`]). + State(usize), + /// A History panel snapshot ([`crate::history_cmds::Snapshot::id`]). + Snapshot(u64), } -/// `history.setSource {state}`: make a History panel row the History Brush's source. Rows are -/// numbered as the panel lists them: the states (0 = oldest, then the current one), then the -/// greyed states a step back left redoable. -fn set_source(s: &mut Session, p: &Value) -> Result { - if p.get("snapshot").is_some_and(|v| !v.is_null()) { - return Err(EngineError::Other("History snapshots are not supported yet: set the source to a `state`".into())); +/// The document's History Brush source, `None` once the source state or snapshot is gone. +pub fn source(st: &DocState) -> Option { + match st.source_snapshot { + Some(id) => st.snapshots.iter().any(|s| s.id == id).then_some(HistorySource::Snapshot(id)), + None => st.history.brush_source().map(HistorySource::State), } - let v = match p.get("state") { - None | Some(Value::Null) => return Err(bad("pass the `state` to paint from (a History panel row, 0 = oldest)")), - Some(v) => v, +} + +fn source_of(st: &DocState, src: HistorySource) -> Option> { + match src { + HistorySource::State(i) => st.history.row_doc(i, &st.doc), + HistorySource::Snapshot(id) => st.snapshots.iter().find(|s| s.id == id).map(|s| s.doc.clone()), + } +} + +/// A `state` or `snapshot` param (`None` when neither is passed), checked against the document. +fn parse(st: &DocState, p: &Value, cmd: &str) -> Result> { + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let get = |k: &str| p.get(k).filter(|v| !v.is_null()); + let index = |k: &str, v: &Value| v.as_u64().ok_or_else(|| bad(format!("`{k}` must be a non-negative integer, not {v}"))); + match (get("state"), get("snapshot")) { + (Some(_), Some(_)) => Err(bad("pass a `state` or a `snapshot`, not both".into())), + (Some(v), None) => { + let rows = st.history.entries().len().saturating_add(st.history.redo_labels().count()); + let i = usize::try_from(index("state", v)?).ok().filter(|i| *i < rows); + let i = i.ok_or_else(|| bad(format!("no history state {v}: a History panel row (0..{rows}, the greyed redo rows last)")))?; + Ok(Some(HistorySource::State(i))) + } + (None, Some(v)) => { + let id = index("snapshot", v)?; + if !st.snapshots.iter().any(|s| s.id == id) { + return Err(bad(format!("no snapshot {id}"))); + } + Ok(Some(HistorySource::Snapshot(id))) + } + (None, None) => Ok(None), + } +} + +/// The document the History Brush and Fill › History paint from: the `state` (History panel row) +/// or `snapshot` passed, else the source set in the History panel. A source of another canvas +/// size is refused rather than painting shifted pixels (after Crop, Canvas Size or Image Size): +/// Photoshop refuses too, "the current canvas size does not match that of the history state" +/// (its message as users report it; Adobe's help doesn't document the case). +pub(crate) fn paint_source(st: &DocState, p: &Value, cmd: &str) -> Result> { + let doc = match parse(st, p, cmd)? { + Some(src) => source_of(st, src).ok_or_else(|| EngineError::Other("that history state or snapshot is gone".into()))?, + None => source(st) + .and_then(|src| source_of(st, src)) + .ok_or_else(|| EngineError::Other("the History Brush source was deleted: set a new source in the History panel".into()))?, }; + if doc.size != st.doc.size { + return Err(EngineError::Other(format!( + "the current canvas size ({}×{}) does not match that of the History Brush source ({}×{}): set a source of this size in the History panel", + st.doc.size.width, st.doc.size.height, doc.size.width, doc.size.height + ))); + } + Ok(doc) +} + +/// `history.setSource {state}` or `{snapshot}`: make a History panel row or a snapshot the History +/// Brush's source. +fn set_source(s: &mut Session, p: &Value) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; - let rows = st.history.entries().len() + st.history.redo_labels().count(); - let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(format!("`state` must be a History panel row (0..{rows}), not {v}")))?; - if !st.history.set_brush_source(i) { - return Err(bad(format!("no history state {i} (0..{rows})"))); + match parse(st, p, CMD)? { + None => return Err(bad("pass the `state` (a History panel row, 0 = oldest) or the `snapshot` id to paint from")), + Some(HistorySource::State(i)) => { + if !st.history.set_brush_source(i) { + return Err(bad(format!("no history state {i}"))); + } + st.source_snapshot = None; + } + Some(HistorySource::Snapshot(id)) => { + // No state is the source now, so none is kept past its time. + st.history.reset_brush_source(); + st.source_snapshot = Some(id); + } } Ok(source_json(st)) } -/// The source as `doc_inspect` reports it: its History panel row and label (`null` when its state -/// was deleted). +/// The source as `doc_inspect` reports it: `{state,label}` (a History panel row) or +/// `{snapshot,name}`; `null` once its state or snapshot is gone. pub fn source_json(st: &DocState) -> Value { - let Some(i) = st.history.brush_source() else { return Value::Null }; - let label = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_string)).nth(i); - json!({ "state": i, "label": label }) + match source(st) { + None => Value::Null, + Some(HistorySource::State(i)) => { + let label = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_string)).nth(i); + json!({ "state": i, "label": label }) + } + Some(HistorySource::Snapshot(id)) => { + json!({ "snapshot": id, "name": st.snapshots.iter().find(|s| s.id == id).map(|s| s.name.clone()) }) + } + } } fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -71,7 +147,7 @@ pub fn specs() -> Vec { label: "Set History Brush Source", menu: &[], shortcut: None, - params: r##"{"state":index} (History panel row: 0 = oldest state, then the current one, then the greyed redo states; `snapshot` is not supported yet) → {state,label}: paint.historyBrush and Fill › History use this state when no `state` is passed"##, + params: r##"{"state":row} | {"snapshot":id} (state: a History panel row, 0 = oldest state, then the current one, then the greyed redo states; snapshot: a history.newSnapshot id) → {state,label} | {snapshot,name}: paint.historyBrush and Fill › History use this source when neither `state` nor `snapshot` is passed"##, enabled: has_doc, run: set_source, journal: true, @@ -183,6 +259,100 @@ mod tests { assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); } + /// Edit › Purge › Histories clears the states: the source goes back to the default instead of + /// leaving every document without one (#3536 review). + #[test] + fn purging_histories_resets_a_state_source() { + let mut s = session(); + s.execute(CMD, json!({"state": 1})).unwrap(); + s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], json!({"state": 0, "label": "Fill"})); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the only state left: {:?}", rgb(&s)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + } + + /// After Canvas Size (or Crop, Image Size) the old states have another size: painting from one + /// would shift the pixels, so the History Brush and Fill › History refuse, as Photoshop does. + #[test] + fn a_source_of_another_canvas_size_is_refused() { + let mut s = session(); + s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap(); + for cmd in ["paint.historyBrush", "edit.fill"] { + for state in [json!(null), json!(0), json!(2)] { + let p = + if cmd == "edit.fill" { json!({"contents": "history", "state": state}) } else { json!({"points": [[4, 4]], "size": 40, "state": state}) }; + let e = s.execute(cmd, p.clone()).unwrap_err().to_string(); + assert!(e.contains("canvas size"), "{cmd} {p}: {e}"); + } + } + assert_eq!(s.active().unwrap().history.entries().len(), 4, "nothing painted"); + // The state after the resize is the same size: it paints. + s.execute(CMD, json!({"state": 3})).unwrap(); + brush(&mut s, json!({})); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + // A snapshot of the old size is refused the same way. + let snap = s.execute("history.newSnapshot", json!({})).unwrap()["snapshot"].clone(); + s.execute("image.canvasSize", json!({"width": 8, "height": 8, "anchor": "topLeft"})).unwrap(); + s.execute(CMD, json!({"snapshot": snap})).unwrap(); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + } + + /// `state` is a History panel row for the History Brush and Fill too: the greyed redo rows + /// count, as in `history.setSource` and `historySource`. + #[test] + fn the_brush_and_fill_take_redo_rows() { + let mut s = session(); + assert!(s.undo()); + brush(&mut s, json!({"state": 2})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the greyed green state: {:?}", rgb(&s)); + let mut s = session(); + assert!(s.undo()); + s.execute("edit.fill", json!({"contents": "history", "state": 2})).unwrap(); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "{:?}", rgb(&s)); + assert!(s.execute("edit.fill", json!({"contents": "history", "state": 4})).is_err(), "no such row"); + } + + /// A snapshot as the source (the column on snapshot rows): kept by snapshot id, apart from the + /// state rows; deleting it leaves no source; Purge › Histories keeps it. + #[test] + fn a_snapshot_can_be_the_source() { + let mut s = session(); + assert!(s.undo()); + let snap = s.execute("history.newSnapshot", json!({"name": "Red"})).unwrap()["snapshot"].as_u64().unwrap(); + assert_eq!(snap, 1, "snapshot 1, while state row 1 is the red fill too: they must not mix"); + assert!(s.redo()); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let r = s.execute(CMD, json!({"snapshot": snap})).unwrap(); + assert_eq!(r, json!({"snapshot": 1, "name": "Red"})); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], r); + // No state is the source now: the default would be state 0 (white). + assert_eq!(s.active().unwrap().history.brush_source(), Some(0)); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "the snapshot paints: {:?}", rgb(&s)); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + // An explicit snapshot or state still wins. + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + brush(&mut s, json!({"snapshot": snap})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + // Purging histories keeps the snapshots and so the source. + s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], r); + // A state source replaces it. + s.execute(CMD, json!({"state": 0})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"]["state"], 0); + s.execute(CMD, json!({"snapshot": snap})).unwrap(); + // Deleting the snapshot leaves no source. + s.execute("history.deleteSnapshot", json!({"snapshot": snap})).unwrap(); + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + assert!(s.execute("edit.fill", json!({"contents": "history"})).is_err()); + assert!(s.execute(CMD, json!({"snapshot": snap})).is_err(), "it is gone"); + } + #[test] fn bad_params_and_no_document_are_errors() { assert!(matches!(Session::new().execute(CMD, json!({"state": 0})), Err(EngineError::NoDocument | EngineError::Disabled(..)))); @@ -197,7 +367,11 @@ mod tests { json!({"state": "1"}), json!({"state": [1]}), json!({"snapshot": 0}), + json!({"snapshot": 1}), + json!({"snapshot": -1}), + json!({"snapshot": "Open"}), json!({"snapshot": "Open", "state": 1}), + json!({"snapshot": 1, "state": 1}), ] { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f8eea7ffe5..85a8cf9a99 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -233,6 +233,9 @@ pub struct DocState { pub snapshots: Vec, /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. next_snapshot: u64, + /// The snapshot set as the History Brush's source (`history.setSource {snapshot}`); `None`: + /// the source is a state, kept by the history ([`photocraft_ops::History::brush_source`]). + pub(crate) source_snapshot: Option, } impl DocState { @@ -276,6 +279,7 @@ impl DocState { color_samplers: Vec::new(), snapshots: Vec::new(), next_snapshot: 1, + source_snapshot: None, } } /// The selected layers in bottom-to-top document order, always including the active layer. diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 6c06b1ea0a..d8a19659a0 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -612,17 +612,12 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { Ok(clone_result(dmg, off, aligned, p, &stroke)) } -/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in history -/// state `state` (default: the source set in the History panel, `history.setSource`, else the -/// oldest state still held, the "Open" snapshot unless trimmed). +/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in History +/// panel row `state` or snapshot `snapshot` (default: the source set in the History panel, +/// `history.setSource`, else the oldest state still held, the "Open" state unless trimmed). fn history_source(s: &Session, id: Option, p: &Value, cmd: &str) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; - let explicit = p.get("state").and_then(Value::as_u64).map(|v| v as usize); - let source_doc = match explicit { - Some(i) if i == st.history.past_len() => st.doc.clone(), - Some(i) => st.history.state(i).ok_or_else(|| bad(cmd, format!("no history state {i} (0..={})", st.history.past_len())))?, - None => crate::history_source_cmds::source_doc(st)?, - }; + let source_doc = crate::history_source_cmds::paint_source(st, p, cmd)?; crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p) .cloned() .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into())) @@ -1365,7 +1360,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":index (into history.entries; default the History panel's source, history.setSource, else 0 = the oldest held state)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index a4fd920313..2cffcce1ca 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -255,10 +255,19 @@ impl History { self.redo.clear(); } + /// Forget every past and redo state (Edit › Purge › Histories). The History Brush's source + /// goes back to the default, the oldest state held (now the current one): a source set on a + /// state the purge removed would otherwise leave no source. pub fn clear(&mut self) { self.undo.clear(); self.redo.clear(); self.tiles = None; + self.reset_brush_source(); + } + + /// Make the History Brush's source the default again (the oldest state held), pinning no state. + pub fn reset_brush_source(&mut self) { + self.brush_source = None; } /// Approximate unique pixel bytes held by history (tiles not shared with `current`). @@ -306,8 +315,10 @@ impl History { /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the /// tiles only history holds (newest states first). The oldest undo states that don't fit are - /// dropped; the most recent one is always kept so the last step can be undone. Returns how - /// many states were dropped. + /// dropped; the most recent one is always kept so the last step can be undone. The History + /// Brush's source state ([`History::set_brush_source`]) is never dropped: its unique tiles are + /// counted first, and the newest states fill what remains. Returns how many states were + /// dropped. pub fn trim(&mut self, current: &Document) -> usize { self.trim_with(current, &[]) } @@ -346,29 +357,35 @@ impl History { for s in self.redo.iter().rev() { total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); } + // The History Brush's source is kept whatever its age, so its own tiles come first; the + // newest states then fill what remains of the budget (the most recent one always). + let source = self.brush_source.and_then(|id| self.undo.iter().position(|s| s.id == id)); + if let Some(state) = source.and_then(|i| self.undo.get(i)) { + total = total.saturating_add(tile_bytes(&state.doc, &mut seen)); + } let mut keep = 0; - for s in self.undo.iter().rev() { + for (i, s) in self.undo.iter().enumerate().rev() { + if Some(i) == source { + continue; + } total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); if total > self.max_bytes && keep >= 1 { break; } keep += 1; } - let drop = self.undo.len() - keep; - let mut pinned = None; - for state in self.undo.drain(..drop) { - // The History Brush's source is kept, as when states fall off the count limit. - if Some(state.id) == self.brush_source { - pinned = Some(state); + let mut kept = VecDeque::with_capacity(keep + 1); + let mut dropped = 0; + for (i, state) in std::mem::take(&mut self.undo).into_iter().enumerate().rev() { + if Some(i) == source || kept.len() < keep + usize::from(source.is_some_and(|j| j > i)) { + kept.push_front(state); } else { tiles.remove(&state.doc); + dropped += 1; } } - let kept = usize::from(pinned.is_some()); - if let Some(state) = pinned { - self.undo.push_front(state); - } - drop - kept + self.undo = kept; + dropped } } @@ -524,6 +541,52 @@ mod tests { assert_eq!(h.brush_source(), Some(0)); } + /// The pinned source counts against the byte budget: with room for three states, the + /// source and the two newest are kept, never more than the budget (#3536 review). + #[test] + fn byte_budget_counts_the_brush_source_first() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = h.pixel_bytes(&cur); + assert!(tile > 0); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + // Every state holds one tile of its own; the source is the oldest (Open). + assert!(h.set_brush_source(0)); + h.max_bytes = tile * 4; + assert_eq!(h.trim(&cur), 2); + assert_eq!(h.entries(), ["Open", "paint 2", "paint 3", "paint 4"]); + assert!(h.pixel_bytes(&cur) <= h.max_bytes, "{} > {}", h.pixel_bytes(&cur), h.max_bytes); + assert_eq!(h.trim(&cur), 0, "it fits now"); + // A new step trims to the budget again, still keeping the source. + edit(&mut h, &mut cur, "paint 5", |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]); + }); + assert_eq!(h.trim(&cur), 1); + assert_eq!(h.entries(), ["Open", "paint 3", "paint 4", "paint 5"]); + assert!(h.pixel_bytes(&cur) <= h.max_bytes); + assert_eq!(h.brush_source(), Some(0)); + } + + /// Edit › Purge › Histories clears every state: a source set on one of them goes back to the + /// default (the oldest state held) instead of leaving no source (#3536 review). + #[test] + fn clear_resets_the_brush_source() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(h.set_brush_source(1)); + h.clear(); + assert_eq!(h.brush_source(), Some(0)); + assert_eq!(h.row_doc(0, &cur).unwrap().name, "b"); + } + #[test] fn new_edit_clears_redo() { let mut h = History::default(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a716c7e585..75eb730e43 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3,6 +3,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent, LayerId}; +use photocraft_engine::history_source_cmds::HistorySource; use serde_json::{Value, json}; use crate::mask_thumbs_ui::MaskKind; @@ -3091,7 +3092,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); - let source = st.history.brush_source(); + let source = photocraft_engine::history_source_cmds::source(st); let selected_snap = selected_snapshot(ui.ctx(), st); let ctx = ui.ctx().clone(); // A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it. @@ -3156,7 +3157,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); - if history_source_column(ui, rect, i, source == Some(i)) { + if history_source_column(ui, rect, egui::Id::new(("history-source", i)), source == Some(HistorySource::State(i))) { set_source = Some(i); } if resp.clicked() && i != current { @@ -3245,13 +3246,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -/// The History panel's source column at the left of a state row: the History Brush icon on the -/// source state; a click makes the row the source (`history.setSource`). It sits above the row, so -/// the click doesn't also step to the state. -fn history_source_column(ui: &mut egui::Ui, row: Rect, i: usize, is_source: bool) -> bool { +/// The History panel's source column at the left of a state or snapshot row: the History Brush +/// icon on the source; a click makes the row the source (`history.setSource`). It sits above the +/// row, so the click doesn't also step to the state or snapshot. +fn history_source_column(ui: &mut egui::Ui, row: Rect, salt: egui::Id, is_source: bool) -> bool { let t = Tokens::get(ui.ctx()); let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); - let resp = ui.interact(cell, ui.id().with(("history-source", i)), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); + let resp = ui.interact(cell, ui.id().with(salt), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); if is_source { icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.icon); } else if resp.hovered() { @@ -3306,6 +3307,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = snaps.iter().map(|s| s.id).collect(); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); @@ -3353,7 +3355,9 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 38.0)).map(|w| w.rect).collect()); + let cell = *cells.first().expect("a snapshot source cell"); + click(&mut h, cell.center()); + let st = h.state().session.active().unwrap(); + assert_eq!(photocraft_engine::history_source_cmds::source(st), Some(HistorySource::Snapshot(1))); + assert_eq!(st.history.entries().len(), 2, "the document did not return to the snapshot"); + } } #[cfg(test)] From 2ab37e7e55c426e0a11c838b8431d2192e5e7584 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:06:00 +0200 Subject: [PATCH 0208/1452] Scorecard: regenerate after merging main (TOOL-214-12 done, #5846) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..e9d0fbb5af 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | From 65ca7c1bbdbf44f2359bd4957e53d8b4e2d12242 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Thu, 15 Oct 2026 09:00:20 -0400 Subject: [PATCH 0209/1452] remove unused deps and dead code --- Cargo.lock | 4 -- apps/photocraft/src/services.rs | 68 +------------------ .../photocraft/src/services/recovery_tests.rs | 3 +- crates/automation/Cargo.toml | 1 - crates/automation/src/server.rs | 2 +- crates/engine/src/float_cmds.rs | 2 +- crates/testkit/Cargo.toml | 3 - 7 files changed, 6 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a268add9..0807a9f377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4690,7 +4690,6 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", - "photocraft-geom", "photocraft-io", "rmcp", "schemars", @@ -4979,9 +4978,6 @@ name = "photocraft-testkit" version = "0.9.0" dependencies = [ "memory-stats", - "photocraft-color", - "photocraft-geom", - "photocraft-raster", "serde_json", ] diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..09714a4ae4 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -1,10 +1,8 @@ //! Native platform services: file dialogs (rfd), filesystem, codecs. -use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS}; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Layer, Size}; +use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; +use photocraft_doc::Document; use photocraft_format::RecoveryStore; -use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; use std::cell::RefCell; use std::future::Future; @@ -515,68 +513,6 @@ fn platform_beep() { } } -/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io). -#[allow(dead_code)] -pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?; - let (w, h) = (img.width(), img.height()); - let depth = match img.sample_type() { - CS::U8 => SampleType::U8, - CS::U16 => SampleType::U16, - _ => SampleType::F32, - }; - let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA); - let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA); - let mode = if gray { - ColorMode::Grayscale - } else if cmyk { - ColorMode::Cmyk - } else { - ColorMode::Rgb - }; - let target = match mode { - ColorMode::Grayscale => ChannelLayout::GrayA, - ColorMode::Cmyk => ChannelLayout::CmykA, - _ => ChannelLayout::Rgba, - }; - let sample = match depth { - SampleType::U8 => CS::U8, - SampleType::U16 => CS::U16, - SampleType::F32 => CS::F32, - }; - let conv = img.convert(target, sample); - let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string()); - let mut doc = Document::new(stem, Size::new(w, h), mode, depth); - doc.icc_profile = img.icc.clone().map(std::sync::Arc::new); - if let Some((x, _)) = img.meta.dpi { - doc.resolution_dpi = x; - } - let mut layer = Layer::raster("Background", doc.pixel_format()); - let data = conv.to_normalized(); - layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data); - doc.layers.push(layer); - Ok(doc) -} - -#[allow(dead_code)] -pub fn export_flat(doc: &Document, path: &str) -> Result, String> { - let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?; - let buf = photocraft_compose::flatten(doc); - let (w, h) = (buf.rect.width(), buf.rect.height()); - let data: Vec = buf.px.iter().flat_map(|p| *p).collect(); - let img = match doc.depth { - SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels), - SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::>()), - SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data), - } - .map_err(|e| e.to_string())?; - let img = match &doc.icc_profile { - Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())), - _ => img, - }; - photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/photocraft/src/services/recovery_tests.rs b/apps/photocraft/src/services/recovery_tests.rs index b1bd2d51c0..23191ed7f9 100644 --- a/apps/photocraft/src/services/recovery_tests.rs +++ b/apps/photocraft/src/services/recovery_tests.rs @@ -1,5 +1,6 @@ use super::*; -use photocraft_color::Color; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::Size; use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery}; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 0738140a41..574ad1250c 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -10,7 +10,6 @@ rust-version.workspace = true workspace = true [dependencies] -photocraft-geom = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f223bc5109..e93566cbc3 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -1174,7 +1174,7 @@ mod tests { break; } assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}"); - std::thread::sleep(std::time::Duration::from_millis(5)); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; } } } diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index e5450b2339..09aff81b7e 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -218,7 +218,7 @@ pub(crate) fn before_command(s: &mut Session, id: &str) -> Result> return Ok(None); } match id { - "edit.undo" | "edit.stepBackward" => { + "edit.undo" => { st.floating = None; Ok(Some(json!({"floating": "returned"}))) } diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 0d69300d90..80e533935b 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -8,9 +8,6 @@ license.workspace = true workspace = true [dependencies] -photocraft-geom = { workspace = true } -photocraft-color = { workspace = true } -photocraft-raster = { workspace = true } serde_json = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 4623c3068c5f0511104cec2acc4e2ab2e9eba54b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:20:51 +1100 Subject: [PATCH 0210/1452] Fix the detect chain after the main merge. The shared closing brace left the Cineon test doubled when the two magic checks were stitched together. --- crates/codecs/src/format.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 771d7a64e5..6d89bf2414 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -335,7 +335,6 @@ pub fn detect(bytes: &[u8]) -> Option { if crate::codecs::iff::looks_like_iff(b) { return Some(Format::Iff); } - } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } From 1e9c0b43f6e611d34bb46671a769a1a99a703260 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:21:14 +1100 Subject: [PATCH 0211/1452] Format the merged open-extension lists. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 90e080d2a1..e98d6cfbb7 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", + "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d44748cdbd..1453d550b5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", - "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", + "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From 0c439b66e25a2b45695c3f58591426247705717a Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:25:36 +1100 Subject: [PATCH 0212/1452] Format and compile the merged DICOM reader. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 83b8c20060..d9c5c7641a 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", + "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9cffaf2843..24e198fd98 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", - "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", + "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From 1d2ebfdc1f7dee57ec8d9a9a59602ebe8e6cf3de Mon Sep 17 00:00:00 2001 From: highperfocused Date: Thu, 15 Oct 2026 15:35:49 +0200 Subject: [PATCH 0213/1452] File Info: Photoshop field order and labels (#6125, #6126) --- crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ crates/ui-egui/src/view_cmds.rs | 37 +++++++++++++++++++++++++-- crates/ui-egui/src/view_cmds/tests.rs | 25 ++++++++++++++++++ 22 files changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..7f6a3f7320 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2792,6 +2792,9 @@ cameraData Lens Objektiv Cooling80 Ochlazovací 80 Cooling82 Ochlazovací 82 Copyright Status Stav autorských práv + Copyright Info URL URL informace o autorských právech + Copyright Notice Oznámení autorských práv + Document Title Název dokumentu Copyrighted Chráněno autorským právem Correct C A Korigovat chromatickou aberaci Correct Distortion Korigovat zkreslení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..8dda6bd821 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2812,6 +2812,9 @@ curves Smooth Glätten # Document Info and color profiles Author Title Position des Urhebers Copyright Status Urheberrechtsstatus + Copyright Info URL URL für Urheberrechtsinfo + Copyright Notice Urheberrechtsvermerk + Document Title Dokumenttitel Unknown Unbekannt Copyrighted Urheberrechtlich geschützt Public Domain Gemeinfrei diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..7c65c7eda9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -995,6 +995,8 @@ Keywords Λέξεις-κλειδιά Copyright Info URL Σύνδεσμος πληροφοριών πνευματικών δικαιωμάτων Copyright Status Κατάσταση πνευματικών δικαιωμάτων + Copyright Notice Σημείωση πνευματικών δικαιωμάτων + Document Title Τίτλος εγγράφου Copyrighted Προστατεύεται από πνευματικά δικαιώματα Public Domain Κοινό κτήμα Unknown Άγνωστο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..9d09393af1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2990,6 +2990,8 @@ curves Smooth Suavizar Keywords Palabras clave Copyright Info URL URL de información de copyright Copyright Status Estado del copyright + Copyright Notice Aviso de copyright + Document Title Título del documento Copyrighted Con copyright Public Domain Dominio público Unknown Desconocido diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..2414e1818e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2431,6 +2431,8 @@ Keywords Mots-clés Copyright Info URL URL des informations de copyright Copyright Status Statut du copyright + Copyright Notice Mention de copyright + Document Title Titre du document Copyrighted Protégé par copyright Public Domain Domaine public Unknown Inconnu diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..2a1741142c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3140,6 +3140,8 @@ cameraRaw Yellow Sárga Convert to Profile Átalakítás profilra Convert to srgb Átalakítás sRGB-re Copyright Status Szerzői jogi állapot + Copyright Notice Szerzői jogi értesítés + Document Title Dokumentum címe Corner radius Sarokkerekítés sugara Correct C A Kromatikus aberráció javítása Correct Distortion Torzítás javítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..6cfa5a068a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2422,6 +2422,8 @@ Keywords Kata Kunci Copyright Info URL URL Info Hak Cipta Copyright Status Status Hak Cipta + Copyright Notice Pemberitahuan Hak Cipta + Document Title Judul Dokumen Copyrighted Berhak Cipta Public Domain Domain Publik Unknown Tidak Diketahui diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..7e26485688 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2982,6 +2982,8 @@ curves Smooth Leviga Keywords Parole chiave Copyright Info URL URL info copyright Copyright Status Stato copyright + Copyright Notice Avviso sul copyright + Document Title Titolo documento Copyrighted Protetto da copyright Public Domain Pubblico dominio Unknown Sconosciuto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..7ef6f1ef1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3043,6 +3043,8 @@ fontWeight Italic 斜体 Cooling80 寒色フィルター (80) Cooling82 寒色フィルター (82) Copyright Status 著作権の状態 + Copyright Notice 著作権表示 + Document Title ドキュメントタイトル Copyrighted 著作権あり Corners 角 Correct C A 色収差を補正 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..c679116cad 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -969,6 +969,9 @@ Copper 구리 Copyright 저작권 Copyright and Contact Info 저작권 및 연락처 정보 + Copyright Info URL 저작권 정보 URL + Copyright Notice 저작권 고지 + Document Title 문서 제목 Corner Crop Marks 모서리 자르기 표시 Corner radius 모서리 반경 Correction 보정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..0a1eed875d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2831,6 +2831,8 @@ cameraRaw Color Kleur Keywords Trefwoorden Copyright Info URL URL copyrightinformatie Copyright Status Copyrightstatus + Copyright Notice Copyrightvermelding + Document Title Documenttitel Copyrighted Auteursrechtelijk beschermd Public Domain Publiek domein Unknown Onbekend diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..044b97adad 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2454,6 +2454,8 @@ Keywords Słowa kluczowe Copyright Info URL Adres URL informacji o prawach autorskich Copyright Status Status praw autorskich + Copyright Notice Informacja o prawach autorskich + Document Title Tytuł dokumentu Copyrighted Chronione prawem autorskim Public Domain Domena publiczna Unknown Nieznany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..298872a93e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2426,6 +2426,8 @@ Keywords Palavras-chave Copyright Info URL URL das informações de direitos autorais Copyright Status Status dos direitos autorais + Copyright Notice Aviso de copyright + Document Title Título do documento Copyrighted Protegido por direitos autorais Public Domain Domínio público Unknown Desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..16a41b2ed4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1339,6 +1339,8 @@ Keywords Ключевые слова Copyright Info URL URL сведений об авторских правах Copyright Status Статус авторских прав + Copyright Notice Уведомление об авторских правах + Document Title Название документа Copyrighted Охраняется авторским правом Public Domain Общественное достояние Unknown Неизвестно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..8a0b707fc5 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2408,6 +2408,8 @@ Keywords Ključne reči Copyright Info URL URL informacija o autorskim pravima Copyright Status Status autorskih prava + Copyright Notice Obaveštenje o autorskim pravima + Document Title Naslov dokumenta Copyrighted Zaštićeno autorskim pravima Public Domain Javno dobro Unknown Nepoznato diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..b0581e1d5b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2801,6 +2801,8 @@ cameraRaw Color Renk Keywords Anahtar Kelimeler Copyright Info URL Telif Hakkı Bilgisi URL'si Copyright Status Telif Hakkı Durumu + Copyright Notice Telif hakkı bildirimi + Document Title Belge başlığı Copyrighted Telif hakkıyla korunuyor Public Domain Kamu Alanı Unknown Bilinmiyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..d391e5e81b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1314,6 +1314,8 @@ Keywords Ключові слова Copyright Info URL URL інформації про авторське право Copyright Status Статус авторського права + Copyright Notice Застереження про авторські права + Document Title Назва документа Copyrighted Захищено авторським правом Public Domain Суспільне надбання Unknown Невідомо diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..b253245199 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2981,6 +2981,9 @@ cameraData Lens Ống kính Cooling80 Lạnh80 Cooling82 Lạnh82 Copyright Status Trạng thái bản quyền + Copyright Info URL URL thông tin bản quyền + Copyright Notice Thông báo bản quyền + Document Title Tiêu đề tài liệu Copyrighted Được bảo vệ bản quyền Correct C A Hiệu chỉnh C A Correct Distortion Hiệu chỉnh độ méo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..d255190bcf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -997,6 +997,9 @@ Copper 铜色 Copyright 版权 Copyright and Contact Info 版权与联系信息 + Copyright Info URL 版权信息 URL + Copyright Notice 版权声明 + Document Title 文档标题 Corner Crop Marks 角落裁剪标记 Corner radius 圆角半径 Correction 校正 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..2d874322c4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1001,6 +1001,9 @@ Copper 銅色 Copyright 版權 Copyright and Contact Info 版權與聯絡資訊 + Copyright Info URL 版權資訊 URL + Copyright Notice 版權聲明 + Document Title 文件標題 Corner Crop Marks 角落裁切標記 Corner radius 圓角半徑 Correction 校正 diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..6d1a555ab3 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1059,6 +1059,23 @@ mod form_reset_tests { } } +/// Display order of a `__form` dialog's field keys. `__order` lists them the way Photoshop +/// shows them (e.g. File Info); keys it omits keep their catalogue order after it. +pub fn form_keys(f: &Map) -> Vec { + let mut keys: Vec = f + .get("__order") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|k| !k.starts_with("__") && f.contains_key(*k)) + .map(str::to_string) + .collect(); + let rest: Vec = f.keys().filter(|k| !k.starts_with("__") && !keys.contains(k)).cloned().collect(); + keys.extend(rest); + keys +} + /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); @@ -1066,7 +1083,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { // come from `__labels` verbatim; everything else goes through the catalogue. let labels = f.get("__labels").cloned().unwrap_or(Value::Null); let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); - let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + let keys = form_keys(f); egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); @@ -1245,7 +1262,23 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); dialog( app, - json!({"title": info["title"], "author": info["author"], "authorTitle": info["authorTitle"], "description": info["description"], "keywords": kw, "copyright": info["copyright"], "copyrightStatus": info["copyrightStatus"], "copyrightUrl": info["copyrightUrl"], "__camera": info["camera"]}), + json!({ + "title": info["title"], + "author": info["author"], + "authorTitle": info["authorTitle"], + "description": info["description"], + "keywords": kw, + "copyrightStatus": info["copyrightStatus"], + "copyright": info["copyright"], + "copyrightUrl": info["copyrightUrl"], + "__order": ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"], + "__labels": { + "title": tl!("Document Title"), + "copyright": tl!("Copyright Notice"), + "copyrightUrl": tl!("Copyright Info URL"), + }, + "__camera": info["camera"] + }), json!({"copyrightStatus": ["unknown", "copyrighted", "publicDomain"]}), ) } diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..7036c018aa 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,28 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +#[test] +fn file_info_dialog_matches_photoshop_field_order_and_labels() { + crate::i18n::with_language(crate::i18n::Lang::EN, || { + let (mut app, ctx) = app_with(1); + let d = menu(&mut app, &ctx, "file.fileInfo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = app.ui.dialog_mut(d).unwrap().fields.clone(); + assert_eq!( + form_keys(&fields).iter().map(String::as_str).collect::>(), + ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"] + ); + let labels = fields["__labels"].as_object().unwrap(); + assert_eq!(labels["title"], "Document Title"); + assert_eq!(labels["copyright"], "Copyright Notice"); + assert_eq!(labels["copyrightUrl"], "Copyright Info URL"); + }); +} + +#[test] +fn form_keys_orders_by_order_then_catalogue() { + let f = json!({"zeta": "", "alpha": "", "mid": "", "__order": ["mid", "zeta", "ghost"]}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["mid", "zeta", "alpha"]); + let f = json!({"zeta": "", "alpha": ""}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["alpha", "zeta"]); +} From 6845cb95f26d04f7bb7d0b5aa89766b4e01862ec Mon Sep 17 00:00:00 2001 From: Giang Nguyen <33104857+giang17@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:49:47 +0200 Subject: [PATCH 0214/1452] Title bar: a window move the window manager finished no longer blocks the next move or resize (#5725) --- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/titlebar.rs | 61 ++++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..e374217e78 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -596,7 +596,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap)); let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + crate::titlebar::start_os_move(ui.ctx()); } if drag.double_clicked() { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 25b23ee0f6..88d8714e0d 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -68,7 +68,7 @@ pub(crate) fn modal_drag_overlay(ctx: &egui::Context) { egui::Area::new(Id::new("titlebar-modal-drag-overlay")).order(Order::Tooltip).fixed_pos(area.min).movable(false).sense(Sense::hover()).show(ctx, |ui| { let response = ui.allocate_rect(area, Sense::click_and_drag()); if response.drag_started() { - ctx.send_viewport_cmd(ViewportCommand::StartDrag); + start_os_move(ctx); } if response.double_clicked() { let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); @@ -266,7 +266,7 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) { let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() }); if resp.drag_started_by(PointerButton::Primary) { - ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + start_os_move(ui.ctx()); } let pressed = resp.is_pointer_button_down_on() && resp.hovered(); if windows { @@ -468,7 +468,7 @@ fn resize_handed_off_id() -> Id { Id::new("titlebar-resize-handed-off") } -/// An OS resize started at `pass`; `released` is the pass whose input ended the press +/// An OS resize or move started at `pass`; `released` is the pass whose input ended the press /// ([`release_after_os_resize`]). #[derive(Clone, Copy, Debug)] struct HandOff { @@ -476,11 +476,26 @@ struct HandOff { released: Option, } -/// The compositor takes the button release of an OS resize (KDE Wayland), so egui kept the edge -/// pressed: the zones stopped hovering until the next click, every other resize (#1581). End the -/// press ourselves on the input after the hand-off, but only while egui still has it down and this -/// input brings no release of its own: where the OS does send it (Windows, X11), nothing is added, -/// so no second release can turn into a click. Call from `raw_input_hook`. +/// The OS took the press at this pass: [`release_after_os_resize`] ends it on the next input. +fn hand_off(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None })); +} + +/// Move the window from this press. The window manager takes the press of an OS move as it does +/// that of a resize and keeps its release, so egui kept the drag: every other move failed, and a +/// resize right after one (#5725). The same hand-off ends it. +pub(crate) fn start_os_move(ctx: &egui::Context) { + ctx.send_viewport_cmd(ViewportCommand::StartDrag); + hand_off(ctx); +} + +/// The compositor takes the button release of an OS resize or move (KDE Wayland, KWin on X11), so +/// egui kept the edge pressed: the zones stopped hovering until the next click, every other resize +/// (#1581, and #5725 for moves). End the press ourselves on the input after the hand-off, but only +/// while egui still has it down and this input brings no release of its own: where the OS does send +/// it (Windows), nothing is added, so no second release can turn into a click. Call from +/// `raw_input_hook`. pub fn release_after_os_resize(ctx: &egui::Context, raw: &mut egui::RawInput) { let Some(h) = ctx.data(|d| d.get_temp::(resize_handed_off_id())) else { return }; if h.released.is_some() || h.pass >= ctx.cumulative_pass_nr() { @@ -524,8 +539,7 @@ pub fn resize_zones(ui: &mut Ui) { let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); if resp.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); - let pass = ctx.cumulative_pass_nr(); - ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None })); + hand_off(&ctx); } } // After the zones, so its one-frame cursor wins over theirs. @@ -795,7 +809,7 @@ mod tests { assert!(w.gesture(&[p, p + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West))); } - /// Where the OS does send the release of a resize (Windows, X11), no second one is added: a + /// Where the OS does send the release of a resize (Windows), no second one is added: a /// release right after it could count as a click on what lies under the edge. #[test] fn a_release_the_os_sends_is_not_doubled() { @@ -818,6 +832,31 @@ mod tests { assert!(raw.events.is_empty(), "{:?}", raw.events); } + /// #5725: the window manager keeps the button release of an OS move just as of a resize, so + /// egui kept the drag: every other move of the window failed, and so did a resize right after one. + #[test] + fn after_an_os_move_the_bar_and_the_edges_take_the_next_press() { + /// Press and drag along `path`; the window manager takes over, so no release ever arrives. + fn handed_off(w: &mut Win, path: &[Pos2]) -> Vec { + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]]; + events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); + events.push(vec![]); + events.into_iter().flat_map(|e| w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect() + } + let moves = |cmds: &[ViewportCommand]| cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(); + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(980.0, 16.0); + assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space"); + let drag = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]; + assert_eq!(moves(&handed_off(&mut w, &drag)), 1); + assert!(!w.ctx.input(|i| i.pointer.primary_down()), "the press ends with the hand-off"); + assert_eq!(moves(&w.gesture(&drag)), 1, "the next move"); + assert_eq!(moves(&handed_off(&mut w, &drag)), 1); + let edge = pos2(3.0, 300.0); + assert!(w.gesture(&[edge, edge + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "a resize after a move"); + } + #[test] fn edge_zones_resize_unless_maximized() { use ResizeDirection::*; From ab9e32c01d16c120b3e8a7e6eef48b69c35bc989 Mon Sep 17 00:00:00 2001 From: Giang Nguyen <33104857+giang17@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:49:47 +0200 Subject: [PATCH 0215/1452] contributors: credit giang17 as Giang Nguyen --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..944a872f3a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,6 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.giang17] +real_name = "Giang Nguyen" From 6b3a6ffd8b224479ee634d5c150e00d3cc8e0387 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 21:52:28 +0800 Subject: [PATCH 0216/1452] Document conservative blend preview coverage Explain why clip_damage replaces the covered intersection, including an empty one, while retaining the affected region for uncovered to recompute. This is a comment-only follow-up to review on storytold/photocraft#5413. Existing controlled 24 MP release measurement: median refresh/upload- submission time was 734.677 ms for the full 6000x4000 region and 59.641 ms for a 1280x720 viewport, five measured samples per region after warmup. This compares regions in the same candidate binary on CPU fallback with Vulkan llvmpipe, not two revisions or physical-GPU completion latency. Evidence: https://github.com/jonathanwxh-cell/photocraft/actions/runs/38512473874 --- crates/ui-egui/src/blend_preview.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index b73ddd553b..b5a203727d 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -91,6 +91,9 @@ pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, ke return region; } let shown = region.intersect(&visible); + // `covered` records only this refresh, including an empty intersection; it is not cumulative. + // Keep `region` so `uncovered` can recompute the next visible area. Forgetting older coverage + // can cause a redundant refresh, but cannot skip newly exposed pixels for this key. p.covered = Some((key, region, shown)); shown } From 70ffad5a566e8b747084b036fed25d517b167faf Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:01:09 +0800 Subject: [PATCH 0217/1452] test(text): retain imported font metadata after text edits --- crates/text/src/tests.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..ff4dade6ea 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2008,3 +2008,27 @@ fn hyphenless_postscript_names_split_trailing_styles() { assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); } } + +#[test] +fn text_rebuild_preserves_imported_font_metadata() { + use crate::engine_data::Value as E; + let layer = point("Original", 24.0); + let mut template = crate::psd::build_engine_data(&layer, None, 72.0); + for key in ["ResourceDict", "DocumentResources"] { + let fonts = template.get_mut(key).unwrap().get_mut("FontSet").unwrap(); + let E::Array(fonts) = fonts else { panic!("font array") }; + for font in fonts.iter_mut() { + font.set("Script", E::Int(4)); + font.set("FontType", E::Int(2)); + font.set("Synthetic", E::Int(3)); + font.set("PrivateFontFlag", E::Int(17)); + } + } + let expected = template.path(&["ResourceDict", "FontSet"]).unwrap().clone(); + let mut edited = layer; + edited.text = "Edited".into(); + let rebuilt = crate::psd::build_engine_data(&edited, Some(template), 72.0); + for key in ["ResourceDict", "DocumentResources"] { + assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit"); + } +} From 2656f8db3d3e0bd4f049aa7f85a49b025390e6a2 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:04:27 +0800 Subject: [PATCH 0218/1452] fix(text): preserve imported font records during text and style edits --- crates/text/src/psd.rs | 20 ++++++++++++-- crates/text/src/psd_styles.rs | 3 ++- crates/text/src/tests.rs | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..3950c699f2 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -901,6 +901,22 @@ pub(crate) fn font_entry(name: &str) -> E { E::Dict(vec![("Name".into(), E::String(name.into())), ("Script".into(), E::Int(0)), ("FontType".into(), E::Int(1)), ("Synthetic".into(), E::Int(0))]) } +/// Keep imported font metadata while retaining the requested font indices. New fonts use +/// the normal defaults; same-name duplicates retain their own positional records. +pub(crate) fn font_set(names: &[String], original: Option<&E>) -> E { + let entries = original.and_then(E::as_array).unwrap_or_default(); + E::Array( + names + .iter() + .enumerate() + .map(|(i, name)| { + let matches = |entry: &&E| entry.get("Name").and_then(E::as_str) == Some(name.as_str()); + entries.get(i).filter(matches).or_else(|| entries.iter().find(matches)).cloned().unwrap_or_else(|| font_entry(name)) + }) + .collect(), + ) +} + /// The `Txt2` auto-kern mode of a style: 0 manual, 1 metrics, 2 optical — the values /// [`apply_txt2`] maps back to [`Kerning`]. EngineData's `AutoKerning` can't say Metrics from /// Optical, so the mode only lives in `Txt2`. @@ -1150,7 +1166,6 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E )])); plens.push(E::Int(n)); } - let fontset = E::Array(fonts.iter().map(|f| font_entry(f)).collect()); let Some(dict) = e.get_mut("EngineDict") else { return fresh(); @@ -1231,7 +1246,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E continue; } let Some(rd) = e.get_mut(key) else { continue }; - rd.set("FontSet", fontset.clone()); + let fontset = font_set(&fonts, rd.get("FontSet")); + rd.set("FontSet", fontset); if rd.get("StyleSheetSet").is_none() { let base = runs.first().map(|r| r.style.clone()).unwrap_or_default(); rd.set( diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index 7c4f69cd8a..6f953f1cec 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -187,7 +187,8 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d rd.set("ParagraphSheetSet", E::Array(pset)); rd.set("TheNormalStyleSheet", E::Int(0)); rd.set("TheNormalParagraphSheet", E::Int(0)); - rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect())); + let fontset = crate::psd::font_set(&fonts, rd.get("FontSet")); + rd.set("FontSet", fontset); } let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default(); let char_ix = |id: Option| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ff4dade6ea..d73c32dda6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2032,3 +2032,54 @@ fn text_rebuild_preserves_imported_font_metadata() { assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit"); } } + +#[test] +fn font_metadata_survives_tysh_and_named_style_rewrites() { + use crate::engine_data::Value as E; + let source = point("Original", 24.0); + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); + let mut data = crate::psd::engine_data(&tysh.text).unwrap(); + for (key, marker) in [("ResourceDict", 17), ("DocumentResources", 29)] { + let E::Array(fonts) = data.get_mut(key).unwrap().get_mut("FontSet").unwrap() else { panic!("font array") }; + for font in fonts { + font.set("PrivateFontFlag", E::Int(marker)); + font.set("Script", E::Int(4)); + } + } + for (key, value) in &mut tysh.text.items { + if key.as_bytes() == b"EngineData" { + *value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); + } + } + let raw = crate::psd::write_tysh(&tysh); + let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + imported.text = "Edited".into(); + let rebuilt = crate::psd::build_tysh(&imported, 72.0, None); + let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap(); + for bytes in [&rebuilt, &styled] { + let parsed = crate::psd::parse_tysh(bytes).unwrap(); + let actual = crate::psd::engine_data(&parsed.text).unwrap(); + for key in ["ResourceDict", "DocumentResources"] { + assert_eq!(actual.path(&[key, "FontSet"]), data.path(&[key, "FontSet"])); + } + assert_eq!(crate::psd::text_layer_from_tysh(bytes, 72.0).unwrap().text, "Edited"); + } +} + +#[test] +fn font_metadata_append_reorder_and_duplicate_indices() { + use crate::engine_data::Value as E; + let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]); + let entries = vec![record("A", 1), record("A", 2), record("B", 3)]; + let original = E::Array(entries.clone()); + let names = ["A", "A", "B", "New"].map(str::to_string); + let result = crate::psd::font_set(&names, Some(&original)); + let fonts = result.as_array().unwrap(); + assert_eq!(&fonts[..3], entries.as_slice()); + assert_eq!(fonts[3], crate::psd::font_entry("New")); + let reordered = crate::psd::font_set(&["B".into(), "A".into()], Some(&original)); + assert_eq!(reordered, E::Array(vec![entries[2].clone(), entries[1].clone()])); + for malformed in [None, Some(&E::Int(0))] { + assert_eq!(crate::psd::font_set(&["New".into()], malformed), E::Array(vec![crate::psd::font_entry("New")])); + } +} From 50a011ae9ec738a6a62e888f6d66b35fb94bf4d2 Mon Sep 17 00:00:00 2001 From: Matthias Schlich Date: Thu, 15 Oct 2026 16:05:34 +0200 Subject: [PATCH 0219/1452] feat(filters): Clouds and Difference Clouds as smart filters; render filters work within a smart object Clouds and Difference Clouds ran outside the filter pipeline (map_pixels with the session's colours), so on a smart object they failed instead of adding a smart filter. They are now a regular render filter (FilterParams::Clouds, same noise as before) that records its colours, so a smart filter re-renders the same clouds after the colours change. Render filters on a smart object (Clouds, Difference Clouds, Fibers, Lens Flare) now use the placed content's bounds, as Photoshop does: a flare's centre is a fraction of the object, not of the canvas. --- crates/algo/src/lib.rs | 15 ++++++- crates/algo/src/render.rs | 51 ++++++++++++++++++++++- crates/engine/src/extra_cmds.rs | 53 ------------------------ crates/engine/src/filters_ext.rs | 11 ++++- crates/engine/src/smart_cmds.rs | 20 ++++++++- crates/engine/src/smart_cmds/tests.rs | 58 +++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 59 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 49ab15871c..ae1bf0006b 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -410,6 +410,14 @@ pub enum FilterParams { }, // ---- Render ---- + /// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight + /// sRGB RGBA colours; the cloud size follows the bounds. + Clouds { + seed: u32, + difference: bool, + foreground: [f32; 4], + background: [f32; 4], + }, /// Variance 1–64, strength 1–64; colours straight sRGB RGBA. Fibers { variance: f32, @@ -604,7 +612,7 @@ impl FilterParams { /// the reference bounds even where the layer is transparent, as Photoshop's does on an /// empty layer, while still reading no neighbourhood. pub fn renders_from_nothing(&self) -> bool { - matches!(self, FilterParams::Fibers { .. }) + matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. }) } /// Human-readable name. @@ -651,6 +659,8 @@ impl FilterParams { FilterParams::Displace { .. } => "Displace", FilterParams::Shear { .. } => "Shear", FilterParams::ZigZag { .. } => "ZigZag", + FilterParams::Clouds { difference: false, .. } => "Clouds", + FilterParams::Clouds { difference: true, .. } => "Difference Clouds", FilterParams::Fibers { .. } => "Fibers", FilterParams::LensFlare { .. } => "Lens Flare", FilterParams::LightingEffects { .. } => "Lighting Effects", @@ -695,7 +705,7 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)), FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds, // Generators and per-pixel renders only need the bounds geometry (always in the context). - FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0), + FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0), FilterParams::LightingEffects { .. } => r(1.0), FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0), FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()), @@ -815,6 +825,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec distort2::shear(src, out, ctx, points, *undefined), FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style), + FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)), FilterParams::Fibers { variance, strength, seed, foreground, background } => { render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)) } diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index baba88f93e..d6e5d0e8a6 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -1,4 +1,4 @@ -//! Render: Fibers, Lens Flare, Lighting Effects. +//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects. use photocraft_geom::Rect; @@ -18,6 +18,55 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 { top + (bot - top) * sy } +fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); + h ^= h >> 13; + h = h.wrapping_mul(0x5bd1_e995); + h ^= h >> 15; + (h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32 +} + +/// Smooth value noise, summed over octaves (fBm), in 0..1. +fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 { + let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0); + for o in 0..8u32 { + let (fx, fy) = (x * freq, y * freq); + let (ix, iy) = (fx.floor() as i32, fy.floor() as i32); + let (tx, ty) = (fx - ix as f32, fy - iy as f32); + let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty)); + let s = seed.wrapping_add(o * 7919); + let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx; + let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx; + sum += (top + (bot - top) * sy) * amp; + norm += amp; + amp *= 0.5; + freq *= 2.0; + } + // Stretch the bell-shaped sum so the clouds use the full foreground→background range. + ((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0) +} + +/// Clouds between the foreground and background colours; Difference Clouds subtracts them from +/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side. +pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec { + let n = src.ch; + let (fg, bg) = colours; + let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0); + let mut res = src.crop(out); + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = xy(out, i); + let t = clouds_value(x as f32, y as f32, base, seed); + let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t); + if difference { + let p = rgba(ctx, px); + set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]); + } else { + set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]); + } + } + res +} + /// Fibers: vertical fibrous streaks between the foreground and background /// colours. Variance sets how quickly the colour varies across fibres (and /// how short they are); strength stretches each fibre vertically. diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 3a27c99be3..85a65587f1 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -278,57 +278,6 @@ fn average(s: &mut Session, p: &Value) -> Result { }) } -fn hash(x: i32, y: i32, seed: u32) -> f32 { - let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); - h ^= h >> 13; - h = h.wrapping_mul(0x5bd1_e995); - h ^= h >> 15; - (h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32 -} - -/// Smooth value noise, summed over octaves (fBm), in 0..1. -pub(crate) fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 { - let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0); - for o in 0..8u32 { - let (fx, fy) = (x * freq, y * freq); - let (ix, iy) = (fx.floor() as i32, fy.floor() as i32); - let (tx, ty) = (fx - ix as f32, fy - iy as f32); - let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty)); - let s = seed.wrapping_add(o * 7919); - let top = hash(ix, iy, s) + (hash(ix + 1, iy, s) - hash(ix, iy, s)) * sx; - let bot = hash(ix, iy + 1, s) + (hash(ix + 1, iy + 1, s) - hash(ix, iy + 1, s)) * sx; - sum += (top + (bot - top) * sy) * amp; - norm += amp; - amp *= 0.5; - freq *= 2.0; - } - // Stretch the bell-shaped sum so the clouds use the full foreground→background range. - ((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0) -} - -/// Filter › Render › Clouds / Difference Clouds, between the foreground and background colours. -fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { - let (fg, bg) = (s.tools.foreground, s.tools.background); - let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0) as u32; - let size = s.active().map_or(256.0, |d| d.doc.size.width.max(d.doc.size.height) as f32); - let base = (size / 4.0).clamp(16.0, 512.0); - map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, p, |area, px, _| { - let w = area.width() as usize; - for (i, p) in px.iter_mut().enumerate() { - let (x, y) = ((area.x0 + (i % w) as i32) as f32, (area.y0 + (i / w) as i32) as f32); - let t = clouds_value(x, y, base, seed); - let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t); - if difference { - for k in 0..3 { - p[k] = (p[k] - c[k]).abs(); - } - } else { - *p = [c[0], c[1], c[2], 1.0]; - } - } - }) -} - // ---------- transform presets ---------- /// The presets' params: like Free Transform, they follow the Channels and Layers panel target. @@ -1017,8 +966,6 @@ pub fn specs() -> Vec { show_only ), spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average), - spec!("filter.render.clouds", "Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, false)), - spec!("filter.render.differenceClouds", "Difference Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, true)), ] } diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index dae5e9b98b..58522f8d66 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -173,6 +173,12 @@ pub fn params_for(id: &str, p: &Value) -> Option { }, }, // ---- Render ---- + "filter.render.clouds" | "filter.render.differenceClouds" => FilterParams::Clouds { + seed: seed(p), + difference: id == "filter.render.differenceClouds", + foreground: colour(p, "foreground", BLACK), + background: colour(p, "background", WHITE), + }, "filter.render.fibers" => FilterParams::Fibers { variance: f(p, "variance", 16.0).clamp(1.0, 64.0), strength: f(p, "strength", 4.0).clamp(1.0, 64.0), @@ -400,7 +406,8 @@ pub fn params_for(id: &str, p: &Value) -> Option { /// Ids whose rendering uses the foreground/background colours. fn uses_colours(id: &str) -> bool { - matches!(id, "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") || crate::gallery_cmds::uses_colours(id) + matches!(id, "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") + || crate::gallery_cmds::uses_colours(id) } /// Fills in session state a filter reads (the current colours) as explicit params, so the @@ -614,6 +621,8 @@ pub fn specs() -> Vec { r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"## ), // Render + cmd!("filter.render.clouds", "Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##), + cmd!("filter.render.differenceClouds", "Difference Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##), cmd!( "filter.render.fibers", "Fibers…", diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e2c1cf2bfa..4f9c8c1487 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -307,7 +307,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, canvas, extent); + cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent); } match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -315,6 +315,21 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> } } +/// The reference bounds of smart filter `f`. Render filters work within the smart object, as +/// Photoshop's do: Lens Flare's centre is a fraction of the object, and Clouds and Fibers fill it +/// and size their pattern by it. The object is its placed content's bounds (which leaves out +/// fully transparent edges of the source); the other filters use the canvas, as on a layer. +fn filter_bounds(f: &SmartFilter, placed: &Surface, canvas: Rect) -> Rect { + match placed.content_bounds() { + b if renders_in_object(f) && !b.is_empty() => b, + _ => canvas, + } +} + +fn renders_in_object(f: &SmartFilter) -> bool { + matches!(f.command.as_str(), "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.render.lensFlare") +} + /// One visible smart filter over `cur`, blended with its blend mode and opacity. fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface { // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. @@ -410,7 +425,8 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result = sm.smart_filters.iter().map(|f| f.command.as_str()).collect(); + assert_eq!(commands, ["filter.render.clouds", "filter.render.differenceClouds"]); + assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.smart_filters[1].params["background"], json!([0.0, 0.0, 1.0, 1.0])); + // Red-to-blue clouds, and their difference, have no green: they fill the object (painted + // from (-6, 5) to (40, 38)) over the white background. + let rendered = flat(&s); + let object: Vec<[f32; 4]> = (5..38).flat_map(|y| (0..40).map(move |x| (y * W + x) as usize)).map(|i| rendered[i]).collect(); + assert!(object.iter().all(|p| p[1] < 0.02), "depth {depth}"); + assert!(max_diff(&object, &vec![object[0]; object.len()]) > 0.05, "the clouds vary"); + s.tools.foreground = [0.0, 1.0, 0.0, 1.0]; + s.execute("layer.smartFilter.setParams", json!({"index": 1, "params": {"seed": 9}})).unwrap(); + assert!(max_diff(&flat(&s), &rendered) < 1e-6, "depth {depth}: same params, same clouds"); + s.undo(); + s.undo(); + assert_eq!(active_smart(&s).smart_filters.len(), 1); + } +} + +/// Render filters on a smart object work within the object, as in Photoshop (and as PSD stores +/// them): Lens Flare's centre is a fraction of the object's bounds, not of the canvas. +#[test] +fn lens_flare_centres_on_the_smart_object() { + let mut s = session(8); + let block = Rect::new(32, 24, W, H); + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let px = photocraft_raster::from_rgba(&fmt, [0.1, 0.1, 0.1, 1.0]); + surf.write_region(block, &px.repeat((block.width() * block.height()) as usize)); + Ok(()) + }) + .unwrap(); + convert(&mut s); + s.execute("filter.render.lensFlare", json!({"centerX": 0.5, "centerY": 0.5, "lens": "prime105"})).unwrap(); + // The centre of the brightest pixels inside the object (the canvas around it is white). + let px = flat(&s); + let inside: Vec<(i32, i32, f32)> = + (block.y0..block.y1).flat_map(|y| (block.x0..block.x1).map(move |x| (x, y))).map(|(x, y)| (x, y, px[(y * W + x) as usize][1])).collect(); + let peak = inside.iter().map(|p| p.2).fold(0.0, f32::max); + let core: Vec<_> = inside.iter().filter(|p| p.2 >= peak - 0.02).collect(); + let (x, y) = (core.iter().map(|p| p.0).sum::() / core.len() as i32, core.iter().map(|p| p.1).sum::() / core.len() as i32); + assert!((x - 48).abs() <= 1 && (y - 36).abs() <= 1, "flare at {x},{y}, the object's centre is 48,36"); +} From b228c1c0c2f4950c52a36c0018966e0ccddd0730 Mon Sep 17 00:00:00 2001 From: Matthias Schlich Date: Thu, 15 Oct 2026 16:05:34 +0200 Subject: [PATCH 0220/1452] feat(psd): map Clouds, Difference Clouds, Fibers and Lens Flare smart filters PSD smart filters Clds, DfrC, Fbrs and LnsF open as editable PhotoCraft filters instead of kept-verbatim entries, and are written back like Photoshop: seeds, the item's foreground and background colours, brightness, centre and lens (Zm/Nkn/Nkn1/PnVs = 50-300mm Zoom, 35mm, 105mm and Movie Prime), with Photoshop's descriptor names. Checked against items saved by Photoshop 27. --- crates/io/src/smart_map.rs | 148 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..79e3f99030 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 18] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -160,8 +160,17 @@ const KNOWN: [Known; 14] = [ Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). Known { class: "adaptCorrect", filter_id: 781, name: "Shado&ws/Highlights...", command: "image.adjustments.shadowsHighlights" }, + Known { class: "LnsF", filter_id: code(b"LnsF"), name: "Lens Flare...", command: "filter.render.lensFlare" }, + // These three also read the item's foreground and background colours (`coloured_params`). + Known { class: "Fbrs", filter_id: code(b"Fbrs"), name: "Fibers...", command: "filter.render.fibers" }, + Known { class: "Clds", filter_id: code(b"Clds"), name: "Clouds", command: "filter.render.clouds" }, + Known { class: "DfrC", filter_id: code(b"DfrC"), name: "Difference Clouds", command: "filter.render.differenceClouds" }, ]; +/// Lens Flare's `Lns ` enum and our `lens` values. Photoshop's codes keep the old lens names: +/// Nikon 35 mm and 105 mm primes, a Panavision movie prime. +const LENSES: [(&[u8; 4], &str); 4] = [(b"Zm ", "zoom"), (b"Nkn ", "prime35"), (b"Nkn1", "prime105"), (b"PnVs", "moviePrime")]; + fn known_by_command(command: &str) -> Option<&'static Known> { KNOWN.iter().find(|k| k.command == command) } @@ -282,6 +291,12 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + ("LnsF", Some(f)) => { + let centre = get_desc(f, "FlrC")?; + let lens = enum_of(f, "Lns ")?; + let lens = LENSES.iter().find(|(c, _)| lens == &c[..])?.1; + json!({"brightness": int(f, "Brgh")?, "centerX": px(centre, "Hrzn")?, "centerY": px(centre, "Vrtc")?, "lens": lens}) + } ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -322,6 +337,26 @@ fn rgbc_colour(d: &Descriptor) -> Option { Some(json!([c("Rd ")?, c("Grn ")?, c("Bl ")?, 1.0])) } +/// Fibers, Clouds and Difference Clouds draw with the item's foreground and background colours: +/// their params are the `Fltr` settings plus those colours. +fn coloured_params(class: &str, item: &Descriptor, f: &Descriptor) -> Option<(&'static str, J)> { + let k = KNOWN.iter().find(|k| k.class == class)?; + let colour = |key: &str| get_desc(item, key).and_then(rgbc_colour); + // The seeds (`RndS`, `FlRs`) are signed longs; ours is their bit pattern. + let mut p = match class { + "Fbrs" => json!({"variance": int(f, "Vrnc")?, "strength": int(f, "Strg")?, "seed": int(f, "RndS")? as u32}), + _ => json!({"seed": int(f, "FlRs")? as u32}), + }; + let o = p.as_object_mut()?; + o.insert("foreground".into(), colour("FrgC")?); + o.insert("background".into(), colour("BckC")?); + Some((k.command, p)) +} + +fn seed_param(p: &J) -> Value { + Value::Integer(p.get("seed").and_then(J::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0) as i32) +} + /// `filter.filterGallery` params for a `GEfc` filter item; `None` when an effect or setting is /// not modelled. fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { @@ -470,6 +505,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), + Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)), c => c.and_then(|c| known_params(c, fltr)), }; let (command, params) = match known { @@ -595,6 +631,17 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + "LnsF" => { + let lens = p.get("lens").and_then(J::as_str).unwrap_or("zoom"); + let code = LENSES.iter().find(|(_, l)| *l == lens).ok_or_else(|| format!("lens `{lens}` has no PSD equivalent"))?.0; + let mut centre = Descriptor::new("Pnt ").with("Hrzn", Value::Double(pf(p, "centerX", 0.5))).with("Vrtc", Value::Double(pf(p, "centerY", 0.5))); + centre.name = UnicodeString::new_nul(""); + d.with("Brgh", pi(p, "brightness", 100.0)) + .with("FlrC", Value::Descriptor(centre)) + .with("Lns ", Value::Enumerated { type_id: Id::new("Lns "), value: Id::Code(*code) }) + } + "Fbrs" => d.with("Vrnc", pi(p, "variance", 16.0)).with("Strg", pi(p, "strength", 4.0)).with("RndS", seed_param(p)), + "Clds" | "DfrC" => d.with("FlRs", seed_param(p)), "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -633,19 +680,40 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { } let k = known_by_command(&f.command).ok_or("this filter has no PSD equivalent")?; let fltr = fltr_for(k, &f.params)?; + // Fibers and the clouds draw with the colours stored on the item; the others store + // Photoshop's defaults. + let coloured = matches!(k.class, "Fbrs" | "Clds" | "DfrC"); + let (fg, bg) = + if coloured { (colour_rgbc(f.params.get("foreground"), 0.0)?, colour_rgbc(f.params.get("background"), 255.0)?) } else { (rgbc(0.0), rgbc(255.0)) }; + // The render filters are written with the descriptor names Photoshop gives them: the filter's + // own name on `Fltr` and empty ones on the colours. + let render = coloured || k.class == "LnsF"; + let (fltr, fg, bg) = if render { (fltr.map(|f| named(f, k.name.trim_end_matches("..."))), named_value(fg), named_value(bg)) } else { (fltr, fg, bg) }; let mut d = Descriptor::new("filterFX") .with("Nm ", Value::Text(UnicodeString::new_nul(k.name))) .with("blendOptions", blend_options(f)) .with("enab", Value::Boolean(f.visible)) .with("hasoptions", Value::Boolean(fltr.is_some())) - .with("FrgC", rgbc(0.0)) - .with("BckC", rgbc(255.0)); + .with("FrgC", fg) + .with("BckC", bg); if let Some(x) = fltr { d = d.with("Fltr", Value::Descriptor(x)); } Ok(d.with("filterID", Value::Integer(k.filter_id))) } +fn named(mut d: Descriptor, name: &str) -> Descriptor { + d.name = UnicodeString::new_nul(name); + d +} + +fn named_value(v: Value) -> Value { + match v { + Value::Descriptor(d) => Value::Descriptor(named(d, "")), + other => other, + } +} + /// Builds a `filterFX` object; filters that can't be written are left out, with a message each. pub fn filter_fx(stack: &FilterStack, layer: &str, warnings: &mut Vec) -> Descriptor { let mut list = Vec::new(); @@ -1473,4 +1541,78 @@ mod tests { assert_eq!(u, uuid_from(b"x")); assert_ne!(u, uuid_from(b"y")); } + + // Render filters as Photoshop 27 saves them on a smart object (`filterFXList` items, with + // neutral blending): Clouds and Difference Clouds in white on black, Fibers in black on white, + // Lens Flare with the 50-300mm Zoom at 159%. + const PHOTOSHOP_CLOUDS: &str = "0000000100000000000866696c746572465800000008000000004e6d202054455854000000070043006c006f00750064007300000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f7562000000000000000000000000466c74724f626a63000000070043006c006f007500640073000000000000436c64730000000100000000466c52736c6f6e67000049e40000000866696c74657249446c6f6e67436c6473"; + const PHOTOSHOP_DIFFERENCE_CLOUDS: &str = "0000000100000000000866696c746572465800000008000000004e6d202054455854000000120044006900660066006500720065006e0063006500200043006c006f00750064007300000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f7562000000000000000000000000466c74724f626a63000000120044006900660066006500720065006e0063006500200043006c006f007500640073000000000000446672430000000100000000466c52736c6f6e67000057d20000000866696c74657249446c6f6e6744667243"; + const PHOTOSHOP_FIBERS: &str = "0000000100000000000866696c746572465800000008000000004e6d2020544558540000000a004600690062006500720073002e002e002e00000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f756200000000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe0000000000000000000466c74724f626a630000000700460069006200650072007300000000000046627273000000030000000056726e636c6f6e670000001000000000537472676c6f6e670000000400000000526e64536c6f6e67000062f10000000866696c74657249446c6f6e6746627273"; + const PHOTOSHOP_LENS_FLARE: &str = "0000000100000000000866696c746572465800000008000000004e6d2020544558540000000e004c0065006e007300200046006c006100720065002e002e002e00000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f756200000000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe0000000000000000000466c74724f626a630000000b004c0065006e007300200046006c0061007200650000000000004c6e73460000000300000000427267686c6f6e670000009f00000000466c72434f626a6300000001000000000000506e7420000000020000000048727a6e646f75623fe86666600000000000000056727463646f75623fe60b60c0000000000000004c6e7320656e756d000000004c6e7320000000005a6d20200000000866696c74657249446c6f6e674c6e7346"; + + fn photoshop(hex: &str) -> Descriptor { + Descriptor::from_bytes(&from_hex(hex).unwrap()).unwrap() + } + + fn set_lens(item: &mut Descriptor, code: [u8; 4]) { + let Some(Value::Descriptor(fltr)) = item.items.iter_mut().find(|(k, _)| k.is("Fltr")).map(|(_, v)| v) else { panic!("no Fltr") }; + set(fltr, "Lns ", Value::Enumerated { type_id: Id::new("Lns "), value: Id::Code(code) }); + } + + #[test] + fn photoshop_render_filters_read_and_write_like_photoshop() { + let colours = |fg: f64, bg: f64| (json!([fg, fg, fg, 1.0]), json!([bg, bg, bg, 1.0])); + let (white, black) = colours(1.0, 0.0); + let cases = [ + (PHOTOSHOP_CLOUDS, "filter.render.clouds", json!({"seed": 18916, "foreground": white, "background": black})), + (PHOTOSHOP_DIFFERENCE_CLOUDS, "filter.render.differenceClouds", json!({"seed": 22482, "foreground": white, "background": black})), + (PHOTOSHOP_FIBERS, "filter.render.fibers", json!({"variance": 16, "strength": 4, "seed": 25329, "foreground": black, "background": white})), + ( + PHOTOSHOP_LENS_FLARE, + "filter.render.lensFlare", + json!({"brightness": 159, "centerX": 0.762499988079071, "centerY": 0.6888889074325562, "lens": "zoom"}), + ), + ]; + for (hex, command, params) in cases { + let item = photoshop(hex); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back, it is Photoshop's item again: name, colours, settings and key order. + assert_eq!(item_for_filter(&f).unwrap().items, item.items, "{command}"); + } + } + + #[test] + fn every_lens_flare_lens_round_trips_with_photoshops_code() { + for (code, lens) in LENSES { + let mut item = photoshop(PHOTOSHOP_LENS_FLARE); + set_lens(&mut item, *code); + let f = filter_from_item(&item); + assert_eq!(f.params["lens"], lens); + assert_eq!(item_for_filter(&f).unwrap().items, item.items, "{lens}"); + } + // A lens we don't know keeps the filter verbatim. + let mut item = photoshop(PHOTOSHOP_LENS_FLARE); + set_lens(&mut item, *b"Xxxx"); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + + #[test] + fn render_filters_made_in_photocraft_are_written() { + // Without colours (an older document or a script), Photoshop's default black and white. + let back = filter_from_item(&item_for_filter(&sf("filter.render.clouds", json!({"seed": 7}))).unwrap()); + assert_eq!(back.params, json!({"seed": 7, "foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]})); + // A seed above i32::MAX keeps its bit pattern, and colours may be hex. + let p = json!({"variance": 30, "strength": 10, "seed": 4_000_000_000u32, "foreground": "#ff0000", "background": [0.0, 0.0, 1.0, 1.0]}); + let back = filter_from_item(&item_for_filter(&sf("filter.render.fibers", p)).unwrap()); + assert_eq!( + back.params, + json!({"variance": 30, "strength": 10, "seed": 4_000_000_000u32, "foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]}) + ); + let flare = sf("filter.render.lensFlare", json!({"brightness": 120, "centerX": 0.25, "centerY": 0.75, "lens": "moviePrime"})); + assert_eq!(filter_from_item(&item_for_filter(&flare).unwrap()).params, flare.params); + // Lenses and colours we can't write are reported, not guessed. + assert!(item_for_filter(&sf("filter.render.lensFlare", json!({"lens": "fisheye"}))).unwrap_err().contains("fisheye")); + assert!(item_for_filter(&sf("filter.render.clouds", json!({"foreground": "red"}))).is_err()); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..e90dafebbf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (18 mapped, among them Clouds, Difference Clouds, Fibers and Lens Flare, plus Filter Gallery with Chrome; rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From e0ddefe7e0ced95765ce32738397604a962f2974 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:07:31 +0800 Subject: [PATCH 0221/1452] test(text): retain raw TySh as the document importer does --- crates/text/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d73c32dda6..4c904b716a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2053,6 +2053,8 @@ fn font_metadata_survives_tysh_and_named_style_rewrites() { } let raw = crate::psd::write_tysh(&tysh); let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + // The IO importer retains the raw block separately from the parsed text model. + imported.psd_raw = Some(raw.into()); imported.text = "Edited".into(); let rebuilt = crate::psd::build_tysh(&imported, 72.0, None); let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap(); From 24a77a18c0008be07b41c9445606a3a9d0135360 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 17:08:05 +0200 Subject: [PATCH 0222/1452] Shapes: a soft shape's transform frame follows its path (#5846) A shape below 100 % Density renders over the whole canvas, and a Feather grows its pixels, so Free Transform, the Move tool's transform controls, snapping and Properties > Transform framed the canvas or the blur. As in Photoshop the frame now follows the path and stroke whatever the mask Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds without the soft reach) feeds whole_layer_bounds and the Properties Transform fields. Rendering keeps the soft bounds; other layers and hard shapes are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 18 ++++++---- crates/engine/src/transform_cmds.rs | 41 +++++++++++++++++++++++ crates/ui-egui/src/layer_props_ui.rs | 2 +- crates/ui-egui/src/lib.rs | 10 ++++++ crates/ui-egui/src/mask_props_ui_tests.rs | 15 +++++++++ crates/ui-egui/src/panels.rs | 2 +- 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 50d248d3ce..5c41a75eab 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -867,18 +867,24 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } -/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width -/// (and the reach of a soft fill). -pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { +/// The bounding box of a shape layer's path and stroke, ignoring its Density and Feather: its +/// path control bounds plus stroke width. It is the frame Free Transform and the Move tool's +/// transform controls show for a soft shape, whose rendering reaches past it (in Photoshop a +/// shape's transform frame follows its path whatever its mask Density and Feather). +pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { return None; } let (x0, y0, x1, y1) = sh.path.control_bounds()?; let stroke_margin = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| s.width.ceil()) as i32; let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32).inflate(stroke_margin); - if r.is_empty() { - return None; - } + (!r.is_empty()).then_some(r) +} + +/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width +/// (and the reach of a soft fill). +pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { + let r = hard_shape_bounds(sh)?; // A soft fill reaches past the path: by its feather, or everywhere below 100 % density. if sh.fill.is_some() && sh.has_soft_mask() { if sh.density.is_finite() && sh.density < 1.0 { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..baab069809 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -57,6 +57,9 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { fn whole_layer_bounds(layer: &Layer) -> Rect { let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + // A soft shape (Density below 100 % or a Feather) renders past its path, over the whole + // canvas below 100 %: its frame follows the path and stroke, as in Photoshop (#5846). + LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(Rect::EMPTY), _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), }; if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content } @@ -1423,4 +1426,42 @@ mod tests { assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); } } + + /// #5846: a shape's Density and Feather render past its path (over the whole canvas below + /// 100 %), but its transform frame follows the path, as in Photoshop: the same box as the hard + /// shape for Free Transform, the Move tool's controls and snapping, never the canvas. + #[test] + fn a_soft_shapes_frame_follows_its_path() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 60, 40], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let id = photocraft_doc::LayerId(id); + let boxes = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(id).unwrap(); + (transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id)) + }; + let hard = boxes(&s); + assert_eq!(hard.0, Rect::new(20, 30, 80, 70), "the hard shape's frame"); + s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + { + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let painted = photocraft_compose::bounds::content_bounds(sh.cache.as_ref().unwrap()); + assert_eq!(painted, st.doc.bounds(), "the soft fill renders over the whole canvas"); + } + let soft = boxes(&s); + assert_eq!(soft, hard, "the frame ignores Density and Feather"); + assert_ne!(soft.1, s.active().unwrap().doc.bounds()); + // Feather alone (density 100 %) leaves the frame on the path too, not grown by the blur. + s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 20})).unwrap(); + assert_eq!(boxes(&s), hard); + // Free Transform scales about the path's centre, not the canvas's. + s.execute("edit.transform", json!({"layer": id.0, "scaleX": 200})).unwrap(); + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + assert_eq!((x0.round(), y0.round(), x1.round(), y1.round()), (-10.0, 30.0, 110.0, 70.0)); + assert_eq!((sh.density, sh.feather), (1.0, 20.0), "the transform keeps the soft settings"); + } } diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index 96899a81de..70ecc28199 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -126,7 +126,7 @@ const LINK_W: f32 = 20.0; pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let mut run: Vec<(String, Value)> = Vec::new(); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); let u = LenUnit::of(app); let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); if section(ui, "transform", tl!("Transform")) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..75e356cf76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2349,6 +2349,16 @@ impl PhotocraftApp { self.bounds_cache.insert(key, (rev, b)); b } + + /// The box Properties › Transform shows for `layer` (X, Y, W, H): its pixels' bounds, but a + /// soft shape's path and stroke, whose Density and Feather render past them (#5846), as its + /// transform frame does. + pub fn layer_frame_bounds(&mut self, layer: &photocraft_doc::Layer, surface: &photocraft_raster::Surface) -> photocraft_geom::Rect { + match &layer.content { + photocraft_doc::LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(photocraft_geom::Rect::EMPTY), + _ => self.cached_bounds(layer.id.0, surface), + } + } } impl PhotocraftApp { diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index eacafafa23..15486be0e3 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -206,6 +206,21 @@ fn shape_properties_edit_density_and_feather() { } } +/// #5846: Properties › Transform (X, Y, W, H) frames a soft shape by its path, as its transform +/// frame does, though its Density renders it over the whole canvas. +#[test] +fn a_soft_shapes_transform_fields_follow_its_path() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 8, 20, 30]})).unwrap()["layer"].as_u64().unwrap(); + session.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let layer = app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + let surface = layer.surface().unwrap().clone(); + assert_eq!(surface.content_bounds(), photocraft_geom::Rect::new(0, 0, 64, 64), "the soft fill covers the canvas"); + assert_eq!(app.layer_frame_bounds(&layer, &surface), photocraft_geom::Rect::new(16, 8, 36, 38)); +} + /// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0dc692d068..e5da6386dd 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3442,7 +3442,7 @@ fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let t = Tokens::get(ui.ctx()); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); egui::Grid::new("props-grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, v) in [("X", b.x0.to_string()), ("Y", b.y0.to_string()), ("W", b.width().to_string()), ("H", b.height().to_string())] { ui.label(RichText::new(k).color(t.text_dim)); From 14ceeb927eaddf2ae821e39f466e7f7ddd0af298 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:27:15 +0800 Subject: [PATCH 0223/1452] Reproduce new selections remaining hidden after Extras is disabled --- crates/ui-egui/src/view_cmds/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..80ab177f0f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,22 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +#[test] +fn creating_a_selection_restores_hidden_extras() { + let mut hidden = Vec::new(); + for (command, params) in + [("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.loadSelection", json!({"channel": "transparency"}))] + { + let (mut app, ctx) = app_with(1); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras, "explicit hide remains effective before a new selection"); + app.run(command, params).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "{command} creates a selection"); + if !app.ui.view.shows(app.ui.view.show.selection_edges) || checked(&app, "view.extras") != Some(true) { + hidden.push(command); + } + } + assert!(hidden.is_empty(), "new selections stayed hidden after Extras was disabled: {hidden:?}"); +} From 0a608e63dbbeff4da8ff02f46cda41f02f2610f6 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:31:23 +0800 Subject: [PATCH 0224/1452] Cover marquee pointer gesture and subsequent explicit hide --- crates/ui-egui/src/input_tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..f2ac635123 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1087,3 +1087,31 @@ fn move_tool_controls_rotate_from_just_outside_the_box() { let tilt = (end[1][1] - end[0][1]).atan2(end[1][0] - end[0][0]).to_degrees(); assert!((tilt - 90.0).abs() < 8.0 || (tilt + 270.0).abs() < 8.0, "rotated about a quarter turn, got {tilt}° (zoom {zoom})"); } + +#[test] +fn marquee_drag_after_hiding_extras_restores_selection_visibility() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::RectMarquee; + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + let start = h.state().last_canvas_rect.center() - vec2(30.0, 20.0); + let end = start + vec2(60.0, 40.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(start + (end - start) * (i as f32 / 4.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the pointer drag created a selection"); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "a new marquee must become visible after hiding Extras"); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + h.run_steps(2); + assert!(!h.state().ui.view.extras, "the user can hide the existing selection again"); + h.state_mut().run("edit.fill", json!({"color": "#22cc44"})).unwrap(); + assert!(!h.state().ui.view.extras, "painting an existing selection must preserve the user's hide choice"); +} From 67a2938cab585f7d458fca46436c985dda173113 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:32:18 +0800 Subject: [PATCH 0225/1452] Guard intentional hidden selections against failures and unrelated edits --- crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 80ab177f0f..c966f516db 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -525,3 +525,17 @@ fn creating_a_selection_restores_hidden_extras() { } assert!(hidden.is_empty(), "new selections stayed hidden after Extras was disabled: {hidden:?}"); } + +#[test] +fn failed_selection_and_unrelated_edits_keep_extras_hidden() { + let (mut app, ctx) = app_with(1); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras); + assert!(app.run("select.rect", json!({"x": "invalid"})).is_err()); + assert!(!app.ui.view.extras, "failed selection must not change the view"); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + assert!(!app.ui.view.extras, "painting an existing hidden selection keeps it hidden"); + app.run("select.deselect", json!({})).unwrap(); + assert!(!app.ui.view.extras, "deselecting has no new outline to reveal"); +} From 7d42b5c838fd77003e8495c3690a7d70363183a2 Mon Sep 17 00:00:00 2001 From: ersoynarin-code Date: Thu, 15 Oct 2026 18:38:34 +0300 Subject: [PATCH 0226/1452] fix(perf): validate regression minimum threshold --- xtask/src/perf.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs index d2a3a75b85..3e49514c1c 100644 --- a/xtask/src/perf.rs +++ b/xtask/src/perf.rs @@ -168,6 +168,9 @@ pub fn parse_budgets(text: &str) -> Result { if !(b.settings.regression_pct.is_finite() && b.settings.regression_pct > 0.0) { return Err(format!("{BUDGETS}: settings.regression_pct must be > 0")); } + if !(b.settings.regression_min_ms.is_finite() && b.settings.regression_min_ms >= 0.0) { + return Err(format!("{BUDGETS}: settings.regression_min_ms must be finite and >= 0")); + } Ok(b) } @@ -980,6 +983,8 @@ not_measurable = "needs a frame harness" .is_err() ); assert!(parse_budgets("[settings]\nregression_pct = 0\n").is_err()); + assert!(parse_budgets("[settings]\nregression_min_ms = -1\n").is_err()); + assert!(parse_budgets("[settings]\nregression_min_ms = 0\n").is_ok()); assert!(parse_budgets("bogus = 1\n").is_err()); assert!(parse_budgets("not toml [").is_err()); } From 6bef23a7150b316d85dd841554ab67dc43fe091f Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:45:34 +0800 Subject: [PATCH 0227/1452] Reveal Extras when interactive selection commands succeed --- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/view_cmds/tests.rs | 23 ++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..0697409c21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1116,6 +1116,15 @@ impl PhotocraftApp { dock::reveal(self, dock::Group::Properties); } } + // Making a new selection reveals its outline after View > Extras was hidden (#5597). + // These interactive selection commands complete inline; a failed command, deselection, + // or painting inside an existing selection must leave the user's view choice alone. + if r.is_ok() + && matches!(id, "select.rect" | "select.lasso" | "select.magicWand" | "select.loadSelection" | "select.loadLuminosity") + && self.session.active().is_some_and(|st| st.doc.selection.is_some()) + { + self.ui.view.extras = true; + } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. self.ui.selected_path = Some("work".into()); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index c966f516db..299a47d22f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -510,9 +510,14 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { #[test] fn creating_a_selection_restores_hidden_extras() { let mut hidden = Vec::new(); - for (command, params) in - [("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.loadSelection", json!({"channel": "transparency"}))] - { + for (command, params) in [ + ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "ellipse": true})), + ("select.lasso", json!({"points": [[10,10], [30,10], [20,30]]})), + ("select.magicWand", json!({"x": 10, "y": 10})), + ("select.loadSelection", json!({"channel": "transparency"})), + ("select.loadLuminosity", json!({})), + ] { let (mut app, ctx) = app_with(1); app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); @@ -539,3 +544,15 @@ fn failed_selection_and_unrelated_edits_keep_extras_hidden() { app.run("select.deselect", json!({})).unwrap(); assert!(!app.ui.view.extras, "deselecting has no new outline to reveal"); } + +#[test] +fn new_selection_preserves_individual_show_preferences() { + let (mut app, ctx) = app_with(1); + app.ui.view.show.selection_edges = false; + app.ui.view.show.grid = false; + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert!(app.ui.view.extras); + assert!(!app.ui.view.show.selection_edges); + assert!(!app.ui.view.show.grid); +} From 6fc1004b5056f38142599732d1d2c7f63ebcfe32 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:47:34 +0800 Subject: [PATCH 0228/1452] Cover Extras restoration through actual layer thumbnail clicks --- crates/ui-egui/src/path_selection_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index b4aaa9e503..a1cec58060 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -331,3 +331,24 @@ fn path_overlay_drawn_for_non_vector_tools() { the path must be drawn regardless of the active tool", ); } + +#[test] +fn thumbnail_selection_after_hiding_extras_restores_outline() { + let mut h = harness(); + let shape = + h.state_mut().run("shape.create", json!({"kind":"rect","rect":[40,20,60,40],"fill":"#cc3333","name":"Badge"})).unwrap()["layer"].as_u64().unwrap(); + let active = h.state_mut().run("layer.new.layer", json!({"name":"Other"})).unwrap()["layer"].as_u64().unwrap(); + panel(&mut h, 0); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == shape).unwrap(); + click(&mut h, row.name.unwrap().center(), command()); + assert!(!h.state().ui.view.extras, "selecting layers must not reveal Extras"); + h.state_mut().run("layer.select", json!({"layer": active})).unwrap(); + h.run_steps(2); + click(&mut h, egui::pos2(row.row.left() + 46.0, row.row.center().y), command()); + assert_eq!(selection(&h), Some([40, 20, 60, 40])); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "Ctrl-clicking the thumbnail reveals the new selection"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active))); +} From 2cea742a00617efc890ff67ff915ec67fe9fb483 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:50:21 +0800 Subject: [PATCH 0229/1452] Cover remaining interactive selection routes and preserve no-op hiding --- crates/ui-egui/src/lib.rs | 15 +++++++++++++-- crates/ui-egui/src/path_selection_tests.rs | 13 +++++++++++++ crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0697409c21..cb87960ff6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1119,8 +1119,19 @@ impl PhotocraftApp { // Making a new selection reveals its outline after View > Extras was hidden (#5597). // These interactive selection commands complete inline; a failed command, deselection, // or painting inside an existing selection must leave the user's view choice alone. - if r.is_ok() - && matches!(id, "select.rect" | "select.lasso" | "select.magicWand" | "select.loadSelection" | "select.loadLuminosity") + if r.as_ref().is_ok_and(|v| v.get("changed") != Some(&Value::Bool(false)) && v.get("pending") != Some(&Value::Bool(true))) + && matches!( + id, + "select.rect" + | "select.lasso" + | "select.magneticLasso" + | "select.magicWand" + | "select.quick" + | "select.object" + | "select.loadSelection" + | "select.loadLuminosity" + | "path.toSelection" + ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { self.ui.view.extras = true; diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index a1cec58060..12b1670863 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -352,3 +352,16 @@ fn thumbnail_selection_after_hiding_extras_restores_outline() { assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "Ctrl-clicking the thumbnail reveals the new selection"); assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active))); } + +#[test] +fn loading_a_path_after_hiding_extras_restores_outline() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(10.0, 10.0, 50.0, 40.0)})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + press(&mut h, Key::Enter, command()); + assert_eq!(selection(&h), Some([10, 10, 50, 40])); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges)); +} diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 299a47d22f..83611d0e1a 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -514,7 +514,9 @@ fn creating_a_selection_restores_hidden_extras() { ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "ellipse": true})), ("select.lasso", json!({"points": [[10,10], [30,10], [20,30]]})), + ("select.magneticLasso", json!({"points": [[10,10], [30,10], [20,30]], "trace": false})), ("select.magicWand", json!({"x": 10, "y": 10})), + ("select.quick", json!({"points": [[10,10]], "size": 8})), ("select.loadSelection", json!({"channel": "transparency"})), ("select.loadLuminosity", json!({})), ] { @@ -556,3 +558,15 @@ fn new_selection_preserves_individual_show_preferences() { assert!(!app.ui.view.show.selection_edges); assert!(!app.ui.view.show.grid); } + +#[test] +fn object_selection_finding_nothing_keeps_extras_hidden() { + let (mut app, ctx) = app_with(1); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + let result = app.run("select.object", json!({"point": [3,3]})).unwrap(); + assert_eq!(result["changed"], false); + assert!(app.session.active().unwrap().doc.selection.is_some()); + assert!(!app.ui.view.extras, "finding no object must not reveal an old selection"); +} From 173e7fd6bd7cfe3840b46129e7510a2f1731f3f5 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:51:18 +0800 Subject: [PATCH 0230/1452] Test successful Object Selection restoring hidden Extras --- crates/ui-egui/src/view_cmds/tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 83611d0e1a..27f05de544 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -570,3 +570,20 @@ fn object_selection_finding_nothing_keeps_extras_hidden() { assert!(app.session.active().unwrap().doc.selection.is_some()); assert!(!app.ui.view.extras, "finding no object must not reveal an old selection"); } + +#[test] +fn object_selection_restores_extras_when_it_finds_an_object() { + let (mut app, ctx) = app_with(0); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.run("edit.fill", json!({"color": "#4073b3"})).unwrap(); + app.run("select.rect", json!({"x": 55, "y": 35, "width": 90, "height": 90, "ellipse": true})).unwrap(); + app.run("edit.fill", json!({"color": "#e64d26"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras); + let result = app.run("select.object", json!({"rect": [45,25,110,110]})).unwrap(); + assert_eq!(result["changed"], true, "the fixture must produce a new object selection"); + let selection = app.session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(selection.sample_channel(100, 80, 0) > 0.5, "the central object is selected"); + assert!(app.ui.view.shows(app.ui.view.show.selection_edges)); +} From ce0ba035728d52516bdb416c69e05bbf4fc8d911 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:56:30 +0800 Subject: [PATCH 0231/1452] Use the actual pixel grid Show preference in regression --- crates/ui-egui/src/view_cmds/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 27f05de544..9543fb7dd9 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -551,12 +551,12 @@ fn failed_selection_and_unrelated_edits_keep_extras_hidden() { fn new_selection_preserves_individual_show_preferences() { let (mut app, ctx) = app_with(1); app.ui.view.show.selection_edges = false; - app.ui.view.show.grid = false; + app.ui.view.show.pixel_grid = false; menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); assert!(app.ui.view.extras); assert!(!app.ui.view.show.selection_edges); - assert!(!app.ui.view.show.grid); + assert!(!app.ui.view.show.pixel_grid); } #[test] From d7828bda6f25f163e99758711fea1c123166fc8d Mon Sep 17 00:00:00 2001 From: EuanTop Date: Fri, 16 Oct 2026 00:01:16 +0800 Subject: [PATCH 0232/1452] Add hex input to shape Fill and Stroke popovers --- crates/ui-egui/src/vector_paint_tests.rs | 78 ++++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 31 ++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index c62277fb1e..f5c7f23cdb 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -94,6 +94,84 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain } } +#[test] +fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + let original = [0.123456, 0.345678, 0.567891, 0.372819]; + app.run("tools.setColors", json!({"foreground": original, "background": original})).unwrap(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + for ch in "12AB".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + assert_eq!(h.get_by_label("#").value().as_deref(), Some("12AB")); + assert_eq!(h.state().session.tools.foreground, original); + assert_eq!(h.state().session.tools.background, original); + for ch in "EF".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, original[3]]; + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + assert_eq!(if label == "Fill color" { tools.background } else { tools.foreground }, original); + // An invalid/multi-byte value leaves the selected paint untouched. + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Text("颜色".into())); + h.run_steps(2); + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + h.key_press(Key::Escape); + h.run_steps(2); + finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let drawn = shape(h.state()); + let paint = if label == "Fill color" { drawn.fill.as_ref().and_then(color_of) } else { drawn.stroke.as_ref().and_then(|s| color_of(&s.paint)) }; + assert_eq!(paint, Some(want), "the next shape takes the typed colour"); + } +} + +#[test] +fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + app.run("tools.setColors", json!({"foreground": [0.2, 0.3, 0.4, 0.6], "background": [0.5, 0.6, 0.7, 0.8]})).unwrap(); + app.ui.tool_options.stroke_width = 3.0; + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Paste("#12ABEF".into())); + h.run_steps(2); + let edited = shape(h.state()); + let (paint, untouched) = if label == "Fill color" { + (edited.fill.as_ref().and_then(color_of), edited.stroke == original.stroke) + } else { + (edited.stroke.as_ref().and_then(|s| color_of(&s.paint)), edited.fill == original.fill) + }; + let alpha = if label == "Fill color" { 0.6 } else { 0.8 }; + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, alpha]; + assert_eq!(paint, Some(want)); + assert!(untouched, "the other paint stays unchanged"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } +} + #[test] fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { let mut app = app("rgb", 32); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..572a3f3dbc 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1431,6 +1431,37 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 out = Some(PaintChange::Color(color)); } } + ui.horizontal(|ui| { + let label = ui.label(egui::RichText::new("#").color(t.text_dim)); + let id = resp.id.with("shape-paint-hex"); + let selected = match out { + Some(PaintChange::Color(color)) => color, + _ => original, + }; + // Keep incomplete typing across frames; deriving the field from the colour each + // frame would discard every keystroke until all six digits were present. + let mut text = ui + .data(|d| d.get_temp::<([f32; 4], String)>(id)) + .filter(|(previous, _)| *previous == selected) + .map_or_else(|| hex(selected).trim_start_matches('#').to_ascii_uppercase(), |(_, text)| text); + let response = crate::widgets::text_field(ui, id, &mut text, "RRGGBB", 90.0, true).labelled_by(label.id); + if response.changed() + && let Some(rgb) = crate::color_picker_ui::parse_hex(&text) + { + let color = [rgb[0], rgb[1], rgb[2], selected[3]]; + out = Some(PaintChange::Color(color)); + hsva = picker_color(color); + } + if response.has_focus() { + let color = match out { + Some(PaintChange::Color(color)) => color, + _ => selected, + }; + ui.data_mut(|d| d.insert_temp(id, (color, text))); + } else { + ui.data_mut(|d| d.remove::<([f32; 4], String)>(id)); + } + }); if let Some(rgb) = crate::screen_picker::button(ui, screen_id) { out = Some(sampled(rgb)); hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6edff953df..3e111a4ec1 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-14 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (shape Fill/Stroke popovers accept hex RGB values, #6176; 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) · **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, @@ -126,6 +126,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | +| Shape options-bar Fill/Stroke hex input | done | #6176; `vector_ui.rs`, `vector_paint_tests.rs`: typed and pasted hex, partial/invalid input, alpha retention, next-shape defaults and selected-shape Undo | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From 2a31c28c806141b38487f6fa9ff89bc20557ecd4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 18:12:32 +0200 Subject: [PATCH 0233/1452] WIP: Zoom tool: hold to zoom continuously with Animated Zoom (#6210) From 59d42f39980317bd931290a55d0cf42b311eff2c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 18:34:00 +0200 Subject: [PATCH 0234/1452] Free Transform: a rotated sprite's soft edge keeps its colour instead of a light border (fixes #6203) --- crates/algo/src/transform.rs | 33 +++++++++++++++++++++++++++++++-- crates/algo/src/warp.rs | 25 ++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 050d08c322..256494fb77 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -467,8 +467,11 @@ fn warp( any = true; let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n; for c in 0..a { - // Integer storage clips on write; float colours retain their signed/HDR range. - outp[o + c] = (acc[c] / al) as f32; + // Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to + // partly transparent pixels, and dividing by the clamped 1 would lighten the + // colour by the overshoot (#6203). Integer storage clips on write; float + // colours retain their signed/HDR range. + outp[o + c] = (acc[c] / acc[a]) as f32; } outp[o + a] = al as f32; } @@ -739,6 +742,32 @@ mod tests { assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}"); } + /// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots + /// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a + /// light border. + #[test] + fn rotation_keeps_the_colour_of_a_soft_edged_sprite() { + let mut s = rgba(); + s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0); + let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]]; + let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap(); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap(); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}"); + } + } + } + } + } + #[test] fn large_downscale_is_prefiltered() { // A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white. diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 188de07d47..a110eac6ca 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -374,7 +374,8 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 any = true; let b = o * n; for c in 0..a { - outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32; + // The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203). + outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32; } outp[b + a] = al as f32; } @@ -498,6 +499,28 @@ mod tests { } } + #[test] + fn a_rotated_soft_edged_sprite_keeps_its_colour() { + // #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half + // transparent edge, and must not lighten the colour. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let k = std::f64::consts::FRAC_1_SQRT_2; + let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k); + let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}"); + } + } + } + } + #[test] fn translation_and_known_arc_points() { let src = sample(SampleType::U8); From 3834845a603f5c520fe2b6c7ed566fbda391b0d2 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:45:41 -0700 Subject: [PATCH 0235/1452] Separate shortcut capture checks from native snapshot rendering Keep capture and visible-label assertions GPU independent. Add opt-in checked snapshots at both scales under the existing GPU lock and Nextest group, with a fresh-output command that rejects empty test selections. Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/ui-egui/src/shortcut_capture_tests.rs | 54 +++++++++++++++----- docs/development.md | 31 +++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/shortcut_capture_tests.rs b/crates/ui-egui/src/shortcut_capture_tests.rs index d68ae57d92..57f7caee92 100644 --- a/crates/ui-egui/src/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/shortcut_capture_tests.rs @@ -15,7 +15,13 @@ fn key(h: &mut Harness<'_, PhotocraftApp>, key: Key, pressed: bool, modifiers: M /// The Keyboard Shortcuts dialog with `command` selected, filtered to it. fn dialog(ppp: f32, command: &str, filter: &str) -> (Harness<'static, PhotocraftApp>, u64) { - let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(|cc| { + dialog_with_renderer(ppp, command, filter, false) +} + +fn dialog_with_renderer(ppp: f32, command: &str, filter: &str, gpu: bool) -> (Harness<'static, PhotocraftApp>, u64) { + let builder = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(ppp).with_max_steps(64); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) }); @@ -134,21 +140,43 @@ fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held h.run_steps(2); } +fn captured_ctrl_k(ppp: f32, gpu: bool) -> Harness<'static, PhotocraftApp> { + let (mut h, id) = dialog_with_renderer(ppp, "edit.search", "search…", gpu); + // Search's default is ⌘F (Photoshop); ⌘K is Preferences, so this also reports a conflict. + let current = crate::shortcuts::pretty("Cmd+F"); + // On Windows and Linux the Ctrl key is the command modifier. + capture(&mut h, ¤t, Key::ControlLeft, Modifiers::COMMAND, Key::K); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+K"), "@{ppp}x"); + assert_eq!(field(&h, id, "capture"), json!(false)); + let message = field(&h, id, "message"); + assert!(!message.as_str().unwrap().contains("ontrol"), "@{ppp}x: {message}"); + // The row shows the new shortcut. + h.get_by_label(&crate::shortcuts::pretty("Cmd+K")); + h +} + #[test] fn ctrl_k_is_recorded_as_ctrl_k_not_the_control_key() { for ppp in [1.0, 2.0] { - let (mut h, id) = dialog(ppp, "edit.search", "search…"); - // Search's default is ⌘F (Photoshop); ⌘K is Preferences, so this also reports a conflict. - let current = crate::shortcuts::pretty("Cmd+F"); - // On Windows and Linux the Ctrl key is the command modifier. - capture(&mut h, ¤t, Key::ControlLeft, Modifiers::COMMAND, Key::K); - assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+K"), "@{ppp}x"); - assert_eq!(field(&h, id, "capture"), json!(false)); - let message = field(&h, id, "message"); - assert!(!message.as_str().unwrap().contains("ontrol"), "@{ppp}x: {message}"); - // The row shows the new shortcut. - h.get_by_label(&crate::shortcuts::pretty("Cmd+K")); - h.render().ok(); + let _h = captured_ctrl_k(ppp, false); + } +} + +/// Opt-in visual check, following the Camera Raw localization snapshot convention. +#[test] +fn captured_shortcut_gpu_snapshots() { + let Some(directory) = std::env::var_os("PHOTOCRAFT_SHORTCUT_SNAPSHOTS") else { + eprintln!("set PHOTOCRAFT_SHORTCUT_SNAPSHOTS to capture the shortcut dialog at 1x and 2x"); + return; + }; + let _gpu = photocraft_testkit::gpu_lock(); + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + for (scale, ppp) in [(1, 1.0), (2, 2.0)] { + let mut h = captured_ctrl_k(ppp, true); + let image = h.render().expect("render captured shortcut"); + assert_eq!((image.width(), image.height()), (1100 * scale, 760 * scale)); + image.save(directory.join(format!("ctrl-k-{scale}x.png"))).expect("save shortcut snapshot"); } } diff --git a/docs/development.md b/docs/development.md index ceb679f5f7..837f6c0536 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,6 +61,37 @@ cargo test --doc --workspace # nextest does not run doctests [cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR. +### Shortcut capture without a GPU, and native snapshots + +The ordinary `shortcut_capture_tests` check modifier capture, conflicts, restored bindings and +visible labels without starting a GPU renderer. `captured_shortcut_gpu_snapshots` repeats the Ctrl+K +capture assertions at 1x and 2x with a native renderer when `PHOTOCRAFT_SHORTCUT_SNAPSHOTS` names +an output directory. Its `gpu` test name selects the existing Nextest GPU group. It also holds +`photocraft_testkit::gpu_lock`, requires successful rendering and +checks physical image dimensions before saving both PNGs. As with the Camera Raw localization +snapshots, pixel appearance is reviewed from the saved images rather than a golden-image test. + +On a native host with a working wgpu adapter, capture the two images into a fresh directory: + +```sh +( + set -eu + shortcut_snapshots="$(mktemp -d)" + PHOTOCRAFT_SHORTCUT_SNAPSHOTS="$shortcut_snapshots" cargo test -p photocraft-ui-egui --lib \ + prefs_ui::shortcut_capture_tests::captured_shortcut_gpu_snapshots -- --exact + test -s "$shortcut_snapshots/ctrl-k-1x.png" + test -s "$shortcut_snapshots/ctrl-k-2x.png" + printf 'Review the shortcut row and conflict message in %s\n' "$shortcut_snapshots" +) +``` + +The file checks intentionally reject a successful zero-test selection. Keep the fresh directory +until both scales have been inspected: the row must show the captured shortcut without a stray ControlLeft +key name, and the conflict message must remain readable. Rendering and file-write failures fail +the opted-in test; an unset variable leaves the ordinary behavior tests available without a GPU. +After review, remove only the two generated PNGs and their owned temporary directory. The same +Rust test and variable work on Windows with the native shell's environment-variable syntax. + ## Fonts (craft-fonts) Font assets shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: don't commit font files here (Inter and JetBrains Mono in `assets/fonts/` are the only exceptions; new fonts go to craft-fonts). The rules are in `craftrules/standards/fonts.md` in a sibling `craftrules` checkout (see below); that repository is not public, so outside contributors can ask a maintainer for the rules that apply to their change. From a3cbc880e6a9e2a92962fa89361966180d916ea4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 18:48:58 +0200 Subject: [PATCH 0236/1452] Preferences: Save in Background off saves in the foreground (#6207) Preferences > File Handling > Save in Background was never read. Saves already run as a background job; the setting now picks how the app waits for it, as in Photoshop: - On (default): you keep working while the file is written; progress shows in the document tab and the status bar. The modal progress dialog no longer appears for these saves. - Off: the same job, worker and atomic write, but the modal progress dialog shows at once and no shortcut reaches the app until the file is complete; a close or quit asked for meanwhile waits for it. The setting now shows in Edit > Preferences. Prefs audit: settings that do nothing 26 -> 25 of 166. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/prefs.rs | 3 +- crates/ui-egui/src/jobs_ui.rs | 48 +++++++++++---- crates/ui-egui/src/jobs_ui_tests.rs | 92 +++++++++++++++++++++++++++-- crates/ui-egui/src/shortcuts.rs | 5 ++ docs/gaps.md | 6 +- docs/scorecard.md | 6 +- 6 files changed, 140 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..0cbb7c6b95 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -439,6 +439,8 @@ impl Default for HistoryLog { pub struct FileHandling { pub image_previews: Ask, pub lowercase_extension: bool, + /// Saves run in the background while you keep working (on), or in the foreground under a + /// modal progress dialog that blocks the app until the file is written (off). pub save_in_background: bool, /// Save recovery information (crash recovery autosave). pub autosave: bool, @@ -1014,7 +1016,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.zoomResizesWindows", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", - "fileHandling.saveInBackground", "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 29da41d481..45fa60d8a3 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -10,8 +10,12 @@ //! shown after a short delay so quick jobs don't flash it. Esc cancels. //! - Files opening in the background get a document tab at once ([`OpenTab`]); its canvas area //! shows the progress, and closing the tab cancels the open. -//! - Saves encode and write on a worker ([`start_save`], #2017) under the modal progress dialog; -//! the document is locked meanwhile, and the save is recorded when the job ends. +//! - Saves encode and write on a worker ([`start_save`], #2017); the document is locked +//! meanwhile, and the save is recorded when the job ends. With Preferences › File Handling › +//! Save in Background on (the default) you keep working while the file is written, the progress +//! in the document's tab and the status bar; off, the save runs in the foreground (#6207): the +//! modal progress dialog shows at once and no shortcut or tool input reaches the app until the +//! file is complete ([`foreground_save`]). //! //! The canvas keeps showing the pre-job state: workers never touch the session's documents. @@ -147,6 +151,9 @@ pub struct SaveJob { /// The job has ended (cancelled, say) but its worker may still be running: kept until it /// returns, so quitting and another save to the same file wait for it. pub ended: bool, + /// Started with Save in Background off: the app waits for it under the modal progress dialog + /// (see [`foreground_save`]). + pub foreground: bool, } /// The shell's job bookkeeping. @@ -301,7 +308,9 @@ pub fn start_save( Ok(Some((path, warnings))) } Started::Job(job) => { - app.jobs.saves.push(SaveJob { job, doc, revision, path, copy, automation, state, ended: false }); + // Preferences › File Handling › Save in Background, read when the save starts. + let foreground = !app.session.prefs().file_handling.save_in_background; + app.jobs.saves.push(SaveJob { job, doc, revision, path, copy, automation, state, ended: false, foreground }); // Control and MCP requests wait on it, as for other jobs. app.jobs.last_started = Some(job); // The status bar's progress readout names the save; clear any old message. @@ -394,9 +403,20 @@ pub fn keep_polling(app: &PhotocraftApp, ctx: &egui::Context) -> bool { true } -/// The job the user is looking at: the open tab in view, the active document's job, or (for -/// the status bar) the newest running job. +/// The save the app is waiting on, while it runs: one started with Preferences › File Handling › +/// Save in Background off (#6207). Like Photoshop's foreground save it blocks the app: the modal +/// progress dialog shows at once ([`dialog`]) and no shortcut or tool reaches the documents +/// until the file is written; anything after it (a close prompt, quitting) waits for it. +pub fn foreground_save(app: &PhotocraftApp) -> Option { + app.jobs.saves.iter().filter(|s| s.foreground && !s.ended).find_map(|s| app.session.job(s.job)) +} + +/// The job the user is looking at: a foreground save, the open tab in view, the active +/// document's job, or (for the status bar) the newest running job. pub fn job_in_view(app: &PhotocraftApp) -> Option { + if let Some(j) = foreground_save(app) { + return Some(j); + } if let Some(j) = app.jobs.focus.and_then(|j| app.session.job(j)) { return Some(j); } @@ -697,12 +717,20 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// The modal progress window for a job locking the active document (Photoshop shows one for /// long filters): title, status, bar, percentage and elapsed time, Cancel. Appears after -/// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). +/// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). A save shows it only when it runs in the +/// foreground (Save in Background off), and then at once, so nothing can be clicked meanwhile; a +/// background save shows its progress in the tab and the status bar instead. pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { - let Some(j) = app.session.active_job() else { return }; - if j.elapsed_ms < DIALOG_DELAY_MS { - return; - } + let j = match foreground_save(app) { + Some(j) => j, + None => { + let Some(j) = app.session.active_job() else { return }; + if j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + return; + } + j + } + }; let t = Tokens::get(ctx); let mut cancel_it = false; // Photoshop doesn't dim the window behind its progress dialog either. diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..1661ba6cbc 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -430,11 +430,15 @@ fn saves_run_in_the_background_and_are_recorded_when_they_end() { let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none()); assert!(h.state_mut().run("layer.new.layer", json!({})).is_err(), "the saving document is locked"); - // The progress dialog names the save and shows its stage; the status bar reads "Saving 0%" - // until the encoder reports progress (#3389). - assert_eq!(h.query_all_by_label("Saving big.psd").count(), 1); - h.get_by_label("Encoding"); + // Save in Background is on by default: no modal dialog, so the rest of the app stays usable + // (#6207); the status bar reads "Saving 0%" until the encoder reports progress (#3389). + assert!(h.query_by_label("Esc to cancel").is_none() && h.query_by_label("Encoding").is_none(), "no progress dialog"); h.get_by_label("Saving 0%"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_none()); + h.state_mut().ui.tool = crate::state::Tool::Move; + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Brush, "shortcuts still work while it saves"); gates.encode.store(true, Ordering::Relaxed); step_until(&mut h, |a| !a.saving()); @@ -448,6 +452,86 @@ fn saves_run_in_the_background_and_are_recorded_when_they_end() { assert!(h.state_mut().run("layer.new.layer", json!({})).is_ok(), "unlocked again"); } +/// Save in Background off (#6207): the same worker writes the file, but the app waits for it +/// like Photoshop's foreground save. The progress dialog shows at once, shortcuts don't reach the +/// app, and a close asked for meanwhile happens only once the file is complete. +#[test] +fn save_in_background_off_blocks_the_app_until_the_file_is_written() { + let mut h = app_harness(); + h.state_mut().run("prefs.set", json!({"path": "fileHandling.saveInBackground", "value": false})).unwrap(); + let gates = Gates { write: Arc::new(AtomicBool::new(true)), ..Gates::default() }; + h.state_mut().services.save_file = Some(gates.save(None)); + h.state_mut().ui.tool = crate::state::Tool::Move; + let r = h.state_mut().save_as(Some("/tmp/photocraft-test/fg.psd".into())).unwrap(); + assert_eq!(r["pending"], true, "the same background job does the work"); + h.run_steps(1); + // The dialog is up from the first frame (no delay), titled with the save, then its stage. + h.get_by_label("Saving fg.psd"); + h.get_by_label("Esc to cancel"); + let t = Instant::now(); + while h.query_by_label("Encoding").is_none() && t.elapsed() < Duration::from_secs(30) { + h.step(); + } + h.get_by_label("Encoding"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_some()); + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move, "no shortcut reaches the app while it saves"); + assert!(h.state_mut().run("layer.new.layer", json!({})).is_err(), "the saving document is locked"); + // Quitting now waits for the file. + let st = h.state_mut().session.active_mut().unwrap(); + st.saved_revision = st.revision; + assert!(press_window_close(h.state_mut()), "the close waits for the save"); + assert!(h.state().jobs.close_after_saves); + + gates.encode.store(true, Ordering::Relaxed); + let t = Instant::now(); + while h.state().jobs.close_after_saves && t.elapsed() < Duration::from_secs(30) { + h.step(); + if h.state().jobs.close_after_saves { + assert!(h.query_by_label("Esc to cancel").is_some(), "the dialog stays up until the save ends"); + } + } + assert_eq!(gates.written(), ["/tmp/photocraft-test/fg.psd"], "the file is complete before the close goes on"); + assert!(!h.state().jobs.close_after_saves, "then the held close is repeated"); + h.run_steps(2); + assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog is gone"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/tmp/photocraft-test/fg.psd")); + assert!(!st.is_dirty()); + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Brush, "shortcuts work again"); +} + +/// A failing foreground save reports its error, closes its dialog and leaves the document +/// unsaved, as a background one does; Esc still cancels one that is encoding. +#[test] +fn a_failed_or_cancelled_foreground_save_leaves_the_document_unsaved() { + let mut h = app_harness(); + h.state_mut().run("prefs.set", json!({"path": "fileHandling.saveInBackground", "value": false})).unwrap(); + let gates = Gates::open(); + h.state_mut().services.save_file = Some(gates.save(Some("disk full"))); + h.state_mut().save_as(Some("/tmp/photocraft-test/f.psd".into())).unwrap(); + step_until(&mut h, |a| !a.saving()); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none()); + assert!(noticed(h.state(), "disk full"), "the failure is reported"); + assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog is gone"); + + gates.encode.store(false, Ordering::Relaxed); + h.state_mut().services.save_file = Some(gates.save(None)); + h.state_mut().save_as(Some("/tmp/photocraft-test/g.psd".into())).unwrap(); + h.run_steps(2); + h.key_press(egui::Key::Escape); + step_until(&mut h, |a| !a.saving()); + assert!(gates.written().is_empty(), "nothing written"); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none()); + assert_eq!(h.state().ui.status, "Cancelled Saving g.psd"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_none()); +} + #[test] fn a_failed_save_or_one_cancelled_while_encoding_leaves_the_document_unsaved() { let mut h = app_harness(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e8645a1511..abb0a7bf79 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -341,6 +341,11 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_some() { return; } + // A foreground save (Save in Background off, #6207) blocks the app until the file is written; + // only Esc reaches it, to cancel while it still encodes (see `jobs_ui::tick`). + if crate::jobs_ui::foreground_save(app).is_some() { + return; + } // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar // once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters // pick an option rather than a tool (#1481). diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..324dbf12b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 25 of 166 preferences do nothing; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -197,7 +197,8 @@ what an agent can't finish alone. ### G9. Panels, preferences and workspace - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). -- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't +- **Missing:** 25 of 166 preferences do nothing (scorecard prefs audit, 2026-10-15: Save in + Background now takes effect, #6207); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 26 → 25 of 166 | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..a92e9494ee 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -177,9 +177,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 6f1984d12659098a86920b11d2b2b8e8a979ef65 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 18:59:22 +0200 Subject: [PATCH 0237/1452] Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212) Tool options and the selected tool were already remembered (tool_memory, #2814), but each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that owned the session brush restarted as the Brush, so after quitting with the Eraser its brush became the Brush's. - Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and on exit, restored at launch with stored tips as references (loaded on swap). - brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow, smoothing). - tool_memory::sanitize clamps restored tool options to their options-bar ranges. - Reset Tool / Reset All Tools clear the persisted state through the same path. - Scorecard UI-217-1 note updated (stays partial: font recents). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds.rs | 36 +++++-- crates/engine/src/brush_cmds/tests.rs | 14 +++ crates/engine/src/prefs.rs | 6 ++ crates/ui-egui/src/paint_mouse.rs | 8 +- crates/ui-egui/src/prefs_ui.rs | 146 ++++++++++++++++++++++++++ crates/ui-egui/src/shape_stroke_ui.rs | 2 +- crates/ui-egui/src/tool_memory.rs | 130 ++++++++++++++++++++++- docs/scorecard.md | 6 +- scorecard/ui.toml | 2 +- 9 files changed, 333 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index b76f8c836d..dc8c39876e 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -993,23 +993,43 @@ pub fn remember_brush(s: &mut Session) { /// bitmap. `None` while a sampled bitmap too big to embed is current (only before the store /// externalizes it; see [`ref_tip`]), so the previous remembered brush stands. fn remembered_brush(s: &Session) -> Option { - let b = &s.tools.brush; + Some(json!({ "preset": s.tools.current_preset.clone(), "brush": remembered_settings(&s.tools.brush)? })) +} + +/// A brush as JSON for the preferences (the last brush, each painting tool's own brush): its +/// settings with stored tips as references, never their full bitmaps. `None` while a sampled +/// bitmap too big to embed is in it (only before the preset store externalizes it). +pub fn remembered_settings(b: &BrushSettings) -> Option { let (tip, dual, pattern) = (ref_tip(&b.tip)?, ref_tip(&b.dual_brush.tip)?, ref_pattern(&b.texture.pattern)?); let mut c = b.clone(); (c.tip, c.dual_brush.tip, c.texture.pattern) = (tip, dual, pattern); - Some(json!({ "preset": s.tools.current_preset.clone(), "brush": serde_json::to_value(&c).ok()? })) + serde_json::to_value(&c).ok() +} + +/// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is +/// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, +/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// they paint with their previews until `Session::attach_preset_store`). +pub fn restored_settings(s: &Session, v: &Value) -> Option { + if !v.is_object() { + return None; + } + let mut b = merge_brush(&BrushSettings::default(), v, "brush.restore").ok()?; + validate_brush(&b, "brush.restore").ok()?; + b.size = b.size.clamp(1.0, photocraft_paint::MAX_BRUSH_SIZE); + for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { + *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; + } + let _ = s.load_brush_tips(&mut b); + Some(b) } /// Restore `Preferences::last_brush` into the session at launch (a missing or malformed value /// keeps the default brush). Stored tips load when the preset store attaches. pub fn restore_brush(s: &mut Session) { let v = s.prefs().last_brush.clone(); - let Some(patch) = v.get("brush").filter(|b| b.is_object()) else { return }; - let Ok(mut b) = merge_brush(&BrushSettings::default(), patch, "brush.restore") else { return }; - if validate_brush(&b, "brush.restore").is_err() { - return; - } - let _ = s.load_brush_tips(&mut b); + let Some(b) = v.get("brush").and_then(|b| restored_settings(s, b)) else { return }; s.tools.brush = b; s.tools.current_preset = v.get("preset").and_then(Value::as_str).map(str::to_string); // What was just restored is exactly what the preferences hold: nothing new to write. diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..9a2028b0cb 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -1021,6 +1021,20 @@ fn the_remembered_brush_restores_at_launch() { assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size); } +/// #6212: a remembered brush reads back clamped to its controls' ranges; one that fails the +/// `tools.setBrush` checks, or isn't a brush at all, doesn't read back. +#[test] +fn restored_brush_settings_are_clamped_or_rejected() { + let s = Session::new(); + let b = restored_settings(&s, &json!({"size": -4.0, "opacity": 7.0, "flow": -1.0, "hardness": 0.25, "smoothing": {"amount": 9.0}})).unwrap(); + assert_eq!((b.size, b.opacity, b.flow, b.hardness, b.smoothing.amount), (1.0, 1.0, 0.0, 0.25, 1.0)); + let round = restored_settings(&s, &remembered_settings(&b).unwrap()).unwrap(); + assert_eq!(round, b, "a remembered brush reads back as it was"); + for bad in [json!({"size": 1e30}), json!({"size": "big"}), json!(null), json!([1]), json!("brush")] { + assert!(restored_settings(&s, &bad).is_none(), "{bad}"); + } +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..9142abce86 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -969,6 +969,12 @@ pub struct Preferences { /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. pub tool_options: Value, + /// Each painting tool's own brush (the Brush, the Eraser, the retouching tools…; see the + /// shell's `paint_mouse::sync_tool_brush`), saved as the user changes them and restored at + /// launch, as Photoshop keeps every tool's brush between sessions (#6212): `{"owner": tool, + /// "brushes": {tool: brush}}`, where `owner` is the tool whose brush `last_brush` holds and + /// the brushes are shaped by `brush_cmds::remembered_settings`. JSON owned by the shell. + pub tool_brushes: Value, /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a45c016c59..ee7ba5fe90 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -30,7 +30,13 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { let current = app.session.tools.brush.clone(); save_brush(app, app.ui.brush_tool, current.clone()); app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) { - Some((_, b)) => session_fields(¤t, b), + Some((_, b)) => { + // A brush remembered from the previous run holds its stored tips as references: + // load their full bitmaps (a missing one keeps painting with its preview). + let mut b = session_fields(¤t, b); + let _ = app.session.load_brush_tips(&mut b); + b + } // A tool seen for the first time takes what the brush has, at Photoshop's smoothing. None => BrushSettings { smoothing: first_smoothing(), ..current }, }; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d194803020..aa6da016ec 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -53,6 +53,8 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + /// The tool owning the session brush and the other tools' brushes, as last remembered. + pub(crate) tool_brushes: Option, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -3269,6 +3271,150 @@ mod tests { } } + /// #6212: a remembered value its options-bar field could never hold is clamped to the + /// field's range (or takes the default) rather than reaching the tool. + #[test] + fn remembered_tool_options_are_clamped_to_their_fields() { + let ctx = egui::Context::default(); + let hostile = json!({"toolOptions": { + "feather": 1e30, "tolerance": -5.0, "polygon_sides": 0, "eyedropper_size": 7, + "crop_overlay_orientation": 9, "fill_opacity": 0.0, "magnetic_width": 1e9, + "pattern_stamp_angle": -720.0, "marquee_width": 1e7, "shape_stroke": {"miter_limit": -1.0, "opacity": 5.0}, + }}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + let o = &app.ui.tool_options; + let d = crate::state::ToolOptions::default(); + assert_eq!((o.feather, o.tolerance, o.polygon_sides, o.eyedropper_size), (1000.0, 0.0, 3, d.eyedropper_size)); + assert_eq!((o.crop_overlay_orientation, o.fill_opacity, o.magnetic_width), (d.crop_overlay_orientation, 1.0, 256.0)); + assert_eq!((o.pattern_stamp_angle, o.marquee_width), (-180.0, 999.0)); + assert_eq!(o.shape_stroke, d.shape_stroke, "an invalid stroke style takes the default"); + // A good value is left alone, and in-range defaults never change (nothing to save). + let (mut app2, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.5}}).to_string())); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions { feather: 12.5, ..d.clone() }); + let mut def = d.clone(); + crate::tool_memory::sanitize(&mut def); + assert_eq!(def, d); + } + + /// #6212: every painting tool keeps its own brush across a restart, and the session brush + /// comes back as the brush of the tool that had it. + #[test] + fn tool_brushes_survive_a_restart() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let set_size = |app: &mut PhotocraftApp, size: f32| { + app.session.tools.brush.size = size; + app.session.tools.brush.opacity = size / 100.0; + app.session.tools.brush_changed(); + }; + set_size(&mut app, 77.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 33.0); + app.ui.tool = Tool::CloneStamp; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 12.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolBrushes"]["owner"], json!("Eraser")); + assert_eq!(saved["toolBrushes"]["brushes"]["Brush"]["size"], json!(77.0)); + assert_eq!(saved["toolBrushes"]["brushes"]["CloneStamp"]["size"], json!(12.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_none(), "the owner's brush is the last brush"); + + let (mut app2, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut app2, &ctx); + assert_eq!((app2.ui.tool, app2.ui.brush_tool), (Tool::Eraser, Tool::Eraser)); + assert_eq!(app2.session.tools.brush.size, 33.0, "the Eraser's brush is the session brush"); + for (tool, size) in [(Tool::Brush, 77.0), (Tool::CloneStamp, 12.0), (Tool::Eraser, 33.0)] { + app2.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app2); + assert_eq!(app2.session.tools.brush.size, size, "{tool:?}"); + assert_eq!(app2.session.tools.brush.opacity, size / 100.0, "{tool:?}"); + } + } + + /// #6212: a damaged or hand-edited `toolBrushes` never panics: what reads back loads (clamped + /// to the options bar's ranges), the rest is skipped and those tools start from the brush. + #[test] + fn remembered_tool_brushes_survive_damage() { + use crate::state::Tool; + let ctx = egui::Context::default(); + for bad in [json!("junk"), json!(42), json!({"owner": [1], "brushes": "x"}), json!({"brushes": {"Eraser": 5}})] { + let (mut app, _) = app_with_saved(Some(json!({"toolBrushes": bad}).to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "{bad}"); + assert!(app.ui.tool_brushes.is_empty(), "{bad}"); + } + let hostile = json!({"toolBrushes": {"owner": "Move", "brushes": { + "Eraser": {"size": 1e30}, "Bogus": {"size": 5.0}, "Move": {"size": 5.0}, + "Smudge": {"size": -3.0, "opacity": 50.0, "flow": -2.0, "hardness": 1e30}, + "Brush": {"size": 9.0}, + }}}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "the Move tool has no brush"); + let tools: Vec = app.ui.tool_brushes.iter().map(|(t, _)| *t).collect(); + assert_eq!(tools, vec![Tool::Smudge], "too large, unknown, brushless and the owner's own are skipped"); + let b = &app.ui.tool_brushes[0].1; + assert_eq!((b.size, b.opacity, b.flow, b.hardness), (1.0, 1.0, 0.0, 1.0)); + // A whole preferences file that isn't JSON: defaults, no panic. + let (mut app, _) = app_with_saved(Some("{not json".into())); + tick(&mut app, &ctx); + assert!(app.ui.tool_brushes.is_empty()); + assert_eq!(app.ui.tool_options, crate::state::ToolOptions::default()); + } + + /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools + /// forgets every tool's. + #[test] + fn reset_tools_clear_the_remembered_options_and_brushes() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + app.ui.tool_options.feather = 12.0; + app.ui.tool_options.strength = 80.0; + app.session.tools.brush.size = 77.0; + app.session.tools.brush_changed(); + for tool in [Tool::Eraser, Tool::Smudge, Tool::Brush] { + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + } + app.ui.tool = Tool::Smudge; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolOptions"]["strength"], json!(80.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some() && saved["toolBrushes"]["brushes"].get("Brush").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].get("strength").is_none(), "the Smudge's Strength is forgotten"); + assert_eq!(saved["toolOptions"]["feather"], json!(12.0), "the marquee's Feather is not the Smudge's"); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].is_null(), "{saved}"); + assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); + assert!(app2.ui.tool_brushes.is_empty()); + } + #[test] fn view_show_items_survive_a_restart() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/shape_stroke_ui.rs b/crates/ui-egui/src/shape_stroke_ui.rs index a44e321dfc..e568403c43 100644 --- a/crates/ui-egui/src/shape_stroke_ui.rs +++ b/crates/ui-egui/src/shape_stroke_ui.rs @@ -74,7 +74,7 @@ impl StrokeOptions { }; } - fn valid(&self) -> bool { + pub(crate) fn valid(&self) -> bool { self.stroke(3.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)).validate().is_ok() } } diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 4c0f61b817..80fefe8ad7 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -4,13 +4,23 @@ //! so a default changed in a later release still reaches users who never touched that option. //! Saved once the pointer is up (not on every slider step), restored at launch. A hand-edited or //! old file can't break the options: an unknown key is ignored and a field that doesn't read back -//! (wrong type, a number too large for its field) keeps its default. +//! (wrong type, a number too large for its field) keeps its default, and numbers are clamped to +//! the ranges their options-bar fields allow ([`sanitize`]). +//! +//! Each painting tool's own brush (`UiState::tool_brushes`, swapped by +//! `paint_mouse::sync_tool_brush`) is kept the same way in `Preferences::tool_brushes` (#6212), +//! with the tool that owns the session brush (that brush itself is `Preferences::last_brush`). +//! Reset Tool and Reset All Tools change both, so the next save forgets what they reset. -use serde_json::{Map, Value}; +use photocraft_engine::BrushSettings; +use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::state::{Tool, ToolOptions}; +/// The tool whose brush the session brush is, and the other painting tools' brushes. +pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -58,6 +68,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; } @@ -68,6 +79,68 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Remember the painting tools' own brushes when they change (a compare per frame otherwise: +/// tip bitmaps are shared, so equal brushes compare by pointer). A brush that can't be remembered +/// yet (a sampled tip too big to embed, before the preset store takes it) keeps what was saved. +fn persist_brushes(app: &mut PhotocraftApp) { + let (owner, brushes) = (app.ui.brush_tool, &app.ui.tool_brushes); + if app.prefs_rt.tool_brushes.as_ref().is_some_and(|(o, b)| *o == owner && b == brushes) { + return; + } + let old = app.session.prefs().tool_brushes.get("brushes").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut saved = Map::new(); + for (tool, brush) in brushes { + // The owner's brush is the session brush, remembered as `last_brush`. + if *tool == owner { + continue; + } + let Ok(Value::String(key)) = serde_json::to_value(tool) else { continue }; + if let Some(v) = photocraft_engine::brush_cmds::remembered_settings(brush).or_else(|| old.get(&key).cloned()) { + saved.insert(key, v); + } + } + // Nothing to remember while the Brush owns the session brush and no other tool has its own. + let now = match serde_json::to_value(owner) { + Ok(o) if !saved.is_empty() || owner != Tool::Brush => json!({"owner": o, "brushes": saved}), + _ => Value::Null, + }; + app.prefs_rt.tool_brushes = Some((owner, brushes.clone())); + if app.session.prefs().tool_brushes != now { + app.session.prefs.edit(|p| p.tool_brushes = now); + } +} + +/// Restore the painting tools' own brushes and which tool the session brush (`last_brush`) +/// belongs to. Unknown tools, tools without a brush and brushes that don't read back are skipped +/// with a warning: such a tool starts from the current brush, as on a first launch. +fn restore_brushes(app: &mut PhotocraftApp) { + let saved = app.session.prefs().tool_brushes.clone(); + if !saved.is_null() && !saved.is_object() { + log::warn!("Preferences: the remembered tool brushes are not an object; using the defaults"); + } + let tool = |v: Value| serde_json::from_value::(v).ok().filter(|t| crate::paint_mouse::has_brush_picker(*t)); + if let Some(v) = saved.get("owner") { + match tool(v.clone()) { + Some(owner) => app.ui.brush_tool = owner, + None => log::warn!("Preferences: ignoring the remembered brush owner: {v}"), + } + } + let owner = app.ui.brush_tool; + let mut brushes: Vec<(Tool, BrushSettings)> = Vec::new(); + for (k, v) in saved.get("brushes").and_then(Value::as_object).into_iter().flatten() { + let Some(t) = tool(Value::String(k.clone())).filter(|t| *t != owner) else { + log::warn!("Preferences: ignoring the remembered brush of `{k}`"); + continue; + }; + match photocraft_engine::brush_cmds::restored_settings(&app.session, v) { + Some(b) => brushes.push((t, b)), + None => log::warn!("Preferences: ignoring the remembered brush of `{k}`: it doesn't read back"), + } + } + app.ui.tool_brushes = brushes; + app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); +} + /// Flush the final choice on normal exit, even if no further frame ran after the tool changed. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { persist(app, &egui::Context::default()); @@ -99,13 +172,64 @@ pub fn restore(app: &mut PhotocraftApp) { cur.insert(k.clone(), old); } } - if let Ok(o) = serde_json::from_value(Value::Object(cur)) { + if let Ok(mut o) = serde_json::from_value(Value::Object(cur)) { + sanitize(&mut o); app.ui.tool_options = o; } } else if !saved.is_null() { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_brushes(app); +} + +/// Bring remembered options into the ranges their options-bar fields allow, so a hand-edited or +/// damaged file can't start a tool with a value its own controls could never set (a 1e30 px +/// feather, a 0-sided polygon). A NaN (JSON can't hold one, but it costs nothing to rule out) +/// takes the default. +pub(crate) fn sanitize(o: &mut ToolOptions) { + let d = ToolOptions::default(); + let fit = |v: &mut f32, lo: f32, hi: f32, def: f32| *v = if v.is_nan() { def } else { v.clamp(lo, hi) }; + fit(&mut o.tolerance, 0.0, 255.0, d.tolerance); + fit(&mut o.feather, 0.0, 1000.0, d.feather); + fit(&mut o.fill_opacity, 1.0, 100.0, d.fill_opacity); + fit(&mut o.type_size, 0.1, 1296.0, d.type_size); + fit(&mut o.pattern_stamp_scale, 1.0, 1000.0, d.pattern_stamp_scale); + fit(&mut o.pattern_stamp_angle, -180.0, 180.0, d.pattern_stamp_angle); + fit(&mut o.patch_structure, 1.0, 7.0, d.patch_structure); + fit(&mut o.patch_color, 0.0, 10.0, d.patch_color); + fit(&mut o.cam_structure, 1.0, 7.0, d.cam_structure); + fit(&mut o.cam_color, 0.0, 10.0, d.cam_color); + fit(&mut o.exposure, 1.0, 100.0, d.exposure); + fit(&mut o.strength, 1.0, 100.0, d.strength); + fit(&mut o.stroke_width, 0.0, 288.0, d.stroke_width); + fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius); + fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight); + fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit); + // The marquee's Width and Height are a ratio or a size in px, as its Style says. + let (lo, hi) = if o.marquee_style == "fixedSize" { (1.0, 300_000.0) } else { (0.001, 999.0) }; + fit(&mut o.marquee_width, lo, hi, d.marquee_width); + fit(&mut o.marquee_height, lo, hi, d.marquee_height); + fit(&mut o.crop_custom_ratio_width, 0.0, f32::MAX, d.crop_custom_ratio_width); + fit(&mut o.crop_custom_ratio_height, 0.0, f32::MAX, d.crop_custom_ratio_height); + fit(&mut o.magic_eraser_opacity, 1.0, 100.0, d.magic_eraser_opacity); + fit(&mut o.bg_tolerance, 0.0, 100.0, d.bg_tolerance); + fit(&mut o.cr_tolerance, 0.0, 100.0, d.cr_tolerance); + fit(&mut o.magnetic_width, 1.0, 256.0, d.magnetic_width); + fit(&mut o.magnetic_contrast, 1.0, 100.0, d.magnetic_contrast); + fit(&mut o.magnetic_frequency, 0.0, 100.0, d.magnetic_frequency); + fit(&mut o.red_eye_pupil_size, 1.0, 100.0, d.red_eye_pupil_size); + fit(&mut o.red_eye_darken, 0.0, 100.0, d.red_eye_darken); + o.polygon_sides = o.polygon_sides.clamp(3, 100); + if o.crop_overlay_orientation > 3 { + o.crop_overlay_orientation = d.crop_overlay_orientation; + } + if !photocraft_engine::sample_cmds::SAMPLE_SIZES.contains(&o.eyedropper_size) { + o.eyedropper_size = d.eyedropper_size; + } + if !o.shape_stroke.valid() { + o.shape_stroke = d.shape_stroke; + } } /// Does `opts` deserialize, with field `k` coming back as a value? A number out of an `f32`'s diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..0903ae2d6f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 167 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -177,13 +177,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 167** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options | +| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list) | | UI-217-2 | Window size and dock width remembered across restarts | done | [#217](https://github.com/storytold/photocraft/issues/217) | eframe persistence (ui.ron): persist_window for the window, egui memory for the dock panel width; window_and_panel_geometry_survive_a_restart asserts the round trip (apps/photocraft/src/main.rs; b07a2e5b, #587) | | UI-217-3 | File > Open starts in the last-used folder | done | [#217](https://github.com/storytold/photocraft/issues/217) | file_dialog.rs last_used_dir: open dialogs ask with initial_dir (the most recent file's folder) and apps services.rs passes it to rfd set_directory; open_starts_in_the_last_used_folder + last_used_dir_takes_the_most_recent_parent | | UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..77074e62b5 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -10,7 +10,7 @@ id = "UI-217-1" target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist" status = "partial" issue = 217 -note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options" +note = "Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list)" [[item]] id = "UI-217-2" From 9a25a8a3908ba986a68e0db0596b9ce3726b3d23 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:14:38 +0700 Subject: [PATCH 0238/1452] style(svg): format expanded conversion state initializer --- crates/io/src/svg.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index a4384c8e17..6537aff28e 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -230,7 +230,17 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH)); - let mut conv = Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 }; + let mut conv = Conv { + fmt, + canvas: doc.bounds(), + global, + max_group_depth, + warnings, + noted: Vec::new(), + counts: [0; 4], + pixel_budget, + retained_pixel_bytes: 0, + }; if count_nodes(tree.root()) > MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); From a1837706ee8b17f9006b6b3602214cc0bdfeaa0c Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 19:17:07 +0200 Subject: [PATCH 0239/1452] Performance: a cancelled Gaussian Blur job stops within budget (#6205) Cancelling a 24 MP Gaussian Blur r 50 job took up to ~400 ms (P25, budget 200 ms max). Stage timings showed two stretches without a cancel check: - The transparency-lock pass (keep_alpha) that puts a Background layer's alpha back after the filter ran serially over every changed tile, about 400-450 ms on 24 MP, with no cancel check. It now fixes the tiles in parallel, each in its own scratch surface, and checks for cancellation before each tile. Results are bit-identical to the serial pass (tested against it at 8, 16 and 32-bit). - Box-blur column strips checked only before each strip; with ~31 strips per band and 32 threads one strip is tens of ms. line_passes now checks between passes. P25 max: 337-399 ms -> 13-44 ms over five runs. The uncancelled job is also faster: p50 about 690 -> 400 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/algo/src/blur.rs | 73 ++++++++++++++++++-- crates/engine/src/filters.rs | 127 +++++++++++++++++++++++++++++++---- docs/gaps.md | 8 ++- 3 files changed, 190 insertions(+), 18 deletions(-) diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index ae8b12f40a..fb62681222 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -212,12 +212,17 @@ fn box_pass_px(src: &[f32], dst: &mut [f32], r: usize) { } /// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many -/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. -fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) { +/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving +/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of +/// milliseconds on a tall image, too long to finish once cancelled (#6205). +fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) { let Scratch { line, next, acc } = s; let Some(len) = dst.len().checked_div(w) else { return }; let mut tail: usize = radii.iter().sum(); for (k, &r) in radii.iter().enumerate() { + if ctl.cancelled() { + return; + } tail -= r; if k + 1 == radii.len() { box_pass(line, dst, w, r, acc); @@ -281,7 +286,7 @@ pub(crate) fn box_blur( (0..left).for_each(|_| s.line.extend_from_slice(first)); s.line.extend_from_slice(&row); (0..right).for_each(|_| s.line.extend_from_slice(last)); - line_passes(dst, n, &radii, &mut s); + line_passes(dst, n, &radii, &mut s, ctl); } }); // Vertical passes, strip by strip, the same way down the columns. @@ -300,7 +305,10 @@ pub(crate) fn box_blur( (0..rows).for_each(|y| s.line.extend_from_slice(row(y))); (0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1))); let mut res = vec![0.0f32; oh * w]; - line_passes(&mut res, w, &radii, &mut s); + line_passes(&mut res, w, &radii, &mut s, ctl); + if ctl.cancelled() { + return (t, Vec::new()); + } unpremultiply(&mut res, n, alpha); (t, res) }); @@ -1180,4 +1188,61 @@ mod tests { } } } + + /// A column strip's passes stop between passes once cancelled, not only before the strip + /// (#6205); uncancelled, every pass runs. + #[test] + fn line_passes_stop_between_passes_when_cancelled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize); + let reach: usize = radii.iter().sum(); + let line: Vec = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect(); + let run = |ctl: &Interrupt| { + let mut s = Scratch { line: line.clone(), ..Scratch::default() }; + let mut dst = vec![0.0f32; len * w]; + line_passes(&mut dst, w, &radii, &mut s, ctl); + dst + }; + assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0)); + let calls = AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1; + // Cancelled after the first pass: the last pass (the one writing `dst`) never runs. + assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + /// A box blur cancelled at any of its checks returns `None` (never a half-blurred result); + /// one that is asked but never cancelled is bit-identical to the uninterruptible run. + #[test] + fn box_blur_honours_a_cancel_at_every_check() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (n, src) = (4usize, Rect::new(0, 0, 150, 90)); + let px: Vec = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect(); + let read = |r: Rect, buf: &mut Vec| { + buf.clear(); + let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize); + let row = r.y0 as usize * src.width() as usize * n; + buf.extend_from_slice(&px[row + x0..row + x1]); + }; + let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") }; + let out = Rect::new(-5, 3, 155, 80); + let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap(); + let checks = AtomicUsize::new(0); + let count = || { + checks.fetch_add(1, Ordering::SeqCst); + false + }; + let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap(); + assert_eq!(got.len(), want.len()); + for ((ta, a), (tb, b)) in got.iter().zip(&want) { + assert_eq!(ta, tb); + assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs"); + } + let total = checks.load(Ordering::SeqCst); + for k in (0..total).step_by((total / 40).max(1)) { + let seen = AtomicUsize::new(0); + let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k; + assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}"); + } + } } diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 9cd6ff4600..4575beb2e0 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -298,7 +298,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let area = algo::output_area(&fp, content, bounds, sel_bounds); *surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?; if let Some(before) = &before { - keep_alpha(before, surf); + keep_alpha(before, surf, &|| ctx.cancelled())?; } Ok(fp) }, @@ -307,25 +307,56 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } /// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed. -fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { +/// +/// The tiles are independent, so they are fixed in parallel, each in a scratch surface of its +/// own, and `cancelled` is asked before each one: on a 24 MP layer (a Background, whose +/// transparency is locked) a serial pass took about 400 ms that a cancelled job had to sit out +/// (#6205). `Err(Cancelled)` leaves `new` holding all its tiles, some of them not yet fixed. +fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface, cancelled: &(dyn Fn() -> bool + Sync)) -> Result<()> { + use photocraft_raster::Surface; + use rayon::prelude::*; if old.format() != new.format() || !new.format().alpha { - return; + return Ok(()); } let mut coords: Vec<_> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect(); coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c)); - let n = new.channels(); - for c in coords { - let r = c.rect(); - let o = old.read_region(r); - let mut v = new.read_region(r); - for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { - if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { - *a = *b; + let (fmt, n) = (new.format(), new.channels()); + let default = new.default_pixel(); + // Each changed tile alone in a surface (a tile `new` lacks is taken as its default pixel, as + // `write_region` would create it). + let mut tiles: Vec<(photocraft_geom::Rect, Surface)> = coords + .iter() + .map(|c| { + let mut s = Surface::with_default(fmt, &default); + s.put_tiles(new.take_tiles(c.rect())); + (c.rect(), s) + }) + .collect(); + let fixed = tiles + .par_iter_mut() + .map(|(r, s)| { + if cancelled() { + return false; } - } - new.write_region(r, &v); + let o = old.read_region(*r); + let mut v = s.read_region(*r); + for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { + if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { + *a = *b; + } + } + s.write_region(*r, &v); + true + }) + .reduce(|| true, |a, b| a && b); + for (_, s) in tiles { + new.put_tiles(s.tiles().map(|(c, t)| (*c, t.clone()))); + } + if !fixed { + return Err(EngineError::Cancelled); } new.prune(); + Ok(()) } macro_rules! filter_cmd { @@ -822,4 +853,74 @@ mod tests { assert_eq!(sm.smart_filters[0].params["radius"], json!(2)); assert!(sm.cache.as_ref().unwrap().pixel(10, 5)[3] > 0.0, "cache was blurred"); } + + /// The serial `keep_alpha` before #6205, kept as the oracle for the parallel one. + fn keep_alpha_serial(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { + if old.format() != new.format() || !new.format().alpha { + return; + } + let mut coords: Vec<_> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect(); + coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c)); + let n = new.channels(); + for c in coords { + let r = c.rect(); + let o = old.read_region(r); + let mut v = new.read_region(r); + for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { + if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { + *a = *b; + } + } + new.write_region(r, &v); + } + new.prune(); + } + + /// A layer before and after a filter: tiles the filter changed, one it left shared, one it + /// emptied (only in `old`) and one it added (only in `new`). + fn alpha_pair(sample: photocraft_color::SampleType) -> (photocraft_raster::Surface, photocraft_raster::Surface) { + use photocraft_geom::Rect; + use photocraft_raster::Surface; + let fmt = photocraft_color::PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let pattern = |r: Rect, k: f32| -> Vec { + (0..r.width() as usize * r.height() as usize * 4).map(|i| ((i as f32 * 0.37 + k).sin() * 0.5 + 0.5).clamp(0.0, 1.0)).collect() + }; + let mut old = Surface::new(fmt); + let r = Rect::new(-40, -20, 700, 300); + old.write_region(r, &pattern(r, 0.0)); + let mut new = old.clone(); + let changed = Rect::new(-40, -20, 400, 300); + new.write_region(changed, &pattern(changed, 1.3)); + let emptied = photocraft_geom::TileCoord::containing(600, 100).rect(); + drop(new.take_tiles(emptied)); + let added = Rect::new(800, 0, 900, 50); + new.write_region(added, &pattern(added, 2.1)); + (old, new) + } + + #[test] + fn keep_alpha_matches_the_serial_pass_at_every_depth() { + use photocraft_color::SampleType; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (old, new) = alpha_pair(sample); + let mut want = new.clone(); + keep_alpha_serial(&old, &mut want); + let mut got = new.clone(); + keep_alpha(&old, &mut got, &|| false).unwrap(); + assert!(got == want, "{sample:?}: parallel keep_alpha differs from the serial pass"); + assert_ne!(got, new, "{sample:?}: alpha was put back"); + } + } + + #[test] + fn keep_alpha_stops_when_cancelled_and_keeps_the_tiles() { + let (old, new) = alpha_pair(photocraft_color::SampleType::U8); + let mut got = new.clone(); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 2; + assert!(matches!(keep_alpha(&old, &mut got, &cancel), Err(EngineError::Cancelled))); + // The cancel was seen inside the pass (after some tiles), and no tile went missing. + assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 2); + assert!(new.tiles().all(|(c, _)| got.tile(*c).is_some())); + } } diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..3907ab9456 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -212,6 +212,11 @@ what an agent can't finish alone. default small blur and the 36 MP saturated-field gain are inconclusive. [Method and full ranges](tilt-shift-performance.md). Native GUI latency remains unmeasured; no scorecard budget or parity percentage is changed by this work. +- **Measured progress (2026-10-15):** P25, cancelling a Gaussian Blur r 50 job on 24 MP, stops + within 13–44 ms max over five runs (was 337–399 ms; budget 200 ms): the transparency-lock + pass that puts a Background's alpha back now runs per tile in parallel and checks for + cancellation, and column-strip box passes check between passes. The uncancelled job is + ~40% faster (p50 about 690 → 400 ms). #6205. - **Missing:** 22 of 25 budgeted scenarios are over budget (baseline 2026-10-05, a heavily loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, @@ -270,6 +275,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms), the uncancelled job about 40% faster; #6205 | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From f57bb1f3b1a47f8af04f451896f043f3ff797b86 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Thu, 15 Oct 2026 19:30:51 +0200 Subject: [PATCH 0240/1452] perf(gpu): batch resident overlap copies --- crates/gpu/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8f8839b9bd..845e29b7bb 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1540,7 +1540,7 @@ impl Compositor { }; // Only the region moved (painting past a layer's tiles grows it): keep the texels the // old texture already holds instead of converting and uploading every tile again. - let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(device, queue, &old, &mut r)); + let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(encoder, &old, &mut r)); // A new layer whose tiles another resident already holds (a duplicate shares its // source's tiles) starts as a GPU copy of that texture. Duplicating a 20 MP photo // converted and uploaded every tile again. @@ -2109,16 +2109,14 @@ fn put_tiles(device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::Com /// format and default pixel; only the region moved) and carry over the tiles they show, so only /// tiles outside the overlap or changed since need uploading. Returns the kept (tile-aligned) /// rect; empty when nothing can be kept. -fn keep_overlap(device: &wgpu::Device, queue: &wgpu::Queue, old: &Resident, new: &mut Resident) -> Rect { +fn keep_overlap(encoder: &mut wgpu::CommandEncoder, old: &Resident, new: &mut Resident) -> Rect { let same = old.kind == new.kind && old.format == new.format && old.cmyk == new.cmyk && old.default_bits == new.default_bits; let k = old.region.intersect(&new.region); if !same || k.is_empty() { return Rect::EMPTY; } - // Its own submission: tile writes go through the queue and land at the start of the next - // submit, before anything in the frame's encoder, so a copy recorded there would overwrite - // the tiles written after it with the old texels. - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose_keep") }); + // Record the copy before this frame's tile uploads/copies. Keeping it in the active encoder + // preserves that order without forcing a separate queue submission for every region shift. encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &old.texture, @@ -2134,7 +2132,9 @@ fn keep_overlap(device: &wgpu::Device, queue: &wgpu::Queue, old: &Resident, new: }, wgpu::Extent3d { width: k.width(), height: k.height(), depth_or_array_layers: 1 }, ); - queue.submit([encoder.finish()]); + // A direct `queue.write_texture` would execute before this encoder's copy and then be + // overwritten. Route changed tiles through encoder-ordered scratch copies for this resident. + new.ordered = true; new.tiles = old.tiles.iter().filter(|(c, _)| k.contains_rect(&c.rect())).map(|(c, t)| (*c, t.clone())).collect(); k } From a1502432e5257b7bed9d926fbb3560a3050065dc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:47:53 +0200 Subject: [PATCH 0241/1452] Paths panel: rename a path in place, and name the Work Path when saving it (part of #6238) --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/vector_ui.rs | 167 ++++++++++++++++++++++++---- 21 files changed, 168 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..6bb36f9b71 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2862,6 +2862,7 @@ cameraData Lens Objektiv Duplicate Layer Comp Duplikovat kompozici vrstev Duplicate Layers Duplikovat vrstvy Duplicate Path Duplikovat cestu + Rename Path… Přejmenovat cestu… Duplication Duplikace Duration Doba trvání Dust & Scratches Prach a škrábance diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..25c5b8ff63 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2245,6 +2245,7 @@ Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen + Rename Path… Pfad umbenennen… W B H H Ruler Lineal diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..33f2f4f023 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2348,6 +2348,7 @@ Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής + Rename Path… Μετονομασία διαδρομής… Duplicate style Διπλασιασμός στυλ Dust & Scratches Σκόνη και γρατζουνιές Dust & Scratches… Σκόνη και γρατζουνιές… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..97d051088a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2936,6 +2936,7 @@ curves Smooth Suavizar Clipping Path Trazado de recorte New Path Nuevo trazado Duplicate Path Duplicar trazado + Rename Path… Cambiar nombre del trazado… Auto-Hide Ocultar automáticamente X X Y Y diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..918c6dea3a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2366,6 +2366,7 @@ Clipping Path Tracé de détourage New Path Nouveau tracé Duplicate Path Dupliquer le tracé + Rename Path… Renommer le tracé… Delete Path Supprimer le tracé Auto-Hide Masquage automatique W L diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..620702ac8e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3193,6 +3193,7 @@ cameraRaw Yellow Sárga Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához. Duplicate Layer Comp Rétegkompozíció kettőzése Duplicate Path Útvonal kettőzése + Rename Path… Útvonal átnevezése… Duplication Kettőzés East asian Kelet-ázsiai Edge Extension Szélek kiterjesztése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..86e920709c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2357,6 +2357,7 @@ Clipping Path Clipping Path New Path Path Baru Duplicate Path Duplikat Path + Rename Path… Ganti Nama Path… Delete Path Hapus Path Auto-Hide Sembunyi Otomatis W W diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..6e89a84655 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2928,6 +2928,7 @@ curves Smooth Leviga Clipping Path Tracciato di ritaglio New Path Nuovo tracciato Duplicate Path Duplica tracciato + Rename Path… Rinomina tracciato… Auto-Hide Nascondi automaticamente X X Y Y diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..33e2ea9a24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2872,6 +2872,7 @@ fontWeight Italic 斜体 Duplicate Layer Comp レイヤーカンプを複製 Duplicate Layers レイヤーを複製 Duplicate Path パスを複製 + Rename Path… パス名を変更… Dust & Scratches ダスト&スクラッチ EXIF EXIF Export As 書き出し形式を指定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..5eea8a2983 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1390,6 +1390,7 @@ Rename 이름 바꾸기 Rename Group… 그룹 이름 바꾸기… Rename Layer… 레이어 이름 바꾸기… + Rename Path… 패스 이름 바꾸기… Rename Tool Preset… 도구 사전 설정 이름 바꾸기… Rename… 이름 바꾸기… Resample 리샘플링 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..161ba124f2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3347,6 +3347,7 @@ cameraRaw Color Kleur Do you want to save you made to “{name}” before quitting? Wil je de wijzigingen in “{name}” opslaan voordat je afsluit? Dodge/Burn Tegenhouden/doordrukken Duplicate Path Pad dupliceren + Rename Path… Pad hernoemen… Dust & Scratches Stof en krassen Dynamics Dynamiek Edit Plane Vlak bewerken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..c7891648fe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2389,6 +2389,7 @@ Clipping Path Ścieżka przycinająca New Path Nowa ścieżka Duplicate Path Duplikuj ścieżkę + Rename Path… Zmień nazwę ścieżki… Delete Path Usuń ścieżkę Auto-Hide Ukrywanie automatyczne W Sz. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..b42fc3710c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2361,6 +2361,7 @@ Clipping Path Demarcador de corte New Path Novo demarcador Duplicate Path Duplicar demarcador + Rename Path… Renomear demarcador… Delete Path Excluir demarcador Auto-Hide Ocultar automaticamente W L diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..b602fa9c26 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1190,6 +1190,7 @@ Clipping Path Обтравочный контур New Path Новый контур Duplicate Path Дублировать контур + Rename Path… Переименовать контур… Delete Path Удалить контур Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..ecfc33a541 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2343,6 +2343,7 @@ Clipping Path Putanja isecanja New Path Nova putanja Duplicate Path Dupliraj putanju + Rename Path… Preimenuj putanju… Delete Path Obriši putanju Auto-Hide Automatski sakrij W Š diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..bdd8a2f8e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3319,6 +3319,7 @@ cameraRaw Color Renk Do you want to save you made to “{name}” before quitting? Çıkmadan önce yaptığınız işi “{name}” konumuna kaydetmek istiyor musunuz? Dodge/Burn Soldurma/Yakma Duplicate Path Yinelenen Yol + Rename Path… Yolu Yeniden Adlandır… Dust & Scratches Toz ve Çizikler Dynamics Dinamik Edit Plane Düzlemi Düzenle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..79daa22097 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1165,6 +1165,7 @@ Clipping Path Відсічний контур New Path Новий контур Duplicate Path Дублювати контур + Rename Path… Перейменувати контур… Delete Path Видалити контур Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..97ea1d458e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1272,6 +1272,7 @@ Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn Duplicate Path Nhân bản đường dẫn + Rename Path… Đổi tên đường dẫn… Delete Path Xóa đường dẫn Delete Vector Mask Xóa mặt nạ vector Draw with the Pen tool (P) or make a work path from a selection. Vẽ bằng công cụ Bút (P) hoặc tạo đường dẫn làm việc từ vùng chọn. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..3b3b5098ff 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1430,6 +1430,7 @@ Rename 重命名 Rename Group… 重命名组… Rename Layer… 重命名图层… + Rename Path… 重命名路径… Rename Tool Preset… 重命名工具预设… Rename… 重命名… Resample 重新采样 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..5d96e20346 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1427,6 +1427,7 @@ Rename 重新命名 Rename Group… 重新命名群組… Rename Layer… 重新命名圖層… + Rename Path… 重新命名路徑… Rename Tool Preset… 重新命名工具預設集… Rename… 重新命名… Resample 重新取樣 diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..b6aff620a3 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1690,6 +1690,32 @@ pub fn paths_footer(ctx: &egui::Context) -> Option { ctx.data(|d| d.get_temp(footer_id())) } +/// Photoshop's default name for a saved path: the first free "Path N", counting from the number of +/// saved paths. +fn free_path_name(doc: &Document) -> String { + let mut n = doc.paths.len().saturating_add(1); + while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { + n = n.saturating_add(1); + if n == usize::MAX { + break; + } + } + format!("Path {n}") +} + +/// A Paths panel name being typed in place: the row's key ("work" or the saved path's name) and +/// the text so far. It lives in egui's temp data, like the Channels panel's rename field. +#[derive(Clone)] +struct PathRename { + key: String, + text: String, + focused: bool, +} + +fn rename_id(doc: &Document) -> egui::Id { + egui::Id::new(("path-rename", doc.id.0)) +} + /// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a /// right-click acts on that row even when another path is selected in the panel. fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, &'static str, Value)> { @@ -1704,14 +1730,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, ("Stroke Path…", "path.stroke", json!({"name": key})), ]; if entry.kind == PathRow::Work { - let mut n = doc.paths.len().saturating_add(1); - while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { - n = n.saturating_add(1); - if n == usize::MAX { - break; - } - } - actions.push(("Save Path", "path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + actions.push(("Save Path", "path.rename", json!({"name": "work", "to": free_path_name(doc)}))); } else { let mut n = 1usize; while doc.paths.iter().any(|p| p.name == format!("{} copy {n}", entry.name)) { @@ -1723,6 +1742,10 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, let copy_name = format!("{} copy {n}", entry.name); actions.push(("Duplicate Path", "path.set", json!({"name": copy_name, "path": photocraft_engine::vector_cmds::path_json(&entry.path)}))); } + if entry.kind == PathRow::Saved { + // No `to`: the panel asks for the new name in place. + actions.push(("Rename Path…", "path.rename", json!({"name": key}))); + } if entry.kind != PathRow::Layer { actions.push(("Delete Path", "path.delete", json!({"name": key}))); } else if entry.name.ends_with(" Vector Mask") { @@ -1742,6 +1765,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; let mut stroke_dialog: Option = None; + let rename_id = rename_id(&doc); // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1777,8 +1801,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() }, ); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + let renaming = ui.ctx().data(|d| d.get_temp::(rename_id)).filter(|e| e.key == key); + if let Some(mut edit) = renaming { + let field = Rect::from_min_max(pos2(r.left() + 43.0, r.center().y - 11.0), pos2(r.right() - 8.0, r.center().y + 11.0)); + let id = rename_id.with("field"); + let te = ui.put(field, egui::TextEdit::singleline(&mut edit.text).id(id).font(egui::FontId::proportional(12.0))); + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if !edit.focused { + te.request_focus(); + // Like Photoshop, the whole name starts selected so typing replaces it. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(edit.text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + edit.focused = true; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } else if esc { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + } else if enter || te.lost_focus() || !te.has_focus() { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + let to = edit.text.trim(); + // The Work Path is saved even under its proposed name; an unchanged saved name is left alone. + if !to.is_empty() && (*kind == PathRow::Work || to != name) { + action = Some(("path.rename", json!({"name": key, "to": to}))); + } + } else { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } + } else { + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + } // Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target. let modifiers = ui.input(|i| i.modifiers); let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p)); @@ -1791,10 +1845,18 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.clicked() { app.ui.selected_path = Some(key.clone()); } - // Double-clicking the Work Path saves it (Photoshop's "Save Path"). - if !load && resp.double_clicked() && *kind == PathRow::Work { - let n = doc.paths.len() + 1; - action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + // As in Photoshop, double-clicking a saved path's name renames it, and double-clicking + // the Work Path asks for the name to save it under (#6238). + let proposed = match kind { + PathRow::Work => Some(free_path_name(&doc)), + PathRow::Saved => Some(name.clone()), + PathRow::Layer => None, + }; + if !load + && resp.double_clicked() + && let Some(text) = proposed + { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, PathRename { key: key.clone(), text, focused: false })); } resp.context_menu(|ui| { crate::widgets::menu_scroll(ui, |ui| { @@ -1807,6 +1869,9 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if cmd == "path.stroke" { // Stroke Path… asks for the tool first, as in Photoshop (#2978). stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string()); + } else if cmd == "path.rename" && params.get("to").is_none() { + let edit = PathRename { key: key.clone(), text: name.clone(), focused: false }; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); } else { action = Some((cmd, params)); } @@ -1869,11 +1934,20 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(name) = stroke_dialog { crate::canvas_tool_menu::stroke_path_dialog(app, &name); } - if let Some((cmd, p)) = action - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = action { + let renamed = if cmd == "path.rename" { p.get("to").and_then(Value::as_str).map(str::to_string) } else { None }; + match app.run(cmd, p) { + // The renamed (or just saved) path stays the panel's target. + Ok(_) => { + if renamed.is_some() { + app.ui.selected_path = renamed; + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } } } @@ -2017,6 +2091,61 @@ mod tests { assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete")); } + /// #6238: double-clicking a saved path's name renames it in place, and double-clicking the + /// Work Path saves it under the name typed (it used to save it as "Path N" straight away). + #[test] + fn double_clicking_a_path_name_renames_it_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "First", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| paths_panel(app, ui), app); + h.run_steps(3); + let rename = |h: &mut Harness<'_, PhotocraftApp>, row: &str, text: &str| { + let name = h.get_by_label(row).rect().left_center() + vec2(60.0, 0.0); + h.event(egui::Event::PointerMoved(name)); + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.paths.iter().map(|p| p.name.clone()).collect::>(); + rename(&mut h, "First", "Outline"); + assert_eq!(names(&h), ["Outline"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_some(), "renaming a saved path leaves the work path alone"); + rename(&mut h, "Work Path", "Circle"); + assert_eq!(names(&h), ["Outline", "Circle"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_none()); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Circle")); + } + + /// #6238: a saved path's context menu offers Rename Path…, as the Layers panel's does. + #[test] + fn saved_path_context_menu_offers_rename() { + let mut app = app(); + app.run("path.set", json!({"name": "First", "path": {"subpaths": []}})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": []}})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let rename = |kind| { + let entry = path_rows(doc, None).into_iter().find(|r| r.kind == kind).unwrap(); + path_context_actions(&entry, doc).into_iter().find(|(label, _, _)| *label == "Rename Path…").map(|(_, id, p)| (id, p)) + }; + assert_eq!(rename(PathRow::Saved), Some(("path.rename", json!({"name": "First"})))); + assert_eq!(rename(PathRow::Work), None, "the work path is saved, not renamed"); + } + /// How many shapes the path overlay paints for `app` this frame. fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { let doc = app.session.active().unwrap().doc.clone(); From 802a1b179093d6926b6493497831b1b49b10f035 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:47:16 +0200 Subject: [PATCH 0242/1452] fix(compose): interior effects see the backdrop through a Normal layer's fill (fixes #6232) --- crates/compose/src/advanced_tests.rs | 50 ++++++++++++++++++++++++++++ crates/compose/src/effects.rs | 28 ++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index c19b0dc92f..d8463e221f 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -266,6 +266,56 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() { } } +#[test] +fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() { + // #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The + // overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of + // yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain + // yellow. A half-transparent part of the layer mixes it in at half strength. + let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0]; + let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]]; + let half = |c: usize| (bg[c] + lit[c]) / 2.0; + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "green", full(&d), [bg[0], bg[1], bg[2], 1.0])); + let mut l = solid(&d, "fade", Rect::new(0, 0, 6, 8), [0.2, 0.6, 0.9, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), [0.2, 0.6, 0.9, 0.5])); + l.fill_opacity = 0.0; + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 1.0), color: Color::rgb(1.0, 1.0, 0.0) }]; + d.layers.push(l); + check(&d, 2, 2, [lit[0], lit[1], lit[2], 1.0], "opaque part"); + check(&d, 9, 2, [half(0), half(1), half(2), 1.0], "half-transparent part"); + } +} + +#[test] +fn a_see_through_normal_layer_without_interior_effects_counts_the_backdrop_once() { + // A half-transparent backdrop under a Normal layer at fill 50 % whose only effect is a drop + // shadow (cast off the canvas): plain Normal compositing, blue at 50 % over red at 50 %. The + // layer must not merge into the backdrop first, or the backdrop shows twice (alpha 0.875). + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5])); + let mut l = solid(&d, "blue", Rect::new(0, 0, 12, 4), BLUE); + l.fill_opacity = 0.5; + l.effects.items = vec![Effect::DropShadow(photocraft_doc::Shadow { + common: FxCommon::new(BlendMode::Normal, 1.0), + color: Color::BLACK, + angle: 90.0, + use_global_light: false, + distance: 20.0, + spread: 1.0, + size: 0.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + })]; + d.layers.push(l); + check(&d, 2, 2, [1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75], "inside the layer"); + } +} + #[test] fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { for depth in DEPTHS { diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..04805e84e9 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1864,9 +1864,33 @@ pub fn shadow_see_through(layer: &Layer) -> f32 { /// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its /// own pixels only, and the interior effects paint over the blended result. The layer is merged /// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer -/// tints the grey beneath it (#3996). Normal-like modes give the same result either way. +/// tints the grey beneath it (#3996). A Normal layer merges only when an interior effect blends +/// in another mode: below 100 % fill that effect sees the backdrop through the layer, so a Soft +/// Light overlay on a Fill 0 % layer soft-lights the backdrop instead of showing its own colour +/// (#6232). Normal effects give the same result either way, and the merge would count a +/// half-transparent backdrop twice, so those layers keep the plain composite. pub fn premerges_layer(layer: &Layer) -> bool { - !layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve) + !layer.advanced.blend_interior + && match layer.blend { + BlendMode::PassThrough | BlendMode::Dissolve => false, + BlendMode::Normal => blends_inside(layer), + _ => true, + } +} + +/// `true` if an enabled effect painted inside the layer (with its content) blends in a mode +/// other than Normal. +fn blends_inside(layer: &Layer) -> bool { + let blends = |m: BlendMode| m != BlendMode::Normal; + layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => blends(common.blend), + Effect::Satin(s) => blends(s.common.blend), + Effect::InnerGlow(g) => blends(g.common.blend), + Effect::InnerShadow(s) => blends(s.common.blend), + Effect::Stroke(s) => stroke_widths(s).0 > 0.0 && blends(s.common.blend), + Effect::BevelEmboss(b) => bevel_geom(b).paint == BevelPaint::Inner && (blends(b.highlight.blend) || blends(b.shadow.blend)), + Effect::DropShadow(_) | Effect::OuterGlow(_) => false, + }) } /// Composites `content` (the layer's own pixels over `big`, alpha already From c4de02978b23c6638bb888d6b2730d6d8704b053 Mon Sep 17 00:00:00 2001 From: artman67 <181856174+artman67@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:20:27 -0400 Subject: [PATCH 0243/1452] Menu bar: releasing Alt after Alt+scroll no longer focuses the bar, so Space can't run File > New Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went to a desktop shortcut), now cancels the tap. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_keys.rs | 7 +++++- crates/ui-egui/src/menu_keys_tests.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..7260ef5387 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -173,7 +173,12 @@ impl Keys { self.alt_down = false; tap |= std::mem::take(&mut self.alt_tap); } - Event::Key { pressed: true, .. } | Event::Text(_) | Event::PointerButton { pressed: true, .. } => self.alt_tap = false, + // Alt+wheel (zoom, brush size…) or Alt going to the desktop is no tap either. + Event::Key { pressed: true, .. } + | Event::Text(_) + | Event::PointerButton { pressed: true, .. } + | Event::MouseWheel { .. } + | Event::WindowFocused(false) => self.alt_tap = false, _ => {} } } diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 1f226054e0..632d6d6cfc 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -76,6 +76,37 @@ fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() { assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar"); } +/// Alt+wheel zooms; letting go of Alt after it is no tap, so Space (the hand tool) can't then +/// open File and run New…. +#[test] +fn alt_with_a_wheel_scroll_or_a_lost_focus_is_no_tap() { + let mut h = bar(); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event_modifiers( + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::ALT, + }, + egui::Modifiers::ALT, + ); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt+scroll leaves the bar alone"); + key(&mut h, egui::Key::Space); + assert!(!is_open(&h.ctx), "Space opens no menu"); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event(egui::Event::WindowFocused(false)); + h.event(egui::Event::WindowFocused(true)); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt that went to the desktop leaves the bar alone"); +} + #[test] fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() { let mut h = bar(); From b11223b272f4fb3f860c3d976b291e0eda4ec05b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 20:31:20 +0200 Subject: [PATCH 0244/1452] Codecs: save Cineon (.cin) film frames Write Kodak Cineon 4.5: the 1024-byte generic header (V4.5, one or three channels designated gray or R/G/B, 10 bits, code values 0-1023 over densities 0-2.048) and a 1024-byte film header left undefined, then the samples three to a big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written as round(v * 1023 / 65535), so a frame opened from Cineon saves back with its code values; other images take the encode plan (alpha dropped, CMYK and Lab to RGB) and are quantized to 10 bits. Cineon leaves ASYMMETRIC_EXCEPTIONS, Save As offers .cin, and the codecs README, file-format parity and G10 are updated. --- apps/photocraft/src/services.rs | 1 + crates/codecs/README.md | 11 +++- crates/codecs/src/codecs/dpx.rs | 79 ++++++++++++++++++++++++- crates/codecs/src/format.rs | 9 +-- crates/codecs/src/lib.rs | 2 +- crates/codecs/tests/detect_caps.rs | 5 +- crates/codecs/tests/dpx.rs | 92 ++++++++++++++++++++++++++++++ crates/io/tests/flat.rs | 3 + docs/file-format-parity.md | 5 +- docs/gaps.md | 10 ++-- 10 files changed, 197 insertions(+), 20 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..09c1f0bcbc 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -68,6 +68,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), + ("Cineon", &["cin"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d4f7094894..f897a5841d 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,7 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | -| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | +| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -96,13 +96,18 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. -* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), +* **Cineon.** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white reference points are a log-conversion choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than - panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. + panicking. Writing follows the Cineon 4.5 layout: a 1024-byte generic header (version `V4.5`, + one or three channels designated as universal-metric gray or R, G, B, 10 bits, data 0–1023 over + densities 0–2.048) and a 1024-byte film header left undefined, then the samples three to a + big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written + as `round(v × 1023 / 65535)`, so a frame opened from Cineon keeps its code values; any other + image takes the encode plan (alpha dropped, CMYK and Lab to RGB) and is quantized to 10 bits. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 091403d641..f93c79c928 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -21,6 +21,8 @@ //! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with //! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image //! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. +//! +//! Kodak Cineon (`.cin`), the format DPX grew from, is read and written here too (see its section). use crate::Format; use crate::error::CodecError; @@ -344,7 +346,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors // (the first carries the bits per pixel and the line/image dimensions). Image data is three // 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as -// DPX's 10-bit packing method A, so the unpacking is shared. +// DPX's 10-bit packing method A, so the unpacking and packing are shared. /// The Cineon magic number (`802A5FD7` hex), big-endian. pub(crate) fn is_cineon(b: &[u8]) -> bool { @@ -409,6 +411,81 @@ pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result Result, CodecError> { + let enc = |msg: String| CodecError::encode(Format::Cineon, msg); + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + // Channel designators: 0 for black and white, 1–3 for red, green and blue. + let designators: &[u8] = match img.layout() { + ChannelLayout::Gray => &[0], + ChannelLayout::Rgb => &[1, 2, 3], + l => return Err(enc(format!("Cineon cannot store {l:?}"))), + }; + let samples = img.to_u16_samples().ok_or_else(|| enc(format!("cannot write {:?} samples at 10 bits", img.sample_type())))?; + let codes: Vec = samples.into_iter().map(|v| narrow(v, 1023)).collect(); + let per_line = (w as usize).checked_mul(designators.len()).ok_or_else(|| enc("image too large".into()))?; + if codes.len() != per_line.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? { + return Err(enc("the image has the wrong number of samples".into())); + } + let data_len = line_bytes(per_line, 10, 1).and_then(|l| l.checked_mul(h as usize)).ok_or_else(|| enc("image too large".into()))?; + let total = + CINEON_HEADER_LEN.checked_add(data_len).and_then(|t| u32::try_from(t).ok()).ok_or_else(|| enc("image too large for a Cineon file (4 GiB)".into()))?; + + let mut wr = Writer { h: vec![0; CINEON_HEADER_LEN] }; + // File information. + wr.u32(0, 0x802A_5FD7); + wr.u32(4, CINEON_HEADER_LEN as u32); + wr.u32(8, CINEON_GENERIC_LEN); + wr.u32(12, CINEON_HEADER_LEN as u32 - CINEON_GENERIC_LEN); + wr.u32(16, 0); + wr.u32(20, total); + wr.string((24, 8), "V4.5"); + // Image information: default orientation, then each channel's descriptor; the unused ones, + // the white point and the primaries are undefined. + wr.u8(192, 0); + wr.u8(193, designators.len() as u8); + wr.undefined(CINEON_CHANNEL0, 420); + for (i, &designator) in designators.iter().enumerate() { + let c = CINEON_CHANNEL0 + 28 * i; + wr.u8(c, 0); + wr.u8(c + 1, designator); + wr.u8(c + 2, 10); + wr.u8(c + 3, 0); + wr.u32(c + 4, w); + wr.u32(c + 8, h); + // Code values 0–1023 stand for printing densities 0–2.048, the Cineon convention. All + // four are 32-bit floats here (DPX stores the data values as integers). + wr.u32(c + 12, 0f32.to_bits()); + wr.u32(c + 16, 0f32.to_bits()); + wr.u32(c + 20, 1023f32.to_bits()); + wr.u32(c + 24, 2.048f32.to_bits()); + } + // Pixel-interleaved, unsigned, positive, no line or channel padding. + wr.u8(681, 5); + // Image origination: pixel pitches and gamma unknown. + wr.undefined(972, 984); + // Film header: manufacturer, type, perforation offset, prefix, count, frame position and rate + // unknown; its strings stay empty. + wr.undefined(1024, 1036); + wr.undefined(1068, 1076); + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| enc("out of memory".into()))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, 10); + } + Ok(out) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index fa2e1e6cb3..c00ad38805 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,7 +33,7 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, - /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from). Cineon, } @@ -80,11 +80,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), - ( - Format::Cineon, - "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ - imported frame as DPX, TIFF or EXR instead.", - ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -266,7 +261,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, - Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, + Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..4dccf3418e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -255,7 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), - Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), + Format::Cineon => dpx::encode_cineon(image, plan), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index f2191e05f1..312920aebd 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -60,6 +60,7 @@ fn default_build_formats_all_symmetric() { Format::Hdr, Format::Fits, Format::Dpx, + Format::Cineon, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); @@ -192,8 +193,8 @@ fn detect_magic_cineon() { b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); assert_eq!(detect(&b), Some(Format::Cineon)); assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); - assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); - assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + assert!(caps(Format::Cineon).read && caps(Format::Cineon).write, "Cineon is read and written"); + assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); // A bare magic is an error, never a panic. assert!(decode_as(Format::Cineon, &b).is_err()); } diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/dpx.rs index 8fdeb398c3..6fbfb2db7b 100644 --- a/crates/codecs/tests/dpx.rs +++ b/crates/codecs/tests/dpx.rs @@ -384,3 +384,95 @@ fn unsupported_and_damaged_files_error_instead_of_panicking() { assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); } } + +// ---------------------------------------------------------------- Cineon (Kodak .cin) + +/// A Cineon 4.5 file built field by field: the 1024-byte generic header, a 1024-byte film header, +/// then `codes` (interleaved, top line first) three 10-bit values to a big-endian word, left +/// justified, each line on a word boundary. +fn cineon_bytes(channels: u8, width: u32, height: u32, codes: &[u16]) -> Vec { + let mut b = vec![0u8; 2048]; + b[0..4].copy_from_slice(&0x802A_5FD7u32.to_be_bytes()); + b[4..8].copy_from_slice(&2048u32.to_be_bytes()); + b[193] = channels; + b[198] = 10; + b[200..204].copy_from_slice(&width.to_be_bytes()); + b[204..208].copy_from_slice(&height.to_be_bytes()); + for line in codes.chunks(width as usize * usize::from(channels)) { + for chunk in line.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (22 - 10 * i as u32))); + b.extend(w.to_be_bytes()); + } + } + b +} + +/// Code values spread over 0..=1023, ends included. +fn cineon_codes(n: usize) -> Vec { + (0..n).map(|i| if i == 1 { 1023 } else { ((i as u64 * 2654435761) % 1024) as u16 }).collect() +} + +/// Reads back what we wrote: (channels, width, height, code values), from the header's own fields. +fn cineon_written(b: &[u8]) -> (u8, u32, u32, Vec) { + let off = be32_at(b, 4) as usize; + let (nc, w, h) = (b[193], be32_at(b, 200), be32_at(b, 204)); + let per_line = w as usize * usize::from(nc); + let stride = per_line.div_ceil(3) * 4; + let mut codes = Vec::new(); + for y in 0..h as usize { + let line = &b[off + y * stride..]; + codes.extend((0..per_line).map(|i| ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16)); + } + (nc, w, h, codes) +} + +#[test] +fn cineon_frames_saved_as_cineon_keep_their_code_values_and_reopen_identically() { + // Widths that leave partly filled words at the end of each line. + for (channels, layout) in [(1u8, ChannelLayout::Gray), (3, ChannelLayout::Rgb)] { + let codes = cineon_codes(5 * 3 * usize::from(channels)); + let img = decode(&cineon_bytes(channels, 5, 3, &codes)).unwrap(); + assert_eq!(img.layout(), layout); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap_or_else(|e| panic!("{layout:?}: {e}")); + assert_eq!(detect(&out), Some(Format::Cineon), "{layout:?}"); + assert_eq!(cineon_written(&out), (channels, 5, 3, codes), "{layout:?}"); + assert_eq!(decode(&out).unwrap(), img, "{layout:?}"); + } +} + +#[test] +fn cineon_header_follows_the_kodak_layout() { + let img = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap(); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap(); + let f32_at = |at: usize| f32::from_bits(be32_at(&out, at)); + // Generic and film header lengths, user data, total size, version. + assert_eq!((be32_at(&out, 4), be32_at(&out, 8), be32_at(&out, 12), be32_at(&out, 16)), (2048, 1024, 1024, 0)); + assert_eq!(be32_at(&out, 20) as usize, out.len()); + assert_eq!(&out[24..29], b"V4.5\0"); + // Upright, three channels, each the universal metric's red, green, blue at 10 bits. + assert_eq!((out[192], out[193]), (0, 3)); + for (i, colour) in [1u8, 2, 3].into_iter().enumerate() { + let c = 196 + 28 * i; + assert_eq!(&out[c..c + 3], &[0, colour, 10], "channel {i}"); + assert_eq!((be32_at(&out, c + 4), be32_at(&out, c + 8)), (4, 2), "channel {i}"); + // Minimum and maximum data value and quantity, all four 32-bit floats. + assert_eq!((f32_at(c + 12), f32_at(c + 16), f32_at(c + 20), f32_at(c + 24)), (0.0, 0.0, 1023.0, 2.048), "channel {i}"); + } + // Pixel interleave, 32-bit words left justified, unsigned, positive, no padding. + assert_eq!(&out[680..692], &[0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + // Two lines of twelve samples: four words each. + assert_eq!(out.len(), 2048 + 2 * 16); +} + +#[test] +fn any_image_saves_as_ten_bit_cineon() { + // 16-bit samples land on the nearest 10-bit code; colour with alpha drops it, gray stays gray. + let wide = Image::from_u16(3, 1, ChannelLayout::Rgba, &[0, 32, 65535, 65535, 30000, 40000, 50000, 0, 1, 2, 3, 4]).unwrap(); + let out = encode(&wide, Format::Cineon, &EncodeOptions::default()).unwrap(); + let to_code = |v: u16| (f64::from(v) * 1023.0 / 65535.0).round() as u16; + let want: Vec = [0u16, 32, 65535, 30000, 40000, 50000, 1, 2, 3].into_iter().map(to_code).collect(); + assert_eq!(cineon_written(&out), (3, 3, 1, want)); + assert!(fidelity_warnings(&wide, Format::Cineon).contains(&FidelityWarning::AlphaDiscarded)); + let gray8 = Image::from_u8(2, 1, ChannelLayout::Gray, vec![0, 255]).unwrap(); + assert_eq!(cineon_written(&encode(&gray8, Format::Cineon, &EncodeOptions::default()).unwrap()), (1, 2, 1, vec![0, 1023])); +} diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index b82b41ffc3..decaa2498b 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -56,6 +56,9 @@ codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); +// Cineon stores 10-bit code values: within half a 10-bit step. +codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / 1023.0); +codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..8bd047d5a8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (Cineon write: 10-bit gray/RGB; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon: 10-bit log gray/RGB (Save As `.cin`) | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; a Cineon frame saved as Cineon keeps its 10-bit code values; no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Cineon write: Save As `.cin` stores 10-bit log gray/RGB frames (Cineon 4.5 header, three samples to a 32-bit word); a Cineon frame saved back keeps its code values | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..71b2317d56 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G10: Cineon write; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -225,12 +225,13 @@ what an agent can't finish alone. - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). -- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the - DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. +- **Progress (2026-10-15):** Cineon (`.cin`) 10-bit gray/RGB frames open and save (Save As + `.cin`), sharing the DPX header/packing (`crates/codecs/src/codecs/dpx.rs`); a Cineon frame saved + back keeps its code values. - **Estimate:** 120–220 h. ## Hardware gaps @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 064d8429d50176eb5331d3182af4c763ac20b567 Mon Sep 17 00:00:00 2001 From: VladimirPozdnyakov Date: Thu, 15 Oct 2026 21:56:25 +0300 Subject: [PATCH 0245/1452] =?UTF-8?q?Type:=20=E2=8C=A5=20on=20a=20paragrap?= =?UTF-8?q?h-box=20handle=20resizes=20the=20block=20symmetrically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging an edge or corner of a paragraph text box with ⌥ held mirrors the opposite edges about the box's centre, so the block grows symmetrically; without ⌥ only the dragged edges move. The drag snapshots the box and its transform at the press (state::BoxResize), so ⌥ pressed or released mid-drag applies as if it had been held from the start — the mirror always turns about the centre the box started with. The ⌥ box clamps at MIN_BOX about that centre. Free Transform and the Ctrl-frame already did ⌥-from-the-reference-point; this closes the plain Type-tool box-edit path. --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/state.rs | 13 ++++- crates/ui-egui/src/type_tool.rs | 74 +++++++++++++++++------- crates/ui-egui/src/type_tool_tests.rs | 82 +++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..5fb8aae0e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5034,7 +5034,7 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui::Modifiers) { let tool = app.active_tool(); if tool.is_type() && app.drag.is_none() { - crate::type_tool::pointer_move(app, x, y); + crate::type_tool::pointer_move(app, x, y, mods); } if tool == Tool::Pen && mods.alt { crate::vector_ui::pen_break_handle(app); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..01da477357 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -967,6 +967,17 @@ pub enum MadeLayer { Place, } +/// A paragraph-box handle drag: the handle, and the box (text-space) and layer transform as +/// they were at the press. Every move recomputes from this snapshot, so ⌥ pressed or released +/// mid-drag mirrors about the centre the box started with — as if it had been held from the +/// start. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoxResize { + pub handle: u8, + pub shape: [f32; 4], + pub aff: photocraft_geom::Affine, +} + /// In-progress inline type editing (Type tool). Offsets are character indices. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { @@ -984,7 +995,7 @@ pub struct TextEdit { pub dragging: bool, /// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges). #[serde(skip)] - pub resize: Option, + pub resize: Option, /// IME composition in progress: (start, length) in characters. The preedit text lives in the /// layer so it lays out like typed text; each IME update replaces it. #[serde(skip)] diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..83cd8f693e 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -15,7 +15,7 @@ use serde_json::json; use crate::PhotocraftApp; use crate::canvas::ViewXform; -use crate::state::TextEdit; +use crate::state::{BoxResize, TextEdit}; pub const PLACEHOLDER: &str = "Lorem Ipsum"; @@ -212,18 +212,23 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64 .map(|i| i as u8) } -/// Drag handle `i` to the document point: the dragged edges follow it, the others stay put. -fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { - let id = LayerId(ed.layer); - let Some((bx, by, w, h)) = box_shape(app, id) else { return }; - let Some((_, aff, _)) = layout(app, id) else { return }; - let (px, py) = to_text(&aff, x, y); - let (l, r, t, b) = handle_sides(i); +/// Drag the handle of `r` (the box and transform snapshotted at the press) to the document +/// point: the dragged edges follow it, the others stay put. With ⌥ the opposite edges mirror +/// the drag about the centre the box started with, so the block grows symmetrically instead of +/// from one side — pressing or releasing ⌥ mid-drag switches that on and off as if it had been +/// held from the start. +fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, r: BoxResize, x: f64, y: f64, alt: bool) { + let (px, py) = to_text(&r.aff, x, y); + let (l, rw, t, b) = handle_sides(r.handle); + let (bx, by, w, h) = (r.shape[0], r.shape[1], r.shape[2], r.shape[3]); let (mut x0, mut y0, mut x1, mut y1) = (bx, by, bx + w, by + h); + // The centre the ⌥ mirror turns about: the box as it stood at the press (every move + // recomputes from that snapshot, so the centre holds for the whole drag). + let (cx, cy) = (bx + w / 2.0, by + h / 2.0); if l { x0 = px.min(x1 - MIN_BOX); } - if r { + if rw { x1 = px.max(x0 + MIN_BOX); } if t { @@ -232,8 +237,28 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { if b { y1 = py.max(y0 + MIN_BOX); } + if alt { + // The mirror keeps the centre fixed: the dragged edge clamps at MIN_BOX/2 from it, and + // the opposite edge follows, so the block never inverts below the minimum. + if l { + x0 = px.min(cx - MIN_BOX / 2.0); + x1 = 2.0 * cx - x0; + } + if rw { + x1 = px.max(cx + MIN_BOX / 2.0); + x0 = 2.0 * cx - x1; + } + if t { + y0 = py.min(cy - MIN_BOX / 2.0); + y1 = 2.0 * cy - y0; + } + if b { + y1 = py.max(cy + MIN_BOX / 2.0); + y0 = 2.0 * cy - y1; + } + } // type.edit places the box's top-left at the given document point. - let o = aff.apply(Point::new(f64::from(x0), f64::from(y0))); + let o = r.aff.apply(Point::new(f64::from(x0), f64::from(y0))); let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session})); } @@ -242,8 +267,11 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie if let Some(ed) = app.ui.text_edit.clone() { let id = LayerId(ed.layer); if let Some(i) = box_handle_at(app, id, x, y) { - if let Some(e) = app.ui.text_edit.as_mut() { - e.resize = Some(i); + // Snapshot the box and transform now: every move recomputes from them, so ⌥ held + // or released mid-drag mirrors about the centre the drag started with. + let snap = box_shape(app, id).zip(layout(app, id)).map(|((bx, by, w, h), (_, aff, _))| BoxResize { handle: i, shape: [bx, by, w, h], aff }); + if let (Some(snap), Some(e)) = (snap, app.ui.text_edit.as_mut()) { + e.resize = Some(snap); } return true; } @@ -282,10 +310,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie false } -pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64) { +pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { let Some(ed) = app.ui.text_edit.clone() else { return }; - if let Some(i) = ed.resize { - resize_box(app, &ed, i, x, y); + if let Some(r) = ed.resize { + resize_box(app, &ed, r, x, y, mods.alt); return; } if ed.dragging { @@ -2636,9 +2664,9 @@ mod tests { let steps = app.session.active().unwrap().history.entries().len(); // Top-left corner: the box keeps its bottom-right corner. assert!(pointer_down(&mut app, 10.0, 10.0, egui::Modifiers::NONE)); - assert_eq!(app.ui.text_edit.as_ref().unwrap().resize, Some(0)); - pointer_move(&mut app, 20.0, 25.0); - pointer_move(&mut app, 30.0, 30.0); + assert_eq!(app.ui.text_edit.as_ref().unwrap().resize.map(|r| r.handle), Some(0)); + pointer_move(&mut app, 20.0, 25.0, egui::Modifiers::NONE); + pointer_move(&mut app, 30.0, 30.0, egui::Modifiers::NONE); pointer_up(&mut app, [10.0, 10.0], [30.0, 30.0]); assert_eq!(box_shape(&app, id), Some((0.0, 0.0, 80.0, 30.0))); let aff = layout(&mut app, id).unwrap().1; @@ -2648,9 +2676,17 @@ mod tests { assert!(app.ui.text_edit.is_some()); // Right edge: only the width changes, and never below the minimum. assert!(pointer_down(&mut app, 110.0, 45.0, egui::Modifiers::NONE)); - pointer_move(&mut app, 0.0, 99.0); + pointer_move(&mut app, 0.0, 99.0, egui::Modifiers::NONE); pointer_up(&mut app, [110.0, 45.0], [0.0, 99.0]); assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX, 30.0))); + // ⌥ mirrors the drag about the centre: the left edge follows the right one out. + assert!(pointer_down(&mut app, 34.0, 45.0, egui::Modifiers::ALT)); + pointer_move(&mut app, 70.0, 45.0, egui::Modifiers::ALT); + pointer_up(&mut app, [34.0, 45.0], [70.0, 45.0]); + assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX + 72.0, 30.0))); + let aff = layout(&mut app, id).unwrap().1; + // Text space −36..40 (centre 2) through the translation: the doc centre stays at 32. + assert_eq!((aff.m[4], aff.m[5]), (-6.0, 30.0)); } #[test] diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index f56829c6d1..b2684de425 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2062,3 +2062,85 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } + +/// The paragraph box's document rect: its text-space origin through the layer transform, then +/// width and height. Only what the symmetric-resize tests need (identity linear part). +fn box_doc_rect(app: &PhotocraftApp, id: LayerId) -> [f64; 4] { + let t = text(app, id); + let (x, y, w, h) = match t.shape { + photocraft_doc::text::TextShape::Box { x, y, width, height } => (f64::from(x), f64::from(y), f64::from(width), f64::from(height)), + ref s => panic!("not a box: {s:?}"), + }; + let p = t.transform.apply(Point::new(x, y)); + [p.x, p.y, w, h] +} + +/// ⌥ on an edge drag of a paragraph box mirrors the opposite edge about the box's centre, so +/// the block grows symmetrically; without ⌥ the opposite edge stays put. +#[test] +fn alt_dragging_a_box_edge_mirrors_the_opposite_edge() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + // The box is at (300, 300), 240×180: its bottom-edge handle is the midpoint (420, 480). + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 530.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 230.0], "plain drag: the top edge stays"); + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::ALT); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 580.0, pressure: 1.0 }, Modifiers::ALT); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 580.0 }, Modifiers::ALT); + // The centre (300 + 115 = 415) held: the bottom followed the pointer (580), the top + // mirrored up to 250. + assert_eq!(box_doc_rect(&app, id), [300.0, 250.0, 240.0, 330.0]); +} + +/// ⌥ pressed or released mid-drag applies as if it had been held from the start: the mirror +/// turns about the centre the box had at the press, not about wherever the box drifted. +#[test] +fn alt_pressed_mid_drag_mirrors_about_the_press_centre() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + // Bottom edge (420, 480) dragged down 80 px without ⌥: only the bottom moves. + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 520.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 560.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 260.0]); + // ⌥ pressed mid-drag: the bottom follows the pointer, the top jumps to the mirror about + // the press centre (390): 2*390 − 600 = 180 — not about the drifted one (430). + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::ALT); + assert_eq!(box_doc_rect(&app, id), [300.0, 180.0, 240.0, 420.0]); + // ⌥ released again mid-drag: the top snaps back to where a plain drag leaves it. + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 300.0]); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 600.0 }, Modifiers::NONE); +} + +/// ⌥ mirrors a side-edge drag the same way, and a corner drag grows both axes about the +/// centre; clamped, the ⌥ box stops at MIN_BOX about its centre instead of collapsing. +#[test] +fn alt_mirrors_side_and_corner_drags_and_clamps_at_the_minimum() { + use crate::canvas::{ToolEvent, tool_event}; + let drag = |app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers| { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); + }; + // Right-edge handle: (540, 390) for the box at (300, 300), 240×180. + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + drag(&mut app, [540.0, 390.0], [590.0, 390.0], Modifiers::ALT); + // cx = 420 held: the right edge followed to 590, the left mirrored from 300 to 250. + assert_eq!(box_doc_rect(&app, id), [250.0, 300.0, 340.0, 180.0]); + // Clamped: dragging the right edge past the centre leaves the minimum box about it. + drag(&mut app, [590.0, 390.0], [400.0, 390.0], Modifiers::ALT); + let r = box_doc_rect(&app, id); + assert_eq!(r[2], 4.0, "MIN_BOX wide"); + assert!(((r[0] + r[2] / 2.0) - 420.0).abs() < 1e-9, "still centred on 420"); + // Corner handle (bottom-right of the fresh box): ⌥ grows both axes about the centre. + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + drag(&mut app, [540.0, 480.0], [590.0, 530.0], Modifiers::ALT); + assert_eq!(box_doc_rect(&app, id), [250.0, 250.0, 340.0, 280.0]); +} From e8f50f69c3d1d0e9c96b4b0f31349fa156f990f5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 21:17:33 +0200 Subject: [PATCH 0246/1452] scorecard: TOOL-214-4 note and the pasteboard menu row catch up with main Dragging layers onto another document's tab landed in #851 (#589), and the pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the context-menu parity row still listed both as open. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/context-menu-parity.md | 5 +++-- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 76d6724b7e..23c34ebaa6 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** trivial (pasteboard row: the menu exists, #2462; layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -82,7 +82,7 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus | | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | | Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All, Reveal in Finder, and drag to reorder implemented; duplicate/move to window where supported remains open | **Inferred** exact list; P1 | -| Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | 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 | +| 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) | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Pasteboard row: the colour menu exists since #2462 | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/scorecard.md b/docs/scorecard.md index a84661205a..729e4fd11b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -148,7 +148,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | -| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | +| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | done | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs build_mesh: traced outline of the opaque region grown by Expansion, triangulated with a Density-spaced lattice (conforming Delaunay); separate shapes get separate meshes (#3105) | | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | done | [#214](https://github.com/storytold/photocraft/issues/214) | Edit > Content-Aware Scale opens a scale-only Free Transform box (cas_transform.rs) with Amount, Protect and Protect Skin Tones in the options bar; edit.contentAwareScale carves a 384 px copy in the background for the live preview, and Enter runs it with the box's size and x/y as one step (#3096) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 3232cdec18..65db639711 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -136,7 +136,7 @@ id = "TOOL-214-4" target = "Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab" status = "partial" issue = 214 -note = "Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open" +note = "Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open" [[item]] id = "TOOL-214-5" From 759b68c234309076dc46bdcf31824d4c5838e8b3 Mon Sep 17 00:00:00 2001 From: Stepan Pavlenko Date: Thu, 15 Oct 2026 22:35:50 +0300 Subject: [PATCH 0247/1452] i18n(ru): translate the dynamic labels the Russian catalog was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys) but lacked the ~1,233 runtime-only labels the other catalogs already carry, so they showed in English: filter and adjustment parameter names and choices, gallery names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…), History/Layers/paths/channels menu labels, metadata and File Info fields, System Info and hardware status lines, rasterize prompts, composed status templates and dialog text. All 1,233 rows are translated (ru rows 2,936 → 4,208), written from the meaning of the English labels and reusing the vocabulary the catalog already uses (Highlights → Света, Midtones → Полутона, Feather → Растушёвка, Pattern → узор). Filter and gallery names are aligned with the menu labels already in the catalog so a filter never reads two ways (Texturizer → Текстуризатор, Unsharp Mask → Нерезкое маскирование, Shape Blur → Размытие по фигуре, …). Tests: `russian_covers_generated_filter_options_and_gallery_names` (filter_dialog.rs, the Italian/Korean/Spanish check) and `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs). Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13, ROADMAP, ATTRIBUTION and LICENSE-translations. Validated: `cargo test -p photocraft-ui-egui --lib` 2,935 passed / 0 failed; clippy clean (the only lint is a pre-existing `nonminimal_bool` in canvas.rs, untouched here and present on main); `xtask layers` OK; `xtask wasm` OK; `xtask i18n-coverage` ru 1,886 / 1,886; offscreen `snapshot` in Russian checked. --- ATTRIBUTION.md | 1 + ROADMAP.md | 5 +- crates/ui-egui/src/filter_dialog.rs | 6 + .../ui-egui/src/i18n/LICENSE-translations.txt | 7 +- crates/ui-egui/src/i18n/mod.rs | 51 + crates/ui-egui/src/i18n/ru.tsv | 1239 +++++++++++++++++ docs/gaps.md | 11 +- docs/localization-parity.md | 9 +- docs/localization-ru.md | 75 + 9 files changed, 1392 insertions(+), 12 deletions(-) create mode 100644 docs/localization-ru.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 26eca62464..0c09a11300 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,7 @@ next to them. | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..ca3bd5cbb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). +Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). ## Upcoming @@ -136,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..87eca79b94 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1475,6 +1475,12 @@ mod tests { assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); } + #[test] + fn russian_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap()); + assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 93a609f946..0e9c354446 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, -at your option. +Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR +Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -37,3 +37,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Serbian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Russian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..3cff605b18 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1111,6 +1111,57 @@ mod tests { } } + /// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels, + /// hardware status, rasterize prompts, History/paths menus) render in Russian, not English. + #[test] + fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() { + let ru = Lang::from_code("ru").expect("ru registered"); + for kind in [ + crate::rasterize_prompt::Kind::Type, + crate::rasterize_prompt::Kind::Shape, + crate::rasterize_prompt::Kind::SmartObject, + crate::rasterize_prompt::Kind::Fill, + ] { + assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind); + } + assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0"); + assert_eq!( + fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), + "Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1" + ); + assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64"); + assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер"); + assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}"); + // Filter / adjustment parameter names. + for label in [ + "Amount", + "Radius", + "Threshold", + "Angle", + "High Pass", + "Smart Blur", + "Reduce Noise", + "Lens Correction", + "Colored Pencil", + "Angled Strokes", + "Stained Glass", + "Sumi-e", + "Film Grain", + "Unsharp Mask", + "Lens Flare", + ] { + assert!(has(ru, label), "Russian filter label missing: {label}"); + } + // Dynamic composed status templates. + assert_eq!(fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]), "Шаг 2 (file.export) не выполнен: ok"); + assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied"); + assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)"); + assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)"); + // Document and layer names stay verbatim. + let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]); + assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}"); + } + /// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus /// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as /// they are (names, units) are listed in `KEEP_AS_IS`. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..e3dbb3212d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3159,3 +3159,1242 @@ curves Smooth Сгладить set name имя набора {set} (modified) {set} (изменён) Random Случайно + + +# Dynamic UI labels (fourth pass): filter and adjustment parameters, Camera Raw panels, +# lighting, History/Layers/paths/channels menus, metadata, System Info, status templates and +# dialog text that other catalogs already carry. Written from the meaning of the English labels +# in the ordinary Russian graphics-editor vocabulary; no proprietary resource consulted. + (coming soon) (скоро) + (none) (нет) + 32-bit Preview Options Параметры предпросмотра 32 бит + 3D… 3D… + A: Ш: + AI Integrations Интеграции ИИ + About {app} О программе {app} + Accented Edges Акцентированные края + Action playback failed: {error} Не удалось воспроизвести операцию: {error} + Actual Pixels Реальный размер + Add Mask Добавить маску + Add Noise Добавить шум + Add a mask (from the selection; ⌥ inverts) Добавить маску (из выделенной области; ⌥ инвертирует) + Add space after paragraph Добавить интервал после абзаца + Add space before paragraph Добавить интервал перед абзацем + Add to selection (⇧) Добавить к выделенной области (⇧) + Added swatch {name} Добавлен образец {name} + Adds grain to the soft edges of the brush tip. Добавляет зерно к мягким краям кисти. + Adjust Interval For Loops Настроить интервал циклов + Adjustment Layers Корректирующие слои + Adjustment Properties Свойства коррекции + Adjustment layers have nothing to transform В корректирующих слоях нечего трансформировать + Adobe PDF Presets… Наборы Adobe PDF… + Adobe-rgb-compat Adobe RGB (совместимо) + Adwaita (Dark) Adwaita (тёмная) + Adwaita (Light) Adwaita (светлая) + After После + Airbrush: keeps painting while the pointer rests. Аэрограф: продолжает рисовать, пока указатель неподвижен. + All shortcuts reset to Photoshop defaults. Все сочетания клавиш сброшены к значениям Photoshop по умолчанию. + Altitude Высота + Ambience Атмосфера + Ambient Окружающий свет + Amplitude Max Макс. амплитуда + Amplitude Min Мин. амплитуда + Android 1080p Android 1080p + Angled Strokes Наклонные штрихи + Anisotropic Анизотропный + Any Любой + Apple Watch 45mm Apple Watch 45 мм + Apply Data Set Применить набор данных + Apply Image Внешний канал + Apply Mask Применить маску + Apply to All Применить ко всем + Apply {option}: {state} Применить {option}: {state} + Arbitrary Произвольный + Arc Lower Нижняя дуга + Arc Upper Верхняя дуга + Are you sure you want to quit? Вы действительно хотите выйти? + Are you sure? Вы уверены? + Around Center Вокруг центра + Arrangement Расположение + Arrows Стрелки + Artboard from Group Монтажная область из группы + Artboard from Layers Монтажная область из слоёв + Artboards Монтажные области + Artboards to Files Монтажные области в файлы + Artboards to PDF Монтажные области в PDF + Artistic Художественные + Ask On Mismatch Спрашивать при несовпадении + Ask On Missing Спрашивать при отсутствии + Ask On Paste Спрашивать при вставке + Assign Profile Назначить профиль + At Позиция + Author Title Имя автора + Auto Scale Автомасштаб + Auto-Align Layers Автовыравнивание слоёв + Auto-Blend Layers Автосмешивание слоёв + Autosave failed: {error} Не удалось выполнить автосохранение: {error} + Back Назад + Backend: {backend} ({type}) Сервер: {backend} ({type}) + Background Level Уровень фона + Bamboo Бамбук + Bas Relief Барельеф + Base Tree Type Базовый тип дерева + Based On На основе + Baseline shift Смещение базовой линии + Basic Latin Основная латиница + Batch Пакетная обработка + Before До + Bend Изгиб + Berries Ягоды + Best Наилучшее + Black Clip Отсечение чёрного + Black Intensity Интенсивность чёрного + Blade Curvature Кривизна лепестков диафрагмы + Blast Порыв + Bleach Bypass Пропуск отбеливания + Blend Clipped Layers as Group Смешивать обтравочные слои как группу + Blend Interior Effects as Group Смешивать внутренние эффекты как группу + Blend Text Gamma Гамма смешивания текста + Blending Mode Режим наложения + Bloat Вздутие + Blocks Блоки + Blue Yellow Синий–жёлтый + Blues Синие тона + Border Граница + Border Thickness Толщина границы + Box Blur Размытие по рамке + Bpc Компенсация чёрной точки + Branches Color Цвет ветвей + Branches Height Высота ветвей + Branches Thickness Толщина ветвей + Breeze Dark Breeze (тёмная) + Breeze Light Breeze (светлая) + Brick Кирпич + Bristle Detail Детализация щетины + Browse Обзор + Brush Detail Детализация кисти + Brush Settings… (F5): every option of this brush Параметры кисти… (F5): все параметры этой кисти + Brush Strokes Мазки кисти + Brush Type Тип кисти + Burlap Мешковина + Calculations Вычисления + Camera Raw Camera Raw + Camera Raw Filter Фильтр Camera Raw + Cancel transform Отменить трансформирование + Candle Light Свет свечи + Canvas Size Размер холста + Case Sensitive Учитывать регистр + Ccw Против часовой стрелки + Cell Size Размер ячейки + Center X Центр X + Center Y Центр Y + Chalk Area Область мела + Change Find Найти и заменить + Changes Saved Изменения сохранены + Changes have not been saved. Изменения не сохранены. + Change… Изменить… + Channel1 Канал 1 + Channel2 Канал 2 + Channel3 Канал 3 + Channel4 Канал 4 + Charcoal Area Область угля + Charcoal Thickness Толщина угля + Check for Updates Проверить обновления + Checkerboard Шахматная доска + Choke Поджатие + Choose Folder Выбрать папку + Circle Круг + Cleanliness Чистота + Clear Amount Величина очистки + Clear Selection Отменить выделение + Click sets foreground · right-click sets background Щелчок задаёт цвет переднего плана · щелчок правой кнопкой задаёт цвет фона + Click the first point or press ↵ to close · Esc cancels Щёлкните первую точку или нажмите ↵, чтобы замкнуть · Esc отменяет + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Щёлкните, чтобы добавить точку · перетащите за пределы, нажмите ⌘/Ctrl и щёлкните или нажмите Delete, чтобы удалить + Click to apply, ⌥-click to clear overrides, double-click for Style Options Щёлкните, чтобы применить, ⌥+щелчок, чтобы очистить переопределения, двойной щелчок — параметры стиля + Click to zoom in · ⌥-click to zoom out Щёлкните, чтобы увеличить · ⌥+щелчок, чтобы уменьшить + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Щелчок: угол · Перетаскивание: сглаживание · Щелчок по первой точке: замкнуть · ↩ завершить · Esc отменить + Clipping Mask Обтравочная маска + Clipping Path… Контур отсечения… + Clone Клонировать + Clone source set at {x}, {y} Источник клонирования задан в {x}, {y} + Close Window Закрыть окно + Clumped Сгруппированные + Cmyk CMYK + Coarse Dots Крупные точки + Coated-cmyk Мелованная CMYK + Collapse group (⌥-click: all groups) Свернуть группу (⌥+щелчок: все группы) + Color Decontaminate Удаление цветового ореола + Color Halftone Цветные полутона + Color Indicates Masked Areas Цветом обозначены маскированные области + Color Indicates Selected Areas Цветом обозначены выделенные области + Color Lookup Tables Таблицы перекодировки цвета + Color Picker Палитра цветов + Color Picker (Background Color) Палитра цветов (цвет фона) + Color Picker (Custom Canvas Color) Палитра цветов (свой цвет холста) + Color Picker (Foreground Color) Палитра цветов (цвет переднего плана) + Color Sampler Tool Инструмент «Цветовой эталон» + Color Settings Настройки цвета + Color Swatches Образцы цветов + Color Table Цветовая таблица + Color management: {error} Управление цветом: {error} + Colored Pencil Цветной карандаш + Comic Book Комикс + Command Команда + Commit Perspective Warp (↩) Применить перспективную деформацию (↩) + Commit Puppet Warp (↩) Применить марионеточную деформацию (↩) + Commit any current edits (⌘↩) Применить текущие изменения (⌘↩) + Commit current crop operation (↵) Применить текущую операцию кадрирования (↵) + Commit transform Применить трансформирование + Commit transform (↩) Применить трансформирование (↩) + Commit warp (↩) Применить деформацию (↩) + Compare Сравнить + Compression Сжатие + Conditional Mode Change Условная смена режима + Confirm Подтвердить + Conform Подогнать + Constraints Ограничения + Content-Aware Fill needs a selection on a pixel layer Заливка с учётом содержимого требует выделенной области на пиксельном слое + Continue Продолжить + Contract Сжать + Contrasty Контрастный + Convert Преобразовать + Convert to Alpha Channel Преобразовать в альфа-канал + Convert to Linked Преобразовать в связанный + Convert to Profile Преобразовать в профиль + Cool Холодный + Cooling L B B Охлаждающий (LBB) + Cooling80 Охлаждающий (80) + Cooling82 Охлаждающий (82) + Copy Complete Stroke Копировать полный контур + Copy Fill Копировать заливку + Copyright Url URL авторских прав + Correct C A Коррекция хроматической аберрации + Correct Distortion Коррекция дисторсии + Correct Vignette Коррекция виньетирования + Could not rename the action: {error} Не удалось переименовать операцию: {error} + Could not transform: the layer is empty Не удалось трансформировать: слой пуст + Could not use Perspective Warp: the layer is empty Не удалось применить перспективную деформацию: слой пуст + Could not use Puppet Warp: no opaque pixels Не удалось применить марионеточную деформацию: нет непрозрачных пикселей + Could not use Puppet Warp: the layer is empty Не удалось применить марионеточную деформацию: слой пуст + Couldn't open {name}: {error} Не удалось открыть {name}: {error} + Couldn't save preferences: {error}. Retrying… Не удалось сохранить настройки: {error}. Повтор… + Crack Brightness Яркость трещин + Crack Depth Глубина трещин + Crack Spacing Интервал трещин + Craquelure Кракелюр + Create By Создать с помощью + Create Vector Mask Создать векторную маску + Create Warped Text Создать деформированный текст + Create a new layer (⇧⌘N) Создать новый слой (⇧⌘N) + Create new brush from the current settings Создать новую кисть из текущих параметров + Create new brush preset from the current settings Создать новый набор кистей из текущих параметров + Create… Создать… + Cross Крест + Cross Process Кросс-процесс + Crosshatch Перекрёстная штриховка + Crosswise Перекрёстно + Crystallize Кристаллизация + Currency Валюта + Curve Darks Кривая: тёмные тона + Curve Highlights Кривая: светлые тона + Curve Lights Кривая: светлые тона + Curve Shadows Кривая: тени + Curve Splits Кривая: разрывы + Custom Branch Color Свой цвет ветвей + Custom Pixel Aspect Ratio Своё пиксельное соотношение + Custom pattern Свой узор + Cutout Аппликация + Cw По часовой стрелке + Cyans Голубые тона + Cyrillic Кириллица + Daisies Маргаритки + Dark Gray Тёмно-серый + Dark Intensity Интенсивность тёмного + Dark Rough Тёмный шероховатый + Dark Strokes Тёмные штрихи + Darken Only Только затемнение + Darkness Затемнение + Data Данные + Data Set: {name} ({index}/{total}) Набор данных: {name} ({index}/{total}) + Data Sets as Files Наборы данных в файлы + Date Дата + Day For Night День как ночь + De-Interlace Устранение чересстрочности + Decrease Brush Hardness Уменьшить жёсткость кисти + Decrease Brush Size Уменьшить размер кисти + Decrease Type Size Уменьшить размер текста + Decrease Type Size by 10 Уменьшить размер текста на 10 + Deep Глубокий + Deep Blue Глубокий синий + Deep Emerald Глубокий изумрудный + Deep Red Глубокий красный + Deep Yellow Глубокий жёлтый + Default Foreground and Background Colors Цвета переднего плана и фона по умолчанию + Default Leaves Листья по умолчанию + Default Photoshop Size Размер Photoshop по умолчанию + Defaults По умолчанию + Define Brush Preset Определить кисть + Define Custom Shape Определить свою фигуру + Define Pattern Определить паттерн + Definition Чёткость + Defringe Убрать кайму + Degrees Градусы + Delete Channel Удалить канал + Delete Mask Удалить маску + Delete Workspace Удалить рабочую среду + Delete brush Удалить кисть + Delete path Удалить контур + Delete vector mask Удалить векторную маску + Delete… Удалить… + Depth Map Карта глубины + Depth Mode Режим глубины + Diameter Диаметр + Diamond Ромб + Diffuse Диффузия + Diffuse Glow Рассеянное свечение + Direction Направление + Direction Balance Баланс направлений + Disable Выключить + Disable Mask Отключить маску + Disable Symmetry Path Отключить контур симметрии + Disabled Отключено + Discard Changes Отменить изменения + Displace Смещение + Display Дисплей + Display profile not used Профиль дисплея не используется + Display profiles: {reason} Профили дисплеев: {reason} + Display-p3 Display P3 + Distance Расстояние + Distortion Дисторсия + Distribution Распределение + Do you want to save you made to “{name}” before quitting? Сохранить изменения в «{name}» перед выходом? + Document Sizes Размеры документа + Documentation Документация + Documents Документы + Dont Enlarge Не увеличивать + Dot Точка + Double Line Двойная линия + Down Вниз + Draft Черновик + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Перетащите рамку кадрирования · перетаскивание внутри перемещает · края изменяют размер (соотношение {ratio}, центр {centre}) · пробел перемещает при рисовании · {commit} применяет · Esc отменяет + Drag: draw freehand · Release on the start point: close Перетаскивание: рисование от руки · Отпустите на начальной точке: замкнуть + Driver: {driver} Драйвер: {driver} + Dry Brush Сухая кисть + Duplicate Channel Дублировать канал + Duplicate Layer Дублировать слой + Duplicate Layers Дублировать слои + Duplication Дублирование + Dust & Scratches Пыль и царапины + Edge Край + Edge Brightness Яркость краёв + Edge Extension Расширение краёв + Edge Fidelity Точность краёв + Edge Intensity Интенсивность краёв + Edge Only Только края + Edge Simplicity Простота краёв + Edge Thickness Толщина краёв + Edge Width Ширина краёв + Edit Toolbar Настроить панель инструментов + Edit… Изменить… + Elevation Возвышение + Eliminate Устранить + Emboss Тиснение + Enable Включить + Enable Mask Включить маску + End X Конец X + End Y Конец Y + Enlarged Увеличенный + Erase with Brush or Pencil (hold) Стирание кистью или карандашом (удерживайте) + Error Ошибка + Even Fields Чётные поля + Exit Quick Mask Выйти из быстрой маски + Expand group (⌥-click: all groups) Развернуть группу (⌥+щелчок: все группы) + Export As Экспортировать как + Export As: {name} Экспортировать как: {name} + Export Contents Экспортировать содержимое + Export Image Экспортировать изображение + Export Options Параметры экспорта + Export Preferences Экспорт настроек + Export Settings Экспортировать настройки + Export to Экспортировать в + Export/Import Presets Экспорт/импорт наборов + Exported contents to {path} Содержимое экспортировано в {path} + Exported presets to {path} Наборы экспортированы в {path} + Exported {path} Экспортировано: {path} + Exported {path} ({size}) Экспортировано: {path} ({size}) + Exporting… Экспорт… + ExtraBold Сверхжирный + ExtraLight Сверхтонкий + Extrude Экструзия + Fade Ослабить + Faded Film Выцветшая плёнка + Failed Не удалось + Failed to open file Не удалось открыть файл + Failed to save file Не удалось сохранить файл + Fallback: {reason} Резерв: {reason} + Feather Radius Радиус растушёвки + Fiber Length Длина волокон + Fibers Волокна + Field Blur Размытие по полю + File Format Формат файла + File Info Сведения о файле + File Name Имя файла + File Size Размер файла + File not found Файл не найден + Filename Имя файла + Fill Color Цвет заливки + Fill Opacity Непрозрачность заливки + Fill Path… Залить контур… + Fill path with foreground color Залить контур цветом переднего плана + Fill with Background Color Залить цветом фона + Fill with Background Color, Preserve Transparency Залить цветом фона, сохранить прозрачность + Fill with Foreground Color Залить цветом переднего плана + Fill with Foreground Color, Preserve Transparency Залить цветом переднего плана, сохранить прозрачность + Film Grain Зернистость плёнки + Filter Gallery Галерея фильтров + Find Найти + Find and Replace Text Найти и заменить текст + Fine Тонкий + Fine Dots Мелкие точки + Finish Готово + Fit Image Подогнать изображение + Fit to Window По размеру окна + Flame Пламя + Flame Bottom Alignment Выравнивание низа пламени + Flame Lines Линии пламени + Flame Shape Форма пламени + Flame Style Стиль пламени + Flame Type Тип пламени + Flat Плоский + Flat Shading Плоское затенение + Flower Color Цвет цветков + Focal Distance Дистанция фокуса + Focus Фокус + Focus Area Область фокусировки + Font size Размер шрифта + Foreground Level Уровень переднего плана + Forward Вперёд + Forward Warp Прямая деформация + Frame Кадр + Frame {current} / {total} Кадр {current} / {total} + Free Transform Path Свободное трансформирование контура + Freeze Lasso Заморозить лассо + Freeze Mask Заморозить маску + Fresco Фреска + From От + From Left Слева + From Right Справа + Frosted Матовое + Full Screen Во весь экран + Fullscreen Полноэкранный + Fuzziness Разброс + GPU backend preference: {backend} Предпочитаемый сервер GPU: {backend} + GPU device was lost; using the CPU renderer. Устройство GPU потеряно; используется отрисовка на CPU. + GPU error; using the CPU renderer. Ошибка GPU; используется отрисовка на CPU. + GPU retry scheduled Запланирована повторная попытка GPU + Gamma Гамма + Gaussian Blur Размытие по Гауссу + General Blending Общее наложение + General Brushes Общие кисти + Generators Генераторы + Generic Общий + Glass Стекло + Global Light Глобальный свет + Gloss Глянец + Glow Amount Величина свечения + Glow Brightness Яркость свечения + Glow Size Размер свечения + Glowing Edges Светящиеся контуры + Good Хорошее + Grade Balance Цветокоррекция: баланс + Grade Blending Цветокоррекция: смешивание + Grade Global Цветокоррекция: общая + Grade Highlights Цветокоррекция: светлые тона + Grade Midtones Цветокоррекция: средние тона + Grade Shadows Цветокоррекция: тени + Gradient Options Параметры градиента + Grain Amount Величина зерна + Grain Roughness Шероховатость зерна + Grain Size Размер зерна + Grain Type Тип зерна + Graininess Зернистость + Grainy Dots Зернистые точки + Graphics adapter Графический адаптер + Graphics adapter: {adapter} Графический адаптер: {adapter} + Gray-gamma-2.2 Серый (гамма 2,2) + Greek Греческий + Greens Зелёные тона + Grid (⌘') Сетка (⌘') + Grid view Вид сеткой + Group from Layers Группа из слоёв + Groups Группы + Grout Width Ширина швов + Guide Layout… Схема направляющих… + HDR Toning HDR-тонирование + Hand Tool (hold) Инструмент «Рука» (удерживайте) + Hearts Сердца + Heptagon Семиугольник + Hexagon Шестиугольник + Hidden Скрыто + Hide Grid Скрыть сетку + Hide Others Скрыть остальные + Hide the layer's effects (⌥-click: all layers) Скрыть эффекты слоя (⌥+щелчок: все слои) + Hide {app} Скрыть {app} + High Pass Цветовой контраст + Highlight Света + Highlight Amount Величина светов + Highlight Area Область светов + Highlight Radius Радиус светов + Highlight Strength Сила светов + Highlight Tone Тон светов + Highlights from: Света от: + Horizontal Distortion Горизонтальная дисторсия + Horizontal Only Только по горизонтали + Horizontal scale Масштаб по горизонтали + Horizontal type Горизонтальный текст + Hotspot Горячая точка + Hsb HSB + Hsl HSL + Hsl Hue Оттенок HSL + Hsl Lum Яркость HSL + Hsl Sat Насыщенность HSL + Image Balance Баланс изображения + Image Processor Обработчик изображений + Image compositor: {processor} Компоновщик изображения: {processor} + Import Brushes Импортировать кисти + Import Brushes… (.abr) Импортировать кисти… (.abr) + Import Image Импортировать изображение + Import Options Параметры импорта + Import Photoshop brushes (.abr) Импортировать кисти Photoshop (.abr) + Import Settings Импортировать настройки + Importing {what} from {file}… Импорт {what} из {file}… + Importing… Импорт… + In Black Входной чёрный + In White Входной белый + Increase Brush Hardness Увеличить жёсткость кисти + Increase Brush Size Увеличить размер кисти + Increase Type Size Увеличить размер текста + Increase Type Size by 10 Увеличить размер текста на 10 + Indent first line Отступ первой строки + Indent left margin Отступ слева + Indent right margin Отступ справа + Indexed Индексированный + Infinite Бесконечный + Information Информация + Initial Direction Начальное направление + Ink Outlines Обводка контура + Inner Bevel Внутренняя фаска + Input Вход + Input Levels Входные уровни + Input Mode Режим ввода + Install Plug-in Установить плагин + Intensity Интенсивность + Intent Метод преобразования + Interpret Footage Интерпретировать материал + Intersect with selection (⇧⌥) Пересечение с выделенной областью (⇧⌥) + Interval Интервал + Invalid panel layout; restored the default dock Недопустимый макет панелей; восстановлена пристыковка по умолчанию + Invert Mask Инвертировать маску + Invert Selection Инвертировать выделенную область + Iris Blur Диафрагменное размытие + Ivy Плющ + Jag Зубчатость + Join the ArtCraft Discord Discord сообщества ArtCraft + Join the ArtCraft Discord ({url}) Присоединиться к Discord ArtCraft ({url}) + Keeps the current texture when switching presets. Сохраняет текущую текстуру при смене набора. + Kernel Ядро + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Кернинг: метрический, оптический или значение в 1/1000 em (Alt+←/→ у курсора) + Keyboard Клавиатура + Lab Lab + Landscape, 10 x 8 Альбомная, 10 x 8 + Landscape, 6 x 4 Альбомная, 6 x 4 + Landscape, 7 x 5 Альбомная, 7 x 5 + Latin 1 Латиница 1 + Latin Extended-A Латиница расширенная A + Latin Extended-B Латиница расширенная B + Layer Comps to Files Варианты слоя в файлы + Layer Content Options Параметры содержимого слоя + Layer Count Число слоёв + Layer From Background Слой из фона + Layer Knocks Out Drop Shadow Слой выбивает тень + Layer Mask Hides Effects Маска слоя скрывает эффекты + Layer Properties Свойства слоя + Layer Styles Стили слоя + Layer from Background Слой из фона + Layers to Files Слои в файлы + Layout Макет + Leading (set to the font size × auto-leading when Auto) Интерлиньяж (при значении «Авто» равен размеру шрифта × автоматический интерлиньяж) + Leaf Color Цвет листьев + Learn More Подробнее + Leaves Amount Количество листьев + Leaves Color Цвет листьев + Leaves Size Размер листьев + Left Diagonal Левая диагональ + Legacy Устаревший + Legal Legal + Lens Blur Размытие при малой глубине + Lens Correction Коррекция дисторсии + Lens Flare Блик от объектива + Less Меньше + Letter Letter + Level Based На основе уровней + Library Библиотека + Light Dark Balance Баланс светлого и тёмного + Light Direction Направление света + Light Intensity Интенсивность света + Light Rough Светлый шероховатый + Light Type Тип света + Light X Свет X + Light Y Свет Y + Light Z Свет Z + Lighten Grout Осветлить швы + Lighten Only Только осветление + Lighting Освещение + Lighting Effects Эффекты освещения + Linear Dodge Линейный осветлитель + Linear Height Линейная высота + Linear-srgb Линейный sRGB + Link Связать + Liquify Пластичность + List Список + List view Вид списком + Live Preview Живой просмотр + Load 3D LUT Загрузить 3D LUT + Load Files into Stack Загрузить файлы в стопку + Load path as a selection Загрузить контур как выделенную область + Loading… Загрузка… + Lock All Заблокировать всё + Lock Image Pixels Заблокировать пиксели изображения + Lock Layers Заблокировать слои + Lock Position Закрепить положение + Lock Transparent Pixels Заблокировать прозрачные пиксели + Locked Заблокировано + Long Lines Длинные линии + Long Strokes Длинные штрихи + Lorem Ipsum Lorem Ipsum + Lower Нижний + Lum Ярк. + Lut LUT + Magentas Пурпурные тона + Make Selection from Path Создать выделенную область из контура + Make Selection… Создать выделенную область… + Make Symmetry Path Создать контур симметрии + Make Work Path… Создать рабочий контур… + Make work path from selection Создать рабочий контур из выделенной области + Manual Вручную + Map Document Документ карты + Map Layer Слой карты + Map Path Путь к карте + Mask Channel Канал-маска + Mask Incomplete Blocks Маскировать неполные блоки + Mask None Без маски + Masks Маски + Match Color Подбор цвета + Math Symbols Математические символы + Max Offset Макс. смещение + Max Radius Макс. радиус + Measurement Scale Масштаб измерений + Measurement Scale Marker Маркер масштаба измерений + Medium Dots Средние точки + Medium Gray Средне-серый + Medium Lines Средние линии + Medium Strokes Средние штрихи + Merge Channels… Объединить каналы… + Merge Spot Channel Объединить цветовой канал + Merge to HDR Pro Объединить в HDR Pro + Metallic Металлический + Metrics Метрики + Mezzotint Меццотинто + Middle Середина + Midtone Полутон + Monitor Profile Профиль монитора + Monitor profile in use on {display}: {profile} Используемый профиль монитора на {display}: {profile} + Monitor profile in use: {profile} Используемый профиль монитора: {profile} + Monitor profile on {display} (main window): {profile} Профиль монитора для {display} (главное окно): {profile} + Monitor profile on {display}: {profile} Профиль монитора для {display}: {profile} + Monitor profile: {profile} Профиль монитора: {profile} + Mono Contrast Монохромный контраст + Monochromatic Монохромный + More Больше + Mosaic Мозаика + Mosaic Tiles Мозаичные плитки + Motion Blur Размытие в движении + Mouse Мышь + Move Layer Переместить слой + Movie Prime Киносъёмочный фикс + Multiple Flames Along Path Несколько языков пламени вдоль контура + Multiple Flames One Direction Несколько языков пламени в одном направлении + Multiple Flames Path Directions Несколько языков пламени по направлениям контура + Multiple Flames Various Length Несколько языков пламени разной длины + My Brushes Мои кисти + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Перетаскивание файлов ещё не поддерживается в Wayland. Используйте «Файл › Открыть» или запустите PhotoCraft под XWayland с `WAYLAND_DISPLAY= photocraft`. + Nearest Ближайший + Neon Glow Неоновое свечение + Neutralize Нейтрализовать + Neutrals Нейтральные тона + New Brush Preset Новый набор параметров кисти + New Channel… Новый канал… + New Guide Новая направляющая + New Guide Layout Схема направляющих + New Spot Channel… Новый цветовой канал… + New Video Layer from File Новый видеослой из файла + New Window Новое окно + New Workspace Новая рабочая среда + New document Новый документ + New document… Новый документ… + New document… ⌘N Новый документ… ⌘N + Next Далее + No Document Нет документа + No Image Нет изображения + No Layers Нет слоёв + No Results Нет результатов + No Results Found Ничего не найдено + No Selection Нет выделенной области + No custom shape "{name}": pick one in the options bar or Window › Shapes Нет произвольной фигуры «{name}»: выберите её на панели параметров или в меню «Окно › Фигуры» + No layers Нет слоёв + No recent files Нет недавних файлов + No selection Нет выделенной области + Noise Color Цветовой шум + Noise Color Detail Цветовая детализация шума + Noise Luminance Яркостный шум + Noise Luminance Detail Яркостная детализация шума + Nothing to Redo Нечего повторить + Nothing to Undo Нечего отменить + Nothing to paste: the clipboard holds no image Нечего вставлять: в буфере обмена нет изображения + Number Of Levels Число уровней + Numbers Числа + Ocean Ripple Океанские волны + Octagon Восьмиугольник + Odd Fields Нечётные поля + Oil Paint Масляная живопись + On Вкл. + One Flame Along Path Один язык пламени вдоль контура + Open As Открыть как + Open File Открыть файл + Open Folder Открыть папку + Open Image Открыть изображение + Open Recent is unavailable on the web «Открыть недавние» недоступно в веб-версии + Opened {url} Открыто: {url} + Opening Открытие + Open… ⌘O Открыть… ⌘O + Operation canceled Операция отменена + Operation cancelled Операция отменена + Operation completed Операция завершена + Operation failed Операция не выполнена + Optical Оптический + Original Ratio Исходное соотношение + Out Black Выходной чёрный + Out From Center От центра наружу + Out Of Gamut Вне охвата + Out White Выходной белый + Out of memory Недостаточно памяти + Outer Bevel Внешняя фаска + Output Levels Выходные уровни + Oval Овал + Overlay Edge Наложение края + Overlay: Rule of Thirds Наложение: правило третей + PSD PSD + Package Упаковать + Page Setup… Параметры страницы… + Paint Daubs Мазки краской + Paints from the document's opening state Рисует по состоянию документа на момент открытия + Palette Knife Шпатель + Panel Панель + Paper Бумага + Paper Brightness Яркость бумаги + Parallel Параллельно + Paste Complete Stroke Вставить полный контур + Paste Fill Вставить заливку + Patchwork Лоскутное шитьё + Path Blur Размытие по контуру + Paths to Illustrator Контуры в Illustrator + Pattern Fill… Заливка узором… + Pattern Type Тип узора + Pencil Width Ширина карандаша + Pentagon Пятиугольник + Percentage Процент + Permission denied Доступ запрещён + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft продолжает попытки; пока сохранение не удастся, изменения настроек будут потеряны при закрытии. + Photomerge Фотослияние + Pick Color From Image Выбрать цвет на изображении + Picture Frame Рамка + Pillow Emboss Подушковидное тиснение + Pinch Сжатие + Pins Булавки + Place Embedded Поместить встроенный + Place Linked Поместить связанный + Place Scale Marker Шкала масштаба + Plane Плоскость + Planes Плоскости + Plastic Wrap Пластиковая упаковка + Platform: {os} {arch} Платформа: {os} {arch} + Please wait… Подождите… + Point Точка + Point Curve Точечная кривая + Point Curve Blue Точечная кривая: синяя + Point Curve Green Точечная кривая: зелёная + Point Curve Red Точечная кривая: красная + Pointed Заострённый + Pointillize Пуантилизм + Polar Coordinates Полярные координаты + Polar To Rectangular Полярные в прямоугольные + Policy Cmyk Правила для CMYK + Policy Gray Правила для градаций серого + Policy Rgb Правила для RGB + Pond Ripples Волны на пруду + Portrait, 4 x 6 Книжная, 4 x 6 + Portrait, 5 x 7 Книжная, 5 x 7 + Postcard Открытка + Poster Плакат + Poster Edges Плакатные края + Posterization Постеризация + Preferences can't be saved Не удаётся сохранить настройки + Preferences saved Настройки сохранены + Preferences were reset: {error} Настройки сброшены: {error} + Preferences… Настройки… + Preserve Сохранить + Preset Manager Управление наборами + Press keys Нажмите клавиши + Preview Image Просмотр изображения + Previous Назад + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Напечатано в {path} + Pro (Dark) Pro (тёмная) + Pro (Medium Gray) Pro (средне-серая) + Processing… Обработка… + Profile Профиль + Proof Setup… Настройка пробного отпечатка… + Prophoto-compat ProPhoto RGB (совместимо) + Pucker Сморщивание + Push Left Сдвиг влево + Pyramids Пирамиды + Quick Actions Быстрые действия + Quit Выход + Quit PhotoCraft Выйти из PhotoCraft + Quit {app} Выйти из {app} + R G B R G B + R g b R g b + Radial Радиальный + Radial Blur Радиальное размытие + Radius X Радиус X + Radius Y Радиус Y + Ran script {name} Выполнен сценарий {name} + Randomize Length Случайная длина + Randomize Shapes Случайные фигуры + Rasterize Fill Content Растрировать содержимое заливки + Rasterize Smart Object Растрировать смарт-объект + Ratio Соотношение + Raw Defaults Значения RAW по умолчанию + Reading Чтение + Ready Готово + Rec2020 Rec. 2020 + Recent Files Недавние файлы + Reconstruct All Восстановить всё + Recovered {name} Восстановлено: {name} + Rectangular Selection Прямоугольная выделенная область + Rectangular To Polar Прямоугольные в полярные + Red Cyan Красный–голубой + Reds Красные тона + Reduce Color Noise Уменьшить цветовой шум + Reduce Noise Уменьшить шум + Reflected Отражение + Relief Рельеф + Relink to File Повторно связать с файлом + Remove All Удалить всё + Remove Jpeg Artifact Удалить артефакты JPEG + Rename Brush Переименовать кисть + Rename Channel… Переименовать канал… + Rename Group Переименовать группу + Rename Tool Preset Переименовать набор + Render Video Экспорт видео + Rendering… Отрисовка… + Reorder Layer Изменить порядок слоя + Replace Заменить + Replace Color Замена цвета + Replace Contents Заменить содержимое + Replace Footage Заменить материал + Report a Bug Сообщить об ошибке + Report an Issue Сообщить о проблеме + Reset Alignment Сбросить выравнивание + Reset All Сбросить всё + Reset Preferences Сбросить настройки + Resolve Missing Fonts Устранить отсутствующие шрифты + Restart Перезапустить + Restart Required Требуется перезапуск + Resume Продолжить + Retry Повторить + Rgb RGB + Ridges Гребни + Right Diagonal Правая диагональ + Ripple Рябь + Ripple Magnitude Величина ряби + Ripple Size Размер ряби + Rope Верёвка + Roses Розы + Rough Pastels Пастель + Row Order Порядок строк + Rulers (⌘R) Линейки (⌘R) + Sample Size Размер образца + Sampled Colors Отобранные цвета + Sandstone Песчаник + Sat Насыщ. + Save As Сохранить как + Save Changes Сохранить изменения + Save File Сохранить файл + Save Image Сохранить изображение + Save Path Сохранить контур + Save Pixel Aspect Ratio Сохранить соотношение сторон пикселя + Save a Copy Сохранить копию + Save before closing? Сохранить перед закрытием? + Save for Web (Legacy) Сохранить для Web (устаревшее) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Сохраните работу и перезапустите PhotoCraft, чтобы повторить попытку ускорения GPU. Отрисовка на CPU остаётся активной в этом сеансе. + Saved a copy as {path} Копия сохранена как {path} + Saved {path}; image assets: {n} in {dir} Сохранено: {path}; графические ресурсы: {n} в {dir} + Saving… Сохранение… + Scale Effects Масштабировать эффекты + Scaling Масштабирование + Scallops Фестоны + Script Events Manager Диспетчер событий сценариев + Scroll menu down Прокрутить меню вниз + Scroll menu up Прокрутить меню вверх + Search commands Поиск команд + Search commands (⌘K) Поиск команд (⌘K) + Search commands, tools and panels Поиск команд, инструментов и панелей + Search menus Поиск по меню + Seed Зерно + Select Custom Color… Выбрать свой цвет… + Select Data Points Выбрать точки данных + Select Layer Выбрать слой + Select and Mask Выделение и маска + Selected at launch: {backend} Выбрано при запуске: {backend} + SemiBold Полужирный + Sensitivity Чувствительность + Sent to the printer ({spooler}) Отправлено на принтер ({spooler}) + Sepia Сепия + Services Службы + Set Colors Задать цвета + Set Measurement Scale Задать масштаб измерений + Sgray sGray + Shadow Тень + Shadow Amount Величина теней + Shadow Intensity Интенсивность теней + Shadow Radius Радиус теней + Shadow Tone Тон теней + Shadows up to: Тени до: + Shadows/Highlights Тени/Света + Shallow Поверхностный + Shape Blur Размытие по фигуре + Sharpen Amount Величина резкости + Sharpen Detail Детализация резкости + Sharpen Details Резкость деталей + Sharpen Masking Маскирование резкости + Sharpen Radius Радиус резкости + Shear Искривление + Shine Блеск + Short Lines Короткие линии + Short Strokes Короткие штрихи + Shortcut Сочетание клавиш + Shortcuts Сочетания клавиш + Show All Показать все + Show Extras Options Параметры вспомогательных элементов + Show Guides Показать направляющие + Show Guides (⌘;) Показать направляющие (⌘;) + Show Rulers Показать линейки + Show the layer's effects (⌥-click: all layers) Показать эффекты слоя (⌥+щелчок: все слои) + Simple Простой + Simulate Paper Имитировать бумагу + Sine Синусоида + Sketch Эскиз + Skip Пропустить + Slice Options Параметры фрагмента + Smart Blur Умное размытие + Smart Sharpen Умная резкость + Smoothness Сглаженность + Smudge Stick Растушёвка + Snap to Grid Привязать к сетке + Snowflakes Снежинки + Soften Смягчить + Softness Мягкость + Software adapter Программный адаптер + Solarized Dark Solarized (тёмная) + Solid Color Однотонный цвет + Solid Front Faces Сплошные передние грани + Source Channel Канал-источник + Source Invert Инвертировать источник + Source Layer Исходный слой + Source1 Channel Канал источника 1 + Source1 Invert Инвертировать источник 1 + Source2 Channel Канал источника 2 + Source2 Invert Инвертировать источник 2 + Source: set Источник: задан + Space Пробел + Sparkle Искры + Spatter Разбрызгивание + Speckle Крапинки + Speed Скорость + Spherize Сферизация + Spin Вращение + Spin Blur Размытие вращением + Split Channels Разделить каналы + Spot Точечный + Spray Radius Радиус распыления + Sprayed Strokes Разбрызганные штрихи + Spread Разброс + Sprinkles Вкрапления + Square Size Размер квадрата + Square, 12 x 12 Квадратная, 12 x 12 + Square, 5 x 5 Квадратная, 5 x 5 + Squareness Квадратность + Stagger Смещение + Stained Glass Витраж + Star Звезда + Stars Звёзды + Start X Начало X + Start Y Начало Y + Statistics Статистика + Step {step} ({id}) failed: {error} Шаг {step} ({id}) не выполнен: {error} + Stippled Крапчатый + Stops Контрольные точки + Strength Сила + Stretch Растянуть + Stroke Color Цвет обводки + Stroke Detail Детализация обводки + Stroke Direction Направление обводки + Stroke Length Длина обводки + Stroke Pressure Нажим обводки + Stroke Size Размер обводки + Stroke Width Ширина обводки + Stroke path with brush Обвести контур кистью + Strokes Штрихи + Studio (Dark) Studio (тёмная) + Studio (Light) Studio (светлая) + Stylization Стилизация + Subtract Shapes at Overlap Вычесть фигуры при перекрытии + Subtract from selection (⌥) Вычесть из выделенной области (⌥) + Sumi-e Суми-э + Surface Blur Размытие по поверхности + Switch Foreground and Background Colors Поменять цвета переднего плана и фона + System Info Информация о системе + Tablet Планшет + Tabloid Tabloid + Taper Сужение + Target Цель + Target Channel 3 Целевой канал 3 + Target Channel 4 Целевой канал 4 + Target Channel 5 Целевой канал 5 + Target Channel 6 Целевой канал 6 + Target Channel 7 Целевой канал 7 + Target Channel 8 Целевой канал 8 + Target Channel 9 Целевой канал 9 + Target Composite Channel Целевой составной канал + Target X Цель X + Target Y Цель Y + Teal Orange Бирюзовый–оранжевый + Technology Previews… Пробные технологии… + Temporary Временный + Text Layer Текстовый слой + Texture Coverage Покрытие текстуры + Texturizer Текстуризатор + Thaw Mask Разморозить маску + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст на {display} отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The current gradient (pick one in Window › Gradients) Текущий градиент (выберите в меню «Окно › Градиенты») + The document does not have an embedded colour profile. Working space: {working}. Документ не содержит встроенного цветового профиля. Рабочее пространство: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Документ содержит встроенный цветовой профиль, не совпадающий с рабочим пространством.\nВстроенный: {embedded}\nРабочее пространство: {working} + The file could not be opened. Не удалось открыть файл. + The file could not be saved. Не удалось сохранить файл. + Thickness Толщина + This Layer: Этот слой: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? + This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. + This session: {reason} Этот сеанс: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Перед продолжением необходимо растрировать этот слой фигуры. Он перестанет быть редактируемым как фигура. Растрировать фигуру? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Перед продолжением необходимо растрировать этот текстовый слой. Его текст перестанет быть редактируемым. Растрировать текст? + Tile Size Размер плитки + Tiles Плитки + Tilt-Shift Наклон-сдвиг + Tint Color Цвет тонирования + Tiny Lens Маленькая линза + Title Заголовок + To До + To Center К центру + Toggle Layer Mask Overlay Переключить наложение маски слоя + Toggle the Brush Settings panel Переключить панель «Параметры кисти» + Toggle the Brush Settings panel (F5) Переключить панель «Параметры кисти» (F5) + Tool Options Параметры инструмента + Trace Contour Обводка контуров + Tracking (1/1000 em) Трекинг (1/1000 em) + Transformation Трансформирование + Transition Переход + Transparency Shapes Layer Прозрачность задаёт форму слоя + Trap Приводка + Tree Дерево + Trim Обрезать + Turbulent Турбулентность + Twirl Скручивание + Twirl Clockwise Скручивание по часовой стрелке + Twirl Counterclockwise Скручивание против часовой стрелки + Type Options Параметры текста + Unaltered Без изменений + Undefined Areas Неопределённые области + Underlying Layer: Нижележащий слой: + Underpainting Подмалёвок + Underwater Под водой + Ungroup Разгруппировать + Uniform Однородный + Unite Shapes at Overlap Объединить фигуры при перекрытии + Unlink Разъединить + Unlock Разблокировать + Unlocked Разблокировано + Unsaved Changes Несохранённые изменения + Unsharp Mask Нерезкое маскирование + Unsupported file format Неподдерживаемый формат файла + Unsupported format Неподдерживаемый формат + Up Вверх + Update Обновить + Updates Обновления + Upper Верхний + Use Custom Color Использовать свой цвет + Use File › Open to open an image. Используйте «Файл › Открыть», чтобы открыть изображение. + Use Selection In Source Использовать выделение в источнике + Use Selection In Target Использовать выделение в цели + Uses the foreground and background colors. Использует цвета переднего плана и фона. + Vanishing Point Точка схода + Variable Data Sets Наборы данных переменных + Vector Mask Hides Effects Векторная маска скрывает эффекты + Vertical Distortion Вертикальная дисторсия + Vertical Only Только по вертикали + Vertical Type Tool Инструмент «Вертикальный текст» + Vertical scale Масштаб по вертикали + Vertical type Вертикальный текст + Video Frames to Layers Видеокадры в слои + Vignette Amount Величина виньетки + Vignette Feather Растушёвка виньетки + Vignette Highlights Светлые тона виньетки + Vignette Midpoint Средняя точка виньетки + Vignette Roundness Округлость виньетки + Vignette Style Стиль виньетки + Vine Color Цвет лозы + Vine With Flowers Лоза с цветами + Vine With Leaves Лоза с листьями + Violent Сильный + W: Ш: + WIA Support Поддержка WIA + Warm Тёплый + Warming Filter (85) Согревающий фильтр (85) + Warming L B A Согревающий (LBA) + Warming81 Согревающий (81) + Warming85 Согревающий (85) + Warmth Теплота + Warning Предупреждение + Warp Text Деформация текста + Watercolor Акварель + Watercolour look: lighter interior, darker rims. Акварельный вид: светлее внутри, темнее по краям. + Wavelength Max Макс. длина волны + Wavelength Min Мин. длина волны + Waves Волны + Web Large Web (крупный) + Web Minimum Web (минимальный) + Web Most Common Web (наиболее распространённый) + WebP (lossless) WebP (без потерь) + Wetness Влажность + White Clip Отсечение белого + White Intensity Интенсивность белого + White Is High Белое — высокое + Whole Word Слово целиком + Wide Blurry Широкий размытый + Wide Sharp Широкий резкий + Wind Ветер + Window renderer: {renderer} Отрисовка окна: {renderer} + Working Рабочее + Working Cmyk Рабочий CMYK + Working Gray Рабочий: градации серого + Working Rgb Рабочий RGB + Working-cmyk Рабочий CMYK + Workspaces Рабочие среды + Wrap Оборачивание + Yellows Жёлтые тона + ZigZag Зигзаг + Zigzag Зигзаг + Zoom In (hold, drag to scrub) Увеличение (удерживайте, перетащите для прокрутки) + Zoom Out (hold) Уменьшение (удерживайте) + auto авто + blue синий + brushes кисти + fallback резерв + gradients градиенты + gray серый + green зелёный + layer mask маска слоя + manual вручную + none нет + off выключено + on включено + orange оранжевый + previous reading kept; the last re-read failed: {reason} сохранено предыдущее значение; последнее повторное чтение не удалось: {reason} + red красный + unknown неизвестно + unknown type неизвестный тип + vector mask векторная маска + violet фиолетовый + yellow жёлтый + {index} of {total} {index} из {total} + {label} is not available: {reason} {label} недоступно: {reason} + {message} (notes: {n}; {warning}) {message} (примечаний: {n}; {warning}) + {shortcut} assigned. {shortcut} назначено. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} уже используется командой {commands} и будет удалено из неё при нажатии «ОК». + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ навигация ↵ выполнить esc закрыть + ✓ Color Indicates Masked Areas ✓ Цветом обозначены маскированные области + ✓ Color Indicates Selected Areas ✓ Цветом обозначены выделенные области + + +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Очистить +@id edit.purge.undo Данные отмены +@id edit.stroke Обвести… +@id edit.transform.scale Масштабирование +@id file.close Закрыть +@id filter.sharpen.sharpen Резкость +@id layer.layerMask.apply Применить +@id select.all Выделить всё +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Импортирована {n} кисть из {file}|Импортированы {n} кисти из {file}|Импортировано {n} кистей из {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Импортирован {n} градиент из {file}|Импортированы {n} градиента из {file}|Импортировано {n} градиентов из {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Импортирован {n} узор из {file}|Импортированы {n} узора из {file}|Импортировано {n} узоров из {file} +cameraRaw Balance Баланс +cameraRaw Clarity Чёткость +cameraRaw Color Цвет +cameraRaw Color Mixer Смеситель цветов +cameraRaw Contrast Контраст +cameraRaw Curve Кривая +cameraRaw Dehaze Удаление дымки +cameraRaw Detail Детализация +cameraRaw Effects Эффекты +cameraRaw Exposure Экспозиция +cameraRaw Feather Растушёвка +cameraRaw Highlights Света +cameraRaw Luminance Яркость +cameraRaw Masking Маскирование +cameraRaw Midpoint Средняя точка +cameraRaw Midtones Средние тона +cameraRaw Radius Радиус +cameraRaw Roundness Округлость +cameraRaw Saturation Насыщенность +cameraRaw Shadows Тени +cameraRaw Size Размер +cameraRaw Temperature Температура +cameraRaw Texture Текстура +cameraRaw Tint Оттенок +cameraRaw Whites Белые точки +colorRange Invert Инвертировать +document Orientation Ориентация +layerLabel Magenta Пурпурный +layerLabel No Color Без цвета +layerLabel Red Красный +layers Fill Заливка +layers Fill: Заливка: +navigator Zoom Масштаб +shape Appearance Оформление diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..f48c5afae3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 - +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G13: Russian gained the 1,233 dynamic UI labels other catalogs already had; no estimate changed; previously minor: G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that nobody is working on, and when you close part of one, update its evidence and estimate here and @@ -254,6 +253,10 @@ what an agent can't finish alone. as tofu (#1408, #1693). - **Evidence (2026-10-11):** Czech dynamic UI wrappers, hardware labels and composed messages translate at display sites; engine errors remain English. This does not close G13. +- **Evidence (2026-10-15):** Russian gained the 1,233 dynamic labels the other catalogs already + had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, + metadata, System Info, status templates, dialogs), with a terminology note + ([localization-ru](localization-ru.md)); engine errors remain English. This does not close G13. - **Detail:** [`localization-parity.md`](localization-parity.md). - **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded). @@ -270,6 +273,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G13: Russian gained the 1,233 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | @@ -286,8 +290,7 @@ what an agent can't finish alone. | 2026-10-13 | minor | G1: Threshold splits on Photoshop's luminosity of whole samples (8- and 16-bit rules exact on four oracles); psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 | | 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 | | 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | -| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | -| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 || 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b2be85595..cc91806da3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (Russian dynamic labels; previously minor: Spanish dynamic labels) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -46,8 +46,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde ## Other shipped languages 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional -Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the +Chinese `zh-hant` (2,329 rows), Russian `ru` (4,208; also every dynamic label the +other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every @@ -87,3 +87,4 @@ engine localization or native review is complete. | 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | +| 2026-10-15 | minor | Russian: 1,233 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) | diff --git a/docs/localization-ru.md b/docs/localization-ru.md new file mode 100644 index 0000000000..c4bda602b4 --- /dev/null +++ b/docs/localization-ru.md @@ -0,0 +1,75 @@ +# Russian terminology and dynamic UI labels + +This note goes with the completion of the Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`). +The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked 1,233 +labels that only exist at runtime and that other catalogs (Italian, Serbian, Czech, Hungarian) +already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects, +History / Layers / paths / channels menu labels, metadata fields, status messages, System Info +and dialog text. In Russian those showed in English. +They are now translated, reusing the vocabulary the catalog already uses elsewhere so the same +term reads the same everywhere. Translations are written from the meaning of PhotoCraft's English +labels; no proprietary translation resources, fonts, screenshots or user documents are included. + +## Register and style + +Commands are imperatives in the second person plural (*Сохранить*, *Удалить*, *Заново*); +where the catalog already uses a noun it is kept (*Уровни…*, *Кривые…*). Sentence case after the +first word, as in the rest of the catalog. Ellipses on dialog commands are preserved (`Ветер…` +→ `Ветер…`). Decimals use a comma (the file format's own labels keep their dots, e.g. +`Landscape, 10 x 8` stays `Альбомная, 10 x 8`). Latin product and technology names are not +translated (PhotoCraft, Camera Raw, RGB, CMYK, Lab, LUT, WebP); Camera Raw keeps `.xmp`; the +`HDR`, `HSL`, `HSB` and `W3C` acronyms and the `sGray`/`Pro Photo` identifiers stay Latin. + +## Terminology + +Existing choices were followed rather than re-invented, so the new rows use: + +| English source | Russian | Note | +|---|---|---| +| Highlight(s) | Света | already in the catalog (`Highlights` → `Света`) | +| Midtone(s) | Полутона | already in the catalog | +| Shadow(s) | Тени | pairs with *Света* | +| Feather | Растушёвка | never *перо* | +| Fuzziness | Разброс | Color Range's tolerance | +| Dither | Дизеринг | | +| Pattern | Узор | also in *Узорный штамп* | +| Preset | Набор | *Набор кистей*, *набор инструментов* | +| Guide | Направляющая | *Схема направляющих* | +| Artboard | Монтажная область | | +| Workspace | Рабочая среда | | +| Stroke (contour) | Обводка | painterly strokes stay *штрихи*/*мазки* | +| Stroke (paint param) | Штрихи / мазки | `Strokes` → `Штрихи` | +| Amount | Величина | `Штрихи`, never *количество*, when it is a strength | +| Fill Opacity | Непрозрачность заливки | distinct from layer *Непрозрачность* | +| Feather Radius | Радиус растушёвки | | +| Sharpen | Резкость | `Smart Sharpen` → *Умная резкость*, `Unsharp Mask` → *Контурная резкость* | +| Noise | Шум | `Reduce Noise` → *Уменьшить шум* | +| Content-Aware | С учётом содержимого | | +| Camera Raw | Camera Raw | the panel name stays Latin (as German/Spanish do; Greek translates it) | +| Place Embedded / Linked | Поместить встроенный / связанный | | +| Rasterize | Растрировать | | + +## Native review + +The review was AI-assisted and each row was checked against the meaning of PhotoCraft's own +English label. A review by a native speaker is still welcome. + +## Not covered + +Engine error and status messages, user document, layer and preset names, and automation command +IDs stay in English, as in every language ([`localization-parity.md`](localization-parity.md)). +A few dynamic labels the other catalogs carry are not used by the current code; they are +translated to stay aligned with the other catalogs. + +## Validation + +- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) + enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires + a Russian row for each, like the Korean and Italian tests. +- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`) + covers the rasterize prompts, hardware status lines, composed status templates and a sample of + filter labels. +- `cargo test -p photocraft-ui-egui --lib`: 2,689 passed, 0 failed. +- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. +- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian + labels, without missing glyphs or clipped text. From a4cdc63d88eebf3debf73da572830244f8ad8f3e Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 23:50:55 +0400 Subject: [PATCH 0248/1452] Retouch: admit the rendered stroke extent, not an estimate (#4961, #6110) Review of f116c32 was right that the guard did not establish the bound it advertised. reject_bounds ran on the value stroke_coverage() returned, and that function calls dense_coverage() internally, so the dense Vec was already allocated by the time it was checked. reject_huge_stroke, the only check that ran first, estimated from the raw points and half the tip, which brush dynamics exceed: Scattering offsets a dab centre by its amount times the tip size. Split rendering from dense extraction. stroke_coverage_within renders the stroke into the renderer's tiles, which hold only what the dabs touch, reads the extent once every dab is placed and refuses before allocating the width x height buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke command goes through it, so the bound now holds whatever the dynamics do. reject_bounds is removed; it could not do the job it was named for. spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns Result now. reject_huge_stroke stays as the cheap early-out and pads by the scatter reach and the dual-brush radius so it no longer falls short. The allowance is unchanged, so no stroke that worked before starts failing. Tests: coverage_within takes the budget as a parameter, and a scattering brush proves the rendered extent exceeds what the points alone suggest, is refused one pixel under it and fits at it. Every command's refusal now asserts the pixels and the history step count are untouched, and the ordinary stroke uses a valid fixture and must succeed rather than merely avoid the size error. --- crates/engine/src/retouch_cmds.rs | 57 +++++++++++-------- .../engine/src/retouch_cmds/pattern_stamp.rs | 13 ++--- crates/engine/src/retouch_cmds/remove.rs | 8 +-- crates/engine/src/retouch_cmds/tests.rs | 46 ++++++++++++--- crates/paint/src/retouch.rs | 18 ++++++ 5 files changed, 99 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index b462d37519..8ef8722c16 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,7 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -43,7 +43,16 @@ const MAX_STROKE_PIXELS: u64 = 67_108_864; /// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush /// size alone, before the renderer allocates anything for it. fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); + // Dynamics place dabs beyond the input points, so half the tip is not the whole reach: + // Scattering offsets a centre by up to its amount times the tip size, and a Dual Brush dab + // reaches its own radius further. Pad by what those can add so this estimate does not fall + // short of what the renderer will cover. It is only the cheap early-out; the rendered extent + // is admitted for real in `coverage` below. + let b = &stroke.brush; + let size = f64::from(b.size.max(1.0)); + let scatter = if b.scattering.enabled { size * f64::from(b.scattering.scatter.jitter.max(0.0)) } else { 0.0 }; + let dual = if b.dual_brush.enabled { f64::from(b.dual_brush.size.max(0.0)) * 0.5 } else { 0.0 }; + let pad = size.mul_add(0.5, 4.0) + scatter + dual; let mut min_x = f64::INFINITY; let mut min_y = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; @@ -69,16 +78,19 @@ fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { Ok(()) } -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) +/// The stroke's coverage, with the **rendered** extent admitted before the dense buffer exists. +/// +/// `reject_huge_stroke` estimates from the input points; this is the real bound. Rendering is +/// tiled, so it holds only the tiles the dabs touch; the extent is read from those tiles once +/// every dab is placed (tips, dynamics and scattering included) and checked against `max`, and +/// only a stroke that fits is made dense. `max` is a parameter so tests can inject a small budget. +fn coverage_within(cmd: &str, stroke: &Stroke, max: u64) -> Result<(Rect, Vec)> { + photocraft_paint::retouch::stroke_coverage_within(stroke, max).ok_or_else(|| bad(cmd, format!("stroke too large (at most {max} px)"))) +} + +/// [`coverage_within`] at the shared allowance. +fn coverage(cmd: &str, stroke: &Stroke) -> Result<(Rect, Vec)> { + coverage_within(cmd, stroke, MAX_STROKE_PIXELS) } /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). @@ -383,8 +395,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -647,8 +658,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -695,8 +705,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; @@ -773,12 +782,12 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Rect { - let (bounds, cov) = stroke_coverage(stroke); +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; @@ -795,7 +804,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); if !hole.iter().any(|h| *h) { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels // carry synthesised values that differ from the untouched surroundings: that mismatch is what the @@ -819,7 +828,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal) + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -833,7 +842,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 3c90c6d2a4..b4b71745ae 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::Pattern; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_bounds, reject_huge_stroke}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -150,8 +150,7 @@ fn paint_aligned( mode: BlendMode, cmd: &str, ) -> Result { - let (bounds, cov) = stroke_coverage(stroke); - reject_bounds(cmd, bounds)?; + let (bounds, cov) = super::coverage(cmd, stroke)?; if bounds.is_empty() { return Ok(Rect::EMPTY); } @@ -259,9 +258,9 @@ mod tests { #[test] fn a_diagonal_across_a_24_mp_canvas_is_not_too_large() { - assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0)).is_ok()); - assert!(reject_bounds("t", Rect::from_xywh(-60, -60, 6136, 4120)).is_ok()); + let across = stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0); + assert!(reject_huge_stroke("t", &across).is_ok()); + assert!(super::super::coverage("t", &across).is_ok(), "the rendered extent fits too"); assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (9000.0, 9000.0)], 100.0)).is_err()); - assert!(reject_bounds("t", Rect::from_xywh(0, 0, 9000, 9000)).is_err()); } } diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 55c53d3af7..d298e8f02c 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -117,12 +117,12 @@ fn plan(s: &Session, p: &Value) -> Result { /// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin, /// limited to the selection. Empty when nothing would change. -fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { +fn hole(plan: &Plan, sel: Option<&Surface>) -> Result> { let win = plan.window; let (w, h) = (win.width() as usize, win.height() as usize); let mut m = vec![false; w * h]; for stroke in &plan.strokes { - let (bounds, cov) = stroke_coverage(stroke); + let (bounds, cov) = super::coverage(CMD, stroke)?; let bw = bounds.width() as usize; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); @@ -143,13 +143,13 @@ fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { *v &= c > 0.0; } } - m + Ok(m) } /// The filled window and the hole it fills, computed on `doc` (a snapshot). `None` when the /// layer's own window is fully transparent: nothing to remove and nothing to fill from. fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result)>> { - let hole = hole(plan, doc.selection.as_ref()); + let hole = hole(plan, doc.selection.as_ref())?; if !hole.iter().any(|m| *m) { return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)")); } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 35f6e81efd..edbf33c317 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -566,23 +566,24 @@ fn history_brush_refuses_a_stroke_too_large_to_render() { } /// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke -/// reached the same dense conversion unguarded. Each command refuses the oversized stroke and -/// leaves the pixels alone, and a stroke across the whole canvas still works. +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke without +/// touching the pixels or the history, and an ordinary stroke still succeeds. #[test] fn every_retouch_stroke_refuses_a_box_it_cannot_render() { let huge = json!([[0, 0], [900_000, 900_000]]); - let ok = json!([[0, 0], [40, 20]]); - let cases: [(&str, Value); 5] = [ + let ok = json!([[2, 2], [30, 16]]); + // Fixtures that are valid for a plain RGB document with one pixel layer. + let cases: [(&str, Value); 4] = [ ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), ("paint.spotHealing", json!({"size": 8, "hardness": 100})), ("paint.historyBrush", json!({"size": 8, "hardness": 100})), - ("paint.patternStamp", json!({"size": 8, "hardness": 100})), ]; for (cmd, base) in cases { let mut s = session(40, 20, 8, "rgb"); s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); let before = rgba(&s, 20, 10); + let steps = s.active().unwrap().history.past_len(); let with = |points: &Value| { let mut v = base.clone(); if let Some(o) = v.as_object_mut() { @@ -593,9 +594,11 @@ fn every_retouch_stroke_refuses_a_box_it_cannot_render() { let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); assert!(err.contains("stroke too large"), "{cmd}: {err}"); assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); - // The allowance still covers a stroke across the whole canvas. - let across = s.execute(cmd, with(&ok)); - assert!(across.is_ok() || !format!("{across:?}").contains("stroke too large"), "{cmd}: {across:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd}: and records no history step"); + + // The allowance still covers ordinary work: this must succeed, not merely avoid the error. + s.execute(cmd, with(&ok)).unwrap_or_else(|e| panic!("{cmd}: an ordinary stroke must work: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd}: and records one step"); } // Remove takes a list of strokes; each one is charged as it is parsed. @@ -604,6 +607,33 @@ fn every_retouch_stroke_refuses_a_box_it_cannot_render() { assert!(err.contains("stroke too large"), "paint.remove: {err}"); } +/// The review on #6110: the estimate from the input points is not the bound. Brush dynamics place +/// dabs beyond those points, so the budget is applied to the extent the renderer actually produced, +/// before the dense buffer is allocated. An injected budget makes that observable without asking +/// for a stroke big enough to exhaust anything. +#[test] +fn the_budget_is_applied_to_the_rendered_extent_not_the_input_points() { + use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; + let mut brush = BrushSettings { size: 24.0, hardness: 1.0, ..Default::default() }; + brush.scattering.enabled = true; + brush.scattering.scatter.jitter = 6.0; + let stroke = Stroke { brush, points: vec![StrokePoint::new(10.0, 10.0, 1.0), StrokePoint::new(40.0, 10.0, 1.0)] }; + + // What the renderer really covered, once scattering has placed every dab. + let (bounds, cov) = super::coverage_within("t", &stroke, u64::MAX).unwrap(); + let rendered = u64::from(bounds.width()) * u64::from(bounds.height()); + assert_eq!(cov.len() as u64, rendered, "the dense buffer is exactly the rendered extent"); + + // Scattering reaches past the points plus half the tip, which is what the estimate alone sees. + let points_only = (40.0 - 10.0 + 24.0 + 8.0) * (24.0 + 8.0); + assert!(rendered as f64 > points_only, "scattering expanded the stroke: {rendered} vs {points_only}"); + + // A budget one pixel under what it rendered refuses it; exactly the rendered extent fits. + let err = super::coverage_within("t", &stroke, rendered - 1).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{err}"); + assert!(super::coverage_within("t", &stroke, rendered).is_ok(), "the rendered extent itself fits"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 9ef837c0c0..023e04b5ec 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -130,6 +130,24 @@ pub fn stroke_coverage(stroke: &Stroke) -> (Rect, Vec) { r.dense_coverage() } +/// [`stroke_coverage`] that admits the **rendered** extent before it is made dense: `None` when the +/// stroke covers more than `max_pixels`. +/// +/// Rendering itself is tiled, so it holds only the tiles the dabs touch. The bounds are read from +/// those tiles, after tips, dynamics and scattering have placed every dab, so this is the stroke's +/// real extent rather than an estimate from the input points. Only then is the `width × height` +/// buffer allocated, which is what callers need bounded. +pub fn stroke_coverage_within(stroke: &Stroke, max_pixels: u64) -> Option<(Rect, Vec)> { + let mut r = crate::StrokeRenderer::new(&stroke.brush, None, 1.0); + r.push(&stroke.points); + r.finish(); + let b = r.bounds(); + if u64::from(b.width()).saturating_mul(u64::from(b.height())) > max_pixels { + return None; + } + Some(r.dense_coverage()) +} + /// Index of the alpha channel, if the format has one. #[inline] pub fn alpha_index(fmt: &PixelFormat) -> Option { From 7f0e7cbee0e4bbbbae6db2869b12de80af950426 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:11:45 +0200 Subject: [PATCH 0249/1452] Zoom tool: holding the button zooms continuously with Animated Zoom on (#6210) With Preferences > General > Animated Zoom on (the default), a Zoom tool press held still past 0.3 s zooms continuously about the press point (2 doublings a second, frame-rate independent, within the zoom_levels range) until release; Alt zooms out. The release is then no click step. A quick click still steps, Scrubby Zoom and the zoom rectangle are unchanged, and with the preference off a hold is one click step. The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it. general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do nothing 25 of 166. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs_ui.rs | 1 + crates/ui-egui/src/zoom_hold.rs | 264 ++++++++++++++++++++++++++++++++ docs/gaps.md | 7 +- docs/scorecard.md | 8 +- docs/ui-parity.md | 5 +- scorecard/bugs.toml | 2 +- 9 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/zoom_hold.rs diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..51901841a2 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1010,7 +1010,6 @@ pub const SECTIONS: [(&str, &str); 18] = [ /// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", - "general.animatedZoom", "general.zoomResizesWindows", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..f115c733ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3523,6 +3523,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if tool == Tool::RotateView && !over_compass && crate::rotate_view::drag(&ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) { (buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false); } + // Animated Zoom: a Zoom press held still zooms continuously (zoom_hold.rs); while it does, + // it owns the press: no scrub, and its release is no click step. + if tool == Tool::Zoom && !transform_owns_pointer { + let out = zoom_out(ui.input(|i| i.modifiers.alt)); + if crate::zoom_hold::update(&ctx, &mut view, &xf, app.session.prefs().general.animated_zoom, out, ppp) { + (buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false); + } + } // Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below. // Like the Hand, the selected Zoom yields to an open Free Transform box (#4799). if tool == Tool::Zoom && !transform_owns_pointer && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..cdff3ee65b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -234,6 +234,7 @@ pub mod wide_angle_ui; pub mod widgets; pub mod work_area; pub mod workspace_ui; +pub mod zoom_hold; pub mod zoom_levels; pub mod zoom_tool; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d194803020..05bc0760f9 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3368,6 +3368,7 @@ mod tests { assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); assert!(!prefs::is_hidden("general.autoShowHomeScreen")); + assert!(!prefs::is_hidden("general.animatedZoom")); assert!(!prefs::is_hidden("interface.uiScale")); assert!(!prefs::is_hidden("interface.uiFontSize")); // Hidden values still round-trip through the dialog untouched. diff --git a/crates/ui-egui/src/zoom_hold.rs b/crates/ui-egui/src/zoom_hold.rs new file mode 100644 index 0000000000..673ccf6a6f --- /dev/null +++ b/crates/ui-egui/src/zoom_hold.rs @@ -0,0 +1,264 @@ +//! Animated Zoom: press and hold with the Zoom tool to zoom continuously (#6210). +//! +//! With Preferences › General › Animated Zoom on (the default, as in Photoshop) a press held +//! still past [`DELAY`] zooms in smoothly about the press point until the button comes up, or out +//! with ⌥. The release is then no click step. A quick click still steps the zoom (the canvas), a +//! drag still scrubs or draws a zoom rectangle (`zoom_tool.rs`), and with the preference off a +//! hold does nothing more than a click. +//! +//! The rate is per second of wall time, so the zoom grows the same at any frame rate. + +use egui::Pos2; + +use crate::canvas::ViewXform; +use crate::state::View; + +/// Seconds a press must be held still before it zooms continuously (a click is shorter). +pub const DELAY: f64 = 0.3; +/// Doublings (or halvings, zooming out) per second while held. +pub const RATE: f32 = 2.0; +/// Screen points the pointer may wander before the press counts as a drag instead. +const SLOP: f32 = 6.0; +/// The longest frame gap a hold zooms over: a stalled frame never jumps the zoom far. +const MAX_DT: f64 = 0.25; + +/// A Zoom-tool press being held. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Hold { + /// Screen point where the button went down. + anchor: Pos2, + /// The document point under it (it stays under the anchor). + doc: [f64; 2], + /// When the button went down (egui seconds). + t0: f64, + /// The time the zoom last advanced to, once zooming. + last: Option, + /// The pointer moved before the hold began: this press is a drag, never a hold. + dragged: bool, +} + +fn id() -> egui::Id { + egui::Id::new("pc-zoom-tool-hold") +} + +/// The zoom after holding for `dt` seconds at `zoom` (out: shrinking), within the range for a +/// document of `size` px. A non-finite or negative `dt` changes nothing. +pub fn hold_zoom(zoom: f32, dt: f64, out: bool, size: [u32; 2]) -> f32 { + let dt = if dt.is_finite() { dt.clamp(0.0, MAX_DT) as f32 } else { 0.0 }; + let e = if out { -RATE * dt } else { RATE * dt }; + crate::zoom_levels::clamp(zoom * e.exp2(), size) +} + +/// Is a held press zooming right now? (It owns the gesture: no scrub, no click step.) +pub fn active(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(id())).is_some_and(|h| h.last.is_some()) +} + +/// Advance a Zoom-tool hold by one frame. `enabled` is the Animated Zoom preference, `out` the +/// zoom direction (⌥ or a temporary zoom-out key, read live). Returns true when the press is a +/// hold zoom (on its release frame too), so the canvas skips the scrub and the click step. +pub fn update(ctx: &egui::Context, view: &mut View, xf: &ViewXform, enabled: bool, out: bool, ppp: f32) -> bool { + let (down, origin, start, pos, now) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_origin(), i.pointer.press_start_time(), i.pointer.latest_pos(), i.time)); + let held = ctx.data(|d| d.get_temp::(id())); + if !enabled || !down { + if held.is_some() { + ctx.data_mut(|d| d.remove::(id())); + } + return enabled && held.is_some_and(|h| h.last.is_some()); + } + let mut h = match held { + Some(h) => h, + None => { + // A press that began on the canvas (not one dragged in from a panel). + let Some(anchor) = origin.filter(|p| xf.rect.contains(*p)) else { return false }; + let t0 = start.filter(|t| t.is_finite()).unwrap_or(now); + let h = Hold { anchor, doc: xf.to_doc(anchor), t0, last: None, dragged: false }; + ctx.data_mut(|d| d.insert_temp(id(), h)); + h + } + }; + if h.dragged { + return false; + } + if h.last.is_none() { + // Moved before the hold began: a drag (scrubby zoom or rectangle), not a hold. + if pos.is_some_and(|p| p.distance(h.anchor) > SLOP) { + h.dragged = true; + ctx.data_mut(|d| d.insert_temp(id(), h)); + return false; + } + let begin = h.t0 + DELAY; + if now < begin { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((begin - now).clamp(0.0, DELAY))); + return false; + } + h.last = Some(begin); + } + let dt = now - h.last.unwrap_or(now); + let zoom = hold_zoom(view.zoom, dt, out, view.doc_size); + crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp); + h.last = Some(now); + ctx.data_mut(|d| d.insert_temp(id(), h)); + ctx.request_repaint(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PhotocraftApp; + use egui::{Event, PointerButton, vec2}; + use egui_kittest::Harness; + use serde_json::json; + + const DOC: [u32; 2] = [400, 300]; + + #[test] + fn hold_zoom_is_frame_rate_independent_and_safe() { + // One second in one step or in 60 steps: two doublings either way. + let mut z = 1.0; + for _ in 0..60 { + z = hold_zoom(z, 1.0 / 60.0, false, DOC); + } + let big = hold_zoom(hold_zoom(hold_zoom(hold_zoom(1.0, 0.25, false, DOC), 0.25, false, DOC), 0.25, false, DOC), 0.25, false, DOC); + assert!((z - 4.0).abs() < 1e-3 && (big - 4.0).abs() < 1e-3, "{z} {big}"); + assert!((hold_zoom(1.0, 0.25, true, DOC) - 2f32.powf(-0.5)).abs() < 1e-5); + // Nothing for no time, a negative or broken dt, or a non-finite zoom (100 %). + assert_eq!(hold_zoom(1.5, 0.0, false, DOC), 1.5); + assert_eq!(hold_zoom(1.5, -3.0, false, DOC), 1.5); + assert_eq!(hold_zoom(1.5, f64::NAN, true, DOC), 1.5); + assert_eq!(hold_zoom(1.5, f64::INFINITY, true, DOC), 1.5); + assert_eq!(hold_zoom(f32::NAN, 0.1, false, DOC), 1.0); + // A stall is capped; the range is kept. + assert!((hold_zoom(1.0, 10.0, false, DOC) - 2f32.powf(0.5)).abs() < 1e-5); + assert_eq!(hold_zoom(crate::zoom_levels::MAX, 0.2, false, DOC), crate::zoom_levels::MAX); + let min = crate::zoom_levels::min(DOC); + assert_eq!(hold_zoom(min, 0.2, true, DOC), min); + assert_eq!(hold_zoom(1.0, 0.1, false, [0, 0]), hold_zoom(1.0, 0.1, false, [0, 0])); + } + + fn harness(animated: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = crate::state::Tool::Zoom; + app.ui.tool_options.zoom_scrubby = true; + // Each step is 0.1 s of egui time. + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.1).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let view = &mut h.state_mut().ui.views[0]; + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + h.run_steps(2); + h + } + + fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + ViewXform::active(h.state()).unwrap() + } + + fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + + /// Press at `p`, hold still for `frames` steps, release; returns the zoom after every step, + /// then after the release step and two more frames. + fn hold(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, frames: usize, alt: bool) -> Vec { + h.event(Event::PointerMoved(p)); + h.step(); + button(h, p, true, alt); + let mut zs = vec![h.state().current_zoom()]; + for _ in 0..frames { + h.step(); + zs.push(h.state().current_zoom()); + } + button(h, p, false, alt); + zs.push(h.state().current_zoom()); + h.run_steps(2); + zs.push(h.state().current_zoom()); + zs + } + + #[test] + fn holding_zooms_in_continuously_about_the_press_point() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(-120.0, 70.0); + let doc = xf(&h).to_doc(p); + let zs = hold(&mut h, p, 8, false); + // Nothing during the delay, then more every frame; the release adds no click step. + assert_eq!(zs[0], 1.0, "{zs:?}"); + let rising = &zs[4..=8]; + assert!(rising.windows(2).all(|w| w[1] > w[0]), "{zs:?}"); + // The release frame ends it: no click step, nothing more afterwards. + assert_eq!(zs[10], zs[9], "{zs:?}"); + assert!(zs[9] < zs[8] * 1.2, "{zs:?}"); + // About 0.5 s held past the delay at RATE doublings per second. + let expected = (RATE * 0.5).exp2(); + assert!((zs[8] - expected).abs() < 0.2, "{zs:?} vs {expected}"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); + assert_eq!(h.state().session.documents()[0].history.past_len(), 0); + } + + #[test] + fn alt_hold_zooms_out() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(50.0, -30.0); + let doc = xf(&h).to_doc(p); + let zs = hold(&mut h, p, 8, true); + assert!(zs[4..=8].windows(2).all(|w| w[1] < w[0]), "{zs:?}"); + assert_eq!(zs[10], zs[9], "{zs:?}"); + assert!(zs[9] > zs[8] / 1.2, "{zs:?}"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); + } + + #[test] + fn with_animated_zoom_off_a_hold_is_one_click_step() { + let mut h = harness(false); + let p = h.state().last_canvas_rect.center(); + // Shorter than egui's longest click, so the release is a click. + let zs = hold(&mut h, p, 5, false); + assert!(zs[..=5].iter().all(|&z| z == 1.0), "{zs:?}"); + assert_eq!(*zs.last().unwrap(), crate::zoom_levels::step(1.0, 1, DOC)); + } + + #[test] + fn a_quick_click_steps_and_a_drag_still_scrubs() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center(); + let zs = hold(&mut h, p, 0, false); + assert_eq!(*zs.last().unwrap(), crate::zoom_levels::step(1.0, 1, DOC), "{zs:?}"); + // Scrubby Zoom: a drag zooms by its distance however long it is held, never by time. + let z0 = h.state().current_zoom(); + h.event(Event::PointerMoved(p)); + h.step(); + button(&mut h, p, true, false); + h.event(Event::PointerMoved(p + vec2(50.0, 0.0))); + h.step(); + for _ in 0..6 { + h.step(); + } + let expected = crate::zoom_tool::scrub_zoom(z0, 50.0, DOC); + assert!((h.state().current_zoom() - expected).abs() < 1e-4, "{} vs {expected}", h.state().current_zoom()); + button(&mut h, p + vec2(50.0, 0.0), false, false); + h.run_steps(2); + assert!((h.state().current_zoom() - expected).abs() < 1e-4); + assert!(!active(&h.ctx)); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..04c0d2b790 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -195,9 +195,11 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-15):** Preferences › General › Animated Zoom takes effect: a held Zoom + tool press zooms continuously about the press point, ⌥ out (#6210, `ui-egui/src/zoom_hold.rs`). - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). -- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't +- **Missing:** 25 of 166 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen @@ -270,6 +272,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G9: Animated Zoom preference takes effect (hold the Zoom tool to zoom continuously, #6210); prefs that do nothing 25 of 166; no estimate changed | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..8993124bac 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -177,9 +177,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -280,7 +280,7 @@ Never-crash attribute on 29 of 29 crate roots. | BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures | | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | | BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | -| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED | +| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom makes a held Zoom tool press zoom continuously (zoom_hold.rs, #6210) | | BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | | BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..8a2194f350 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Animated Zoom: holding the Zoom tool zooms continuously, #6210; 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) · **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, @@ -170,7 +170,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | -| Animated zoom | missing | unread pref; #566 | +| Animated zoom | partial | `zoom_hold.rs`: with Preferences › General › Animated Zoom on, a Zoom press held still past 0.3 s zooms continuously about the press point (2 doublings a second, ⌥ out) until release (#6210); eased click steps still missing (#566) | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | | ⌥-wheel zoom in gentle steps | missing | UI-217-18 | | Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | @@ -243,6 +243,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-15 | minor | Animated zoom partial: with Animated Zoom on, holding the Zoom tool's button zooms continuously about the press point, ⌥ out (#6210) | | 2026-10-15 | minor | Pen Auto Add/Delete works on the path being drawn: a click on its segment adds an anchor, on a middle anchor deletes it, and drawing carries on (#6140) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index a4620bf7d6..8a32dab9f7 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -60,7 +60,7 @@ id = "BUG-204-2" target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden" status = "done" issue = 204 -note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED" +note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom makes a held Zoom tool press zoom continuously (zoom_hold.rs, #6210)" [[item]] id = "BUG-204-3" From 14b1783e9345e69172e5da30355e5cb9264c4f37 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 21:46:33 +0200 Subject: [PATCH 0250/1452] Smart Filters: Delete/Backspace removes the selected filter, not its smart object (#4751) --- crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/shortcut_dispatch.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 4 + crates/ui-egui/src/smart_filter_rows_tests.rs | 88 +++++++++++++++++++ crates/ui-egui/src/smart_ui.rs | 59 ++++++++++++- docs/ui-parity.md | 5 +- 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..ebc6b24fb3 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2409,6 +2409,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); }); // A layer being dragged can also be dropped on the footer's Delete, New Layer and Group // buttons (#736); read the drag before it ends. diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 4336315dba..e89db8b2e1 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -272,7 +272,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 e8645a1511..8d597f5109 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -517,6 +517,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::guide_select::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 7f49843c53..48fc7dd12b 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -145,6 +145,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 @@ -160,12 +166,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(), @@ -180,6 +196,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 }; @@ -275,6 +302,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) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..9de205e7bb 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (⌫/Delete removes the selected smart filter, #4751; 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) · **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, @@ -219,7 +219,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` | @@ -257,3 +257,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 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) | From c84886adc2253d82b40b52c2eb670418d6aa09b7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:22:00 +0200 Subject: [PATCH 0251/1452] WIP: PSD: Offset and Custom smart filters open and save like Photoshop (#6304) From 81b6cdf0d2fce00bd33bc273ce2daf0ce08eb258 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 03:15:08 +0300 Subject: [PATCH 0252/1452] text: apply anti-alias mode shaping when rasterizing type Photoshop's Sharp/Crisp/Strong/Smooth settings only changed metadata; shape edge coverage per mode (fixes storytold/photocraft#5902). Co-authored-by: Cursor --- crates/text/src/render.rs | 24 +++++++++++++++++++++++- crates/text/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..7fff345a6d 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -19,6 +19,28 @@ pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +/// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin +/// the fringe (sharper); below 1 widen it (stronger smoothing). +fn shape_antialias_coverage(cv: f32, mode: AntiAlias) -> f32 { + if !cv.is_finite() { + return 0.0; + } + let cv = cv.clamp(0.0, 1.0); + match mode { + AntiAlias::None => { + if cv >= 0.5 { + 1.0 + } else { + 0.0 + } + } + AntiAlias::Smooth => cv, + AntiAlias::Strong => cv.powf(0.55), + AntiAlias::Crisp => cv.powf(1.35), + AntiAlias::Sharp | AntiAlias::Windows | AntiAlias::WindowsLcd => cv.powf(2.2), + } +} + /// Rendered text: pixels in document space plus the covered rectangle. pub struct Rendered { pub surface: Surface, @@ -277,7 +299,7 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); for (i, &cv) in cov.iter().enumerate() { - let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv }; + let cv = shape_antialias_coverage(cv, antialias); let s = cv * a; if s <= 0.0 { continue; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..c6cc6b2ce4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1930,6 +1930,35 @@ fn engine_data_antialias_numbers_match_photoshop() { } } +/// Anti-alias modes must change edge coverage (storytold/photocraft#5902). +#[test] +fn antialias_modes_change_edge_fringe() { + use photocraft_doc::text::AntiAlias; + + let fringe_mass = |mode: AntiAlias| { + let mut e = TextEngine::new(); + let layer = TextLayer { antialias: mode, ..point("O", 48.0) }; + let (_, r) = e.render(&layer, 72.0, PixelFormat::RGBA8); + let n = r.surface.channels(); + r.surface + .read_region(r.rect) + .chunks_exact(n) + .map(|p| f64::from(p[n - 1])) + .filter(|&a| a > 0.02 && a < 0.98) + .sum::() + }; + + let none = fringe_mass(AntiAlias::None); + let sharp = fringe_mass(AntiAlias::Sharp); + let smooth = fringe_mass(AntiAlias::Smooth); + let strong = fringe_mass(AntiAlias::Strong); + + assert_eq!(none, 0.0, "aliased-off edges are hard"); + assert!(strong > smooth + 1.0, "strong smooths more than smooth: {strong} vs {smooth}"); + assert!(smooth > sharp + 0.5, "sharp thins the fringe vs smooth: {sharp} vs {smooth}"); + assert!(sharp > 0.0); +} + /// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs /// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. #[test] From 07bf389914ceda951339e62b141caff203806ec2 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 04:56:02 +0300 Subject: [PATCH 0253/1452] Fix Exposure value-field steps when decimals are set. Fractional stops use the declared decimal precision for arrow keys and scrub steps, not only narrow ranges (storytold/photocraft#5914). Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 42 ++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6e841292e7..a8ab3a0840 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -502,6 +502,19 @@ struct FieldOpts { wrap_drag: bool, } +/// Arrow-key and drag step for a value field: explicit `decimals` win over the range width +/// (Exposure's ±20 stops still step by 0.01 when it shows two decimals, #5914). +fn field_step(range: &std::ops::RangeInclusive, decimals: Option) -> f32 { + if let Some(d) = decimals { + return 10f32.powi(-(d.min(6) as i32)); + } + if *range.end() - *range.start() <= 10.0 { 0.01 } else { 1.0 } +} + +fn field_fine(range: &std::ops::RangeInclusive, decimals: Option) -> bool { + decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 +} + /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and /// returns the box with the number's response. fn value_field_in( @@ -564,9 +577,10 @@ fn value_field_box( let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. - let fine = range.end() - range.start() <= 10.0; + let fine = field_fine(&range, decimals); + let unit = field_step(&range, decimals); let (lo, hi) = (*range.start(), *range.end()); - let (step, grid) = arrow_step(ui, slot.id, if fine { 0.01 } else { 1.0 }); + let (step, grid) = arrow_step(ui, slot.id, unit); // new_child (not scope_builder): a scope would move the parent cursor back to the child rect. let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field)); let mut resp = { @@ -596,7 +610,6 @@ fn value_field_box( ui.memory_mut(|m| m.surrender_focus(resp.id)); } if s.steps != 0.0 { - let unit = if fine { 0.01 } else { 1.0 }; // Onto the step grid first, so a whole-number field drops its decimals (55.4 + 1 = 56). let v = ((*value / unit).round() + s.steps) * unit; let v = (v * 1e4).round() / 1e4; @@ -2035,6 +2048,29 @@ mod tests { let _ = press(&mut h, Modifiers::NONE, Key::ArrowDown); } + /// #5914: Exposure's ±20 stops keep 0.01 arrow steps when the field shows two decimals. + #[test] + fn arrow_keys_step_fractional_stops_when_decimals_are_set() { + let mut h = Harness::new_ui_state( + |ui, v: &mut f32| { + super::value_field_prec(ui, v, -20.0..=20.0, "", 74.0, 2); + }, + 0.0, + ); + h.run(); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run(); + h.key_press_modifiers(Modifiers::NONE, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.01).abs() < 1e-6); + h.key_press_modifiers(Modifiers::NONE, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.02).abs() < 1e-6); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.12).abs() < 1e-6); + } + #[test] fn arrow_keys_round_decimals_step_fine_fields_and_clamp() { for (start, mods, key, want) in [ From d089908158515d2500f8708bc8f71ea213453bfd Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:45:46 +0200 Subject: [PATCH 0254/1452] PSD: Offset and Custom smart filters open and save like Photoshop (#6304) Offset (filter.other.offset) and Custom (filter.other.custom) applied as smart filters were left out of PSD saves with a warning, and the same filters in a Photoshop PSD opened only as verbatim, non-editable entries. smart_map.rs now maps both ways in filterFX: - Offset: filterID/class Ofst; Hrzn and Vrtc as long (signed), Fl as the FlMd enum: Wrp = wrap, Rpt = repeat, Bckg = transparent ("Set to Transparent" on a layer). Unknown enum values, wrong types or missing keys keep the entry verbatim. - Custom: filterID/class Cstm; Scl and Ofst as long, Mtrx as 25 longs, row-major 5 x 5. Written as the engine renders it (no kernel = identity, missing cells 0, values clamped to -999..999, 1..9999, -9999..9999); fractional values have no PSD equivalent and are reported. On open, values outside Photoshop's ranges or a matrix that is not 25 longs keep the entry verbatim. Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back (Offset Wrap 0/0, Set to Background 12/-7, Repeat 5/3; Custom default sharpen and a blur at Scale 2, Offset 10). The measured files open as the editable filters and survive export and re-import unchanged (checked locally, not committed). Scorecard FILE-215-2 note and the file-format parity doc list the two filters (still partial). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 228 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 232 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..6040d3d0b0 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 16] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -154,6 +154,8 @@ const KNOWN: [Known; 14] = [ Known { class: "HghP", filter_id: code(b"HghP"), name: "High Pass...", command: "filter.other.highPass" }, Known { class: "Mxm ", filter_id: code(b"Mxm "), name: "Maximum...", command: "filter.other.maximum" }, Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, + Known { class: "Ofst", filter_id: code(b"Ofst"), name: "Offset...", command: "filter.other.offset" }, + Known { class: "Cstm", filter_id: code(b"Cstm"), name: "Custom...", command: "filter.other.custom" }, Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, @@ -258,6 +260,32 @@ fn curves_params(f: &Descriptor) -> Option { Some(J::Object(out)) } +/// Offset › Undefined Areas: the `FlMd` enum (`Fl `) and our `undefinedAreas`. On a layer +/// Photoshop labels `Bckg` "Set to Transparent"; a smart object is always a layer. +const OFFSET_FILL: [(&str, &str); 3] = [("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")]; + +/// Custom's ranges in Photoshop's dialog: matrix cells, Scale, Offset. +const CUSTOM_CELL: (i32, i32) = (-999, 999); +const CUSTOM_SCALE: (i32, i32) = (1, 9999); +const CUSTOM_OFFSET: (i32, i32) = (-9999, 9999); + +fn int_in(v: Option, (lo, hi): (i32, i32)) -> Option { + v.filter(|v| (lo..=hi).contains(v)) +} + +/// `filter.other.custom` params for a `Cstm` filter: `Mtrx` is 25 longs, row-major 5 × 5. Values +/// outside Photoshop's ranges are not modelled (the filter stays verbatim). +fn custom_params(f: &Descriptor) -> Option { + let Some(Value::List(m)) = f.get("Mtrx") else { return None }; + if m.len() != 25 { + return None; + } + let kernel: Option> = m.iter().map(|v| int_in(int_value(v), CUSTOM_CELL).map(|v| json!(v))).collect(); + let scale = int_in(int(f, "Scl "), CUSTOM_SCALE)?; + let offset = int_in(int(f, "Ofst"), CUSTOM_OFFSET)?; + Some(json!({"kernel": kernel?, "scale": scale, "offset": offset})) +} + /// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its /// settings) is not modelled. fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J)> { @@ -282,6 +310,11 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + ("Ofst", Some(f)) => { + let fill = OFFSET_FILL.iter().find(|(psd, _)| enum_of(f, "Fl ") == Some(psd.as_bytes()))?.1; + json!({"horizontal": int(f, "Hrzn")?, "vertical": int(f, "Vrtc")?, "undefinedAreas": fill}) + } + ("Cstm", Some(f)) => custom_params(f)?, ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -520,6 +553,15 @@ fn pi(p: &J, key: &str, default: f64) -> Value { Value::Integer(pf(p, key, default).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32) } +/// A whole-number setting as a long, clamped to `range` like the engine clamps it; a fraction +/// has no PSD equivalent (Photoshop stores these as integers). +fn whole(v: f64, (lo, hi): (i32, i32), what: &str) -> Result { + if v.fract() != 0.0 { + return Err(format!("{what} ({v}) is not a whole number")); + } + Ok(Value::Integer(v.clamp(f64::from(lo), f64::from(hi)) as i32)) +} + fn channel_ref(code: &[u8; 4]) -> Value { Value::Reference(vec![ReferenceItem::Enumerated { class: Class { name: UnicodeString::new_nul(""), class_id: Id::new("Chnl") }, @@ -595,6 +637,24 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + "Ofst" => { + // As the engine reads it: anything but repeat / transparent wraps. + let ours = p.get("undefinedAreas").and_then(J::as_str).unwrap_or("wrap"); + let fill = OFFSET_FILL.iter().find(|(_, o)| *o == ours).map_or("Wrp ", |(psd, _)| *psd); + d.with("Hrzn", pi(p, "horizontal", 0.0)).with("Vrtc", pi(p, "vertical", 0.0)).with("Fl ", enumv("FlMd", fill)) + } + "Cstm" => { + // The engine's identity kernel when none is given; missing cells are 0. + let kernel = p.get("kernel").and_then(J::as_array); + let cell = |i: usize| match kernel { + Some(a) => a.get(i).and_then(J::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0), + None => f64::from(u8::from(i == 12)), + }; + let m = (0..25).map(|i| whole(cell(i), CUSTOM_CELL, "a kernel value")).collect::, _>>()?; + d.with("Scl ", whole(pf(p, "scale", 1.0), CUSTOM_SCALE, "Scale")?) + .with("Ofst", whole(pf(p, "offset", 0.0), CUSTOM_OFFSET, "Offset")?) + .with("Mtrx", Value::List(m)) + } "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -1063,6 +1123,8 @@ mod tests { sf("filter.other.minimum", json!({"radius": 2.0})), sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + sf("filter.other.offset", json!({"horizontal": 12, "vertical": -7, "undefinedAreas": "transparent"})), + sf("filter.other.custom", json!({"kernel": SHARPEN, "scale": 1, "offset": 0})), sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), sf("image.adjustments.shadowsHighlights", json!({})), @@ -1080,6 +1142,170 @@ mod tests { } } + /// Photoshop's default Custom kernel: a 3 × 3 sharpen centred in the 5 × 5. + const SHARPEN: [i32; 25] = [0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, -1, 5, -1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0]; + + /// A `filterFXList` item as measured in Photoshop (applied through Action Manager to a smart + /// object, saved, `filterFX` read back). + fn measured_item(id: &[u8; 4], name: &str, fltr: Descriptor) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(id))) + } + + fn offset_fltr(h: Value, v: Value, fill: Value) -> Descriptor { + Descriptor::new("Ofst").with("Hrzn", h).with("Vrtc", v).with("Fl ", fill) + } + + fn custom_fltr(scale: Value, offset: Value, m: Vec) -> Descriptor { + Descriptor::new("Cstm").with("Scl ", scale).with("Ofst", offset).with("Mtrx", Value::List(m)) + } + + fn longs(m: &[i32]) -> Vec { + m.iter().map(|v| Value::Integer(*v)).collect() + } + + #[test] + fn offset_round_trips_every_fill_mode() { + for (fill, ours) in [("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")] { + for (h, v) in [(0, 0), (12, -7), (5, 3), (i32::MIN, i32::MAX)] { + let f = sf("filter.other.offset", json!({"horizontal": h, "vertical": v, "undefinedAreas": ours})); + let d = item_for_filter(&f).unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert_eq!(*fltr, offset_fltr(Value::Integer(h), Value::Integer(v), enumv("FlMd", fill))); + assert_eq!(int(&d, "filterID"), Some(1_332_114_292)); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + } + } + + /// The measured descriptors (Wrap 0/0, Set to Background 12/−7, Repeat 5/3) open as Offset. + #[test] + fn offset_measured_descriptors_open_as_offset() { + for (h, v, fill, ours) in [(0, 0, "Wrp ", "wrap"), (12, -7, "Bckg", "transparent"), (5, 3, "Rpt ", "repeat")] { + let item = measured_item(b"Ofst", "Offset...", offset_fltr(Value::Integer(h), Value::Integer(v), enumv("FlMd", fill))); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.other.offset"); + assert_eq!(f.params, json!({"horizontal": h, "vertical": v, "undefinedAreas": ours})); + // Written back exactly as measured. + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn offset_written_with_engine_defaults() { + // No params: 0, 0, Wrap Around (the engine's defaults); fractional pixels round. + let d = item_for_filter(&sf("filter.other.offset", json!({}))).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), offset_fltr(Value::Integer(0), Value::Integer(0), enumv("FlMd", "Wrp "))); + let d = item_for_filter(&sf("filter.other.offset", json!({"horizontal": 2.6, "vertical": 3e12, "undefinedAreas": "bogus"}))).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), offset_fltr(Value::Integer(3), Value::Integer(i32::MAX), enumv("FlMd", "Wrp "))); + } + + #[test] + fn malformed_offset_is_kept_verbatim() { + let i = Value::Integer; + for fltr in [ + offset_fltr(i(1), i(2), enumv("FlMd", "Xxxx")), + offset_fltr(i(1), i(2), Value::Integer(1)), + offset_fltr(Value::Double(1.5), i(2), enumv("FlMd", "Wrp ")), + offset_fltr(i(1), unit(b"#Pxl", f64::NAN), enumv("FlMd", "Wrp ")), + Descriptor::new("Ofst").with("Hrzn", i(1)).with("Fl ", enumv("FlMd", "Wrp ")), + Descriptor::new("Ofst"), + ] { + let item = measured_item(b"Ofst", "Offset...", fltr); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "kept byte for byte"); + } + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"Ofst"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn custom_round_trips() { + let edge: Vec = (0..25) + .map(|i| { + if i == 12 { + 8 + } else if (6..=18).contains(&i) && i % 5 != 0 && i % 5 != 4 { + -1 + } else { + 0 + } + }) + .collect(); + let full: Vec = (0..25).map(|i| i * 83 - 999).collect(); + for (kernel, scale, offset) in [(SHARPEN.to_vec(), 1, 0), (edge, 2, 10), (full, 9999, -9999), (vec![999; 25], 1, 9999)] { + let f = sf("filter.other.custom", json!({"kernel": kernel, "scale": scale, "offset": offset})); + let d = item_for_filter(&f).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), custom_fltr(Value::Integer(scale), Value::Integer(offset), longs(&kernel))); + assert_eq!(int(&d, "filterID"), Some(1_131_639_917)); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + } + + /// The measured descriptors (the default sharpen; a blur at Scale 2, Offset 10) open as Custom. + #[test] + fn custom_measured_descriptors_open_as_custom() { + let blur = [0, 0, 0, 0, 0, 0, 1, 2, 1, 0, 0, 2, 4, 2, 0, 0, 1, 2, 1, 0, 0, 0, 0, 0, 0]; + for (kernel, scale, offset) in [(SHARPEN, 1, 0), (blur, 2, 10)] { + let item = measured_item(b"Cstm", "Custom...", custom_fltr(Value::Integer(scale), Value::Integer(offset), longs(&kernel))); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.other.custom"); + assert_eq!(f.params, json!({"kernel": kernel, "scale": scale, "offset": offset})); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn custom_written_like_the_engine_reads_it() { + let fltr = |p: J| get_desc(&item_for_filter(&sf("filter.other.custom", p)).unwrap(), "Fltr").cloned().unwrap(); + // No kernel: the engine's identity; short kernels pad with 0; out-of-range values clamp. + let mut id = [0; 25]; + id[12] = 1; + assert_eq!(fltr(json!({})), custom_fltr(Value::Integer(1), Value::Integer(0), longs(&id))); + let mut short = [0; 25]; + short[0] = 3; + short[1] = -999; + assert_eq!(fltr(json!({"kernel": [3, -5000], "scale": 0, "offset": 20000})), custom_fltr(Value::Integer(1), Value::Integer(9999), longs(&short))); + // Fractions have no PSD equivalent. + for p in [json!({"kernel": [0.5]}), json!({"scale": 1.5}), json!({"offset": -0.25})] { + assert!(item_for_filter(&sf("filter.other.custom", p.clone())).is_err(), "{p}"); + } + } + + #[test] + fn malformed_custom_is_kept_verbatim() { + let i = Value::Integer; + let mut wrong_type = longs(&SHARPEN); + wrong_type[3] = Value::Double(1.0); + for fltr in [ + custom_fltr(i(1), i(0), longs(&SHARPEN[..24])), + custom_fltr(i(1), i(0), longs(&[0; 26])), + custom_fltr(i(1), i(0), wrong_type), + custom_fltr(i(1), i(0), longs(&[1000; 25])), + custom_fltr(i(0), i(0), longs(&SHARPEN)), + custom_fltr(i(10_000), i(0), longs(&SHARPEN)), + custom_fltr(i(1), i(-10_000), longs(&SHARPEN)), + custom_fltr(Value::Double(f64::NAN), i(0), longs(&SHARPEN)), + custom_fltr(i(1), unit(b"#Pxl", 1.0), longs(&SHARPEN)), + Descriptor::new("Cstm").with("Scl ", i(1)).with("Ofst", i(0)).with("Mtrx", i(5)), + Descriptor::new("Cstm").with("Scl ", i(1)).with("Mtrx", Value::List(longs(&SHARPEN))), + Descriptor::new("Cstm"), + ] { + let item = measured_item(b"Cstm", "Custom...", fltr); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "kept byte for byte"); + } + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..6756daa212 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Offset and Custom open and save as editable filters, #6304; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (16 mapped, Offset and Custom among them, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Offset (`Ofst`: `Hrzn`/`Vrtc` long, `Fl ` `FlMd` Wrap/Background/Repeat) and Custom (`Cstm`: `Scl `, `Ofst` long, 25-long `Mtrx`) map both ways in `filterFX`, matching descriptors measured in Photoshop (FILE-215-2, #6304) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..c131ddd78b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 16 filters (Offset and Custom among them) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..0ca0495082 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 16 filters (Offset and Custom among them) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From ef4e9214084a5ae56afc9ce01d36a77b4db923bc Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:46:42 +0200 Subject: [PATCH 0255/1452] PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298) Map three more smart filters both ways in filterFX (smart_map.rs): - Surface Blur: filterID 1135 (numeric), Fltr class `surfaceBlur`, `Rds ` UnitFloat #Pxl -> radius, `Thsh` long -> threshold. - Smart Blur: filterID `SmrB`, `Rds ` / `Thsh` doubles, `SmBQ` enum (SBQL/SBQM/SBQH -> low/medium/high), `SmBM` enum (SBMN/SBME/SBMO -> normal/edgeOnly/overlayEdge). - Radial Blur: filterID `RdlB`, `Amnt` long, `BlrM` enum (Spn/Zm -> spin/zoom), `BlrQ` enum (Drft/Gd/Bst -> draft/good/best). Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back. Every measured Fltr descriptor is written back byte for byte (checked against the measured files locally; they are not committed). Entries with missing keys, wrong types, out-of-range or NaN values, unknown enum values or keys we don't model stay verbatim, as does Radial Blur at its defaults (no Fltr stored). An off-centre Radial Blur centre and unknown enum names are reported on save, not guessed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 233 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 237 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..a1805b4542 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,10 +144,15 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 17] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, + // Surface Blur, Smart Blur and Radial Blur: keys, value types and enums measured in Photoshop. + Known { class: "surfaceBlur", filter_id: 1135, name: "Surface Blur...", command: "filter.blur.surfaceBlur" }, + Known { class: "SmrB", filter_id: code(b"SmrB"), name: "Smart Blur...", command: "filter.blur.smartBlur" }, + // Radial Blur at its defaults stores no `Fltr` (`hasoptions` false); that entry is kept verbatim. + Known { class: "RdlB", filter_id: code(b"RdlB"), name: "Radial Blur...", command: "filter.blur.radialBlur" }, Known { class: "UnsM", filter_id: code(b"UnsM"), name: "Unsharp Mask...", command: "filter.sharpen.unsharpMask" }, Known { class: "AdNs", filter_id: code(b"AdNs"), name: "Add Noise...", command: "filter.noise.addNoise" }, Known { class: "Mdn ", filter_id: code(b"Mdn "), name: "Median...", command: "filter.noise.median" }, @@ -181,6 +186,45 @@ fn int(d: &Descriptor, key: &str) -> Option { } } +/// `v` when it lies in `lo..=hi` (the filter dialog's range); out-of-range settings are not +/// opened as editable filters, so a re-save keeps Photoshop's value instead of a clamped one. +fn in_range(v: f64, lo: f64, hi: f64) -> Option { + (lo..=hi).contains(&v).then_some(v) +} + +/// Integer `key` of `f` when it lies in `lo..=hi`. +fn int_in(f: &Descriptor, key: &str, lo: i32, hi: i32) -> Option { + int(f, key).filter(|v| (lo..=hi).contains(v)) +} + +/// True when `f` holds only the given keys. A filter with a setting we don't know about (Radial +/// Blur's off-centre centre, for one) is kept verbatim rather than losing that setting. +fn only_keys(f: &Descriptor, keys: &[&str]) -> bool { + f.items.iter().all(|(k, _)| keys.iter().any(|x| k.is(x))) +} + +/// Our name for enum `key`'s value in `f`, from `(PSD value, our name)` pairs. +fn enum_name(f: &Descriptor, key: &str, table: &[(&[u8; 4], &'static str)]) -> Option<&'static str> { + let v = enum_of(f, key)?; + table.iter().find(|(c, _)| v == &c[..]).map(|(_, n)| *n) +} + +/// Enum `type_id` holding the PSD value of our name `p[key]` (`default` when absent). +fn enum_param(p: &J, key: &str, default: &str, type_id: &str, table: &[(&[u8; 4], &'static str)]) -> Result { + let name = match p.get(key) { + None | Some(J::Null) => default, + Some(v) => v.as_str().ok_or_else(|| format!("setting `{key}` is not text"))?, + }; + let code = table.iter().find(|(_, n)| *n == name).map(|(c, _)| **c).ok_or_else(|| format!("{key} `{name}` has no PSD equivalent"))?; + Ok(Value::Enumerated { type_id: Id::new(type_id), value: Id::Code(code) }) +} + +/// Smart Blur `SmBQ` / `SmBM` and Radial Blur `BlrM` / `BlrQ` values (measured in Photoshop). +const SMART_BLUR_QUALITY: [(&[u8; 4], &str); 3] = [(b"SBQL", "low"), (b"SBQM", "medium"), (b"SBQH", "high")]; +const SMART_BLUR_MODE: [(&[u8; 4], &str); 3] = [(b"SBMN", "normal"), (b"SBME", "edgeOnly"), (b"SBMO", "overlayEdge")]; +const RADIAL_METHOD: [(&[u8; 4], &str); 2] = [(b"Spn ", "spin"), (b"Zm ", "zoom")]; +const RADIAL_QUALITY: [(&[u8; 4], &str); 3] = [(b"Drft", "draft"), (b"Gd ", "good"), (b"Bst ", "best")]; + fn channel_key(d: &Descriptor) -> Option<&'static str> { let Some(Value::Reference(items)) = d.get("Chnl") else { return None }; let Some(ReferenceItem::Enumerated { value, .. }) = items.first() else { return None }; @@ -280,6 +324,21 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) }; json!({"amount": px(f, "Nose")?, "distribution": dist, "monochromatic": mono, "seed": int(f, "FlRs").unwrap_or(0)}) } + ("surfaceBlur", Some(f)) if only_keys(f, &["Rds ", "Thsh"]) => json!({ + "radius": in_range(px(f, "Rds ")?, 1.0, 100.0)?, + "threshold": int_in(f, "Thsh", 2, 255)?, + }), + ("SmrB", Some(f)) if only_keys(f, &["Rds ", "Thsh", "SmBQ", "SmBM"]) => json!({ + "radius": in_range(px(f, "Rds ")?, 0.1, 100.0)?, + "threshold": in_range(px(f, "Thsh")?, 0.1, 100.0)?, + "quality": enum_name(f, "SmBQ", &SMART_BLUR_QUALITY)?, + "mode": enum_name(f, "SmBM", &SMART_BLUR_MODE)?, + }), + ("RdlB", Some(f)) if only_keys(f, &["Amnt", "BlrM", "BlrQ"]) => json!({ + "amount": int_in(f, "Amnt", 1, 100)?, + "method": enum_name(f, "BlrM", &RADIAL_METHOD)?, + "quality": enum_name(f, "BlrQ", &RADIAL_QUALITY)?, + }), ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), ("Lvls", Some(f)) => levels_params(f)?, @@ -593,6 +652,21 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { .with("Mnch", Value::Boolean(p.get("monochromatic").and_then(J::as_bool).unwrap_or(false))) .with("FlRs", pi(p, "seed", 0.0)) } + "surfaceBlur" => d.with("Rds ", unit(b"#Pxl", pf(p, "radius", 5.0))).with("Thsh", pi(p, "threshold", 15.0)), + "SmrB" => d + .with("Rds ", Value::Double(pf(p, "radius", 3.0))) + .with("Thsh", Value::Double(pf(p, "threshold", 25.0))) + .with("SmBQ", enum_param(p, "quality", "high", "SmBQ", &SMART_BLUR_QUALITY)?) + .with("SmBM", enum_param(p, "mode", "normal", "SmBM", &SMART_BLUR_MODE)?), + "RdlB" => { + // Photoshop wrote no centre key for a centred blur; an off-centre one is unmeasured. + if ["centerX", "centerY"].iter().any(|k| (pf(p, k, 0.5) - 0.5).abs() > 1e-9) { + return Err("an off-centre Radial Blur centre is not written to PSD".into()); + } + d.with("Amnt", pi(p, "amount", 10.0)) + .with("BlrM", enum_param(p, "method", "spin", "BlrM", &RADIAL_METHOD)?) + .with("BlrQ", enum_param(p, "quality", "good", "BlrQ", &RADIAL_QUALITY)?) + } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), "Lvls" => d.with("Adjs", adjustments(p, false)?), @@ -1055,6 +1129,13 @@ mod tests { sf("filter.blur.gaussianBlur", json!({"radius": 4.0})), sf("filter.blur.boxBlur", json!({"radius": 5.0})), sf("filter.blur.motionBlur", json!({"angle": -20, "distance": 10.0})), + sf("filter.blur.surfaceBlur", json!({"radius": 12.0, "threshold": 40})), + sf("filter.blur.smartBlur", json!({"radius": 5.0, "threshold": 25.0, "quality": "high", "mode": "normal"})), + sf("filter.blur.smartBlur", json!({"radius": 3.0, "threshold": 30.0, "quality": "medium", "mode": "overlayEdge"})), + sf("filter.blur.smartBlur", json!({"radius": 0.5, "threshold": 0.1, "quality": "low", "mode": "edgeOnly"})), + sf("filter.blur.radialBlur", json!({"amount": 10, "method": "spin", "quality": "draft"})), + sf("filter.blur.radialBlur", json!({"amount": 20, "method": "zoom", "quality": "good"})), + sf("filter.blur.radialBlur", json!({"amount": 100, "method": "spin", "quality": "best"})), sf("filter.sharpen.unsharpMask", json!({"amount": 150.0, "radius": 2.0, "threshold": 0})), sf("filter.noise.addNoise", json!({"amount": 12.0, "distribution": "uniform", "monochromatic": false, "seed": 1234})), sf("filter.noise.median", json!({"radius": 3.0})), @@ -1103,6 +1184,156 @@ mod tests { assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); } + /// A `filterFXList` item with filter `fltr` and `filterID`, as Photoshop saves it. + fn ps_item(name: &str, fltr: Descriptor, filter_id: i32) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(filter_id)) + } + + fn surface_blur(rds: f64, thsh: i32) -> Descriptor { + Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", rds)).with("Thsh", Value::Integer(thsh)) + } + + fn smart_blur(rds: f64, thsh: f64, q: &str, m: &str) -> Descriptor { + Descriptor::new("SmrB").with("Rds ", Value::Double(rds)).with("Thsh", Value::Double(thsh)).with("SmBQ", enumv("SmBQ", q)).with("SmBM", enumv("SmBM", m)) + } + + fn radial_blur(amnt: i32, m: &str, q: &str) -> Descriptor { + Descriptor::new("RdlB").with("Amnt", Value::Integer(amnt)).with("BlrM", enumv("BlrM", m)).with("BlrQ", enumv("BlrQ", q)) + } + + /// The `Fltr` shapes measured in Photoshop (each filter applied to a smart object through + /// Action Manager, saved, and `filterFX` read back) open as our filters and are written back + /// with the same keys, value types, units, enums and filter ids. + #[test] + fn blur_family_matches_photoshop_shapes() { + let smart = code(b"SmrB"); + let radial = code(b"RdlB"); + assert_eq!((smart, radial), (1_399_681_602, 1_382_313_026)); + let cases = [ + (surface_blur(5.0, 15), 1135, "filter.blur.surfaceBlur", json!({"radius": 5.0, "threshold": 15})), + (surface_blur(12.0, 40), 1135, "filter.blur.surfaceBlur", json!({"radius": 12.0, "threshold": 40})), + ( + smart_blur(5.0, 25.0, "SBQH", "SBMN"), + smart, + "filter.blur.smartBlur", + json!({"radius": 5.0, "threshold": 25.0, "quality": "high", "mode": "normal"}), + ), + ( + smart_blur(3.0, 30.0, "SBQL", "SBME"), + smart, + "filter.blur.smartBlur", + json!({"radius": 3.0, "threshold": 30.0, "quality": "low", "mode": "edgeOnly"}), + ), + ( + smart_blur(3.0, 30.0, "SBQM", "SBMO"), + smart, + "filter.blur.smartBlur", + json!({"radius": 3.0, "threshold": 30.0, "quality": "medium", "mode": "overlayEdge"}), + ), + (radial_blur(10, "Spn ", "Drft"), radial, "filter.blur.radialBlur", json!({"amount": 10, "method": "spin", "quality": "draft"})), + (radial_blur(20, "Spn ", "Bst "), radial, "filter.blur.radialBlur", json!({"amount": 20, "method": "spin", "quality": "best"})), + (radial_blur(20, "Zm ", "Gd "), radial, "filter.blur.radialBlur", json!({"amount": 20, "method": "zoom", "quality": "good"})), + ]; + for (fltr, id, command, params) in cases { + let item = ps_item("x", fltr.clone(), id); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms), "{fltr:?}"); + let out = item_for_filter(&f).unwrap(); + assert_eq!(out.get("filterID"), Some(&Value::Integer(id))); + assert_eq!(out.get("hasoptions"), Some(&Value::Boolean(true))); + // Byte-identical filter descriptor: class id, four-char keys, types, units and enums. + let Some(Value::Descriptor(w)) = out.get("Fltr") else { panic!("no Fltr for {command}") }; + assert_eq!(w.to_bytes(), fltr.to_bytes(), "{command}"); + } + // Surface Blur's class is a string id; Smart Blur's and Radial Blur's are four-char codes. + assert_eq!(surface_blur(1.0, 2).class_id, Id::Str(b"surfaceBlur".to_vec())); + assert_eq!(radial_blur(1, "Spn ", "Gd ").class_id, Id::Code(*b"RdlB")); + // Unset options are written with the dialog defaults. + let fltr = |c: &str, p: J| item_for_filter(&sf(c, p)).unwrap().get("Fltr").cloned(); + assert_eq!(fltr("filter.blur.surfaceBlur", json!({})), Some(Value::Descriptor(surface_blur(5.0, 15)))); + assert_eq!(fltr("filter.blur.smartBlur", json!({})), Some(Value::Descriptor(smart_blur(3.0, 25.0, "SBQH", "SBMN")))); + assert_eq!(fltr("filter.blur.radialBlur", json!({"centerX": 0.5, "centerY": 0.5})), Some(Value::Descriptor(radial_blur(10, "Spn ", "Gd ")))); + } + + /// Malformed or unmodelled blur descriptors stay verbatim (and are written back unchanged); + /// settings PSD can't hold are reported, never a panic. + #[test] + fn blur_family_malformed_is_kept_verbatim() { + let smart = code(b"SmrB"); + let radial = code(b"RdlB"); + let text = |s: &str| Value::Text(UnicodeString::new_nul(s)); + let bad = [ + // Missing keys. + (Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", 5.0)), 1135), + (Descriptor::new("SmrB").with("Rds ", Value::Double(3.0)).with("Thsh", Value::Double(25.0)), smart), + (Descriptor::new("RdlB").with("Amnt", Value::Integer(10)).with("BlrM", enumv("BlrM", "Spn ")), radial), + // Wrong types. + (Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", 5.0)).with("Thsh", Value::Double(15.0)), 1135), + ( + Descriptor::new("SmrB") + .with("Rds ", text("3")) + .with("Thsh", Value::Double(25.0)) + .with("SmBQ", enumv("SmBQ", "SBQH")) + .with("SmBM", enumv("SmBM", "SBMN")), + smart, + ), + (Descriptor::new("RdlB").with("Amnt", text("10")).with("BlrM", enumv("BlrM", "Spn ")).with("BlrQ", enumv("BlrQ", "Gd ")), radial), + (Descriptor::new("RdlB").with("Amnt", Value::Integer(10)).with("BlrM", Value::Integer(1)).with("BlrQ", enumv("BlrQ", "Gd ")), radial), + // Out of range, NaN and infinity. + (surface_blur(0.5, 15), 1135), + (surface_blur(5.0, 300), 1135), + (surface_blur(f64::NAN, 15), 1135), + (smart_blur(f64::INFINITY, 25.0, "SBQH", "SBMN"), smart), + (smart_blur(3.0, 0.0, "SBQH", "SBMN"), smart), + (radial_blur(0, "Spn ", "Gd "), radial), + (radial_blur(101, "Zm ", "Gd "), radial), + // Unknown enum values. + (smart_blur(3.0, 25.0, "SBQX", "SBMN"), smart), + (smart_blur(3.0, 25.0, "Hgh ", "SBMN"), smart), + (radial_blur(10, "Twrl", "Gd "), radial), + (radial_blur(10, "Spn ", "Hgh "), radial), + // A key we don't model (an off-centre Radial Blur would carry one). + (radial_blur(10, "Zm ", "Gd ").with("Cntr", Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(10.0)))), radial), + (surface_blur(5.0, 15).with("Edg ", Value::Integer(1)), 1135), + ]; + for (fltr, id) in bad { + let item = ps_item("x", fltr.clone(), id); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{fltr:?}"); + // Compared as bytes: a NaN setting is not equal to itself. + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "{fltr:?}"); + } + // Radial Blur at its defaults has no `Fltr` (measured): its values are not stored, so the + // entry stays verbatim instead of guessing them. + let no_opts = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(radial)); + assert_eq!(filter_from_item(&no_opts).command, UNSUPPORTED_FILTER); + // Settings with no PSD equivalent are errors when writing. + for (command, p) in [ + ("filter.blur.smartBlur", json!({"quality": "ultra"})), + ("filter.blur.smartBlur", json!({"mode": 3})), + ("filter.blur.radialBlur", json!({"method": "twirl"})), + ("filter.blur.radialBlur", json!({"quality": ["best"]})), + ("filter.blur.radialBlur", json!({"centerX": 0.25})), + ("filter.blur.radialBlur", json!({"centerY": 1.0})), + ] { + assert!(item_for_filter(&sf(command, p.clone())).is_err(), "{command} {p}"); + } + // Hostile numbers write without panicking. + for p in [json!({"radius": f64::MAX, "threshold": -1e300, "amount": 1e300}), json!({"radius": "x", "threshold": null}), json!(null), json!([1, 2])] { + for command in ["filter.blur.surfaceBlur", "filter.blur.smartBlur", "filter.blur.radialBlur"] { + let _ = item_for_filter(&sf(command, p.clone())); + } + } + } + /// A Filter Gallery item as Photopea writes it (Chrome, Detail 12, Smoothness 4). fn gallery_item_with(effects: Vec) -> Descriptor { let rgb = diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..d01b054261 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Surface Blur, Smart Blur and Radial Blur mapped both ways, #6298; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (17 mapped, including Surface, Smart and Radial Blur, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Surface Blur (filterID 1135, `surfaceBlur`), Smart Blur (`SmrB`) and Radial Blur (`RdlB`) open as editable filters and save with the keys, types and enums measured in Photoshop; out-of-range, unknown-enum or extra-key entries (an off-centre Radial Blur) stay verbatim (#6298, FILE-215-2) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..e0c5eec3b6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 17 filters (Surface, Smart and Radial Blur added #6298) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..8f4062d710 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 17 filters (Surface, Smart and Radial Blur added #6298) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From 7d36b417e575b50c4ff402fac59b41c206c5a8c1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:20:55 +0200 Subject: [PATCH 0256/1452] =?UTF-8?q?PSD:=20parameterless=20smart=20filter?= =?UTF-8?q?s=20(Blur,=20Sharpen,=20Find=20Edges,=20Solarize=E2=80=A6)=20op?= =?UTF-8?q?en=20and=20save=20like=20Photoshop=20(#6296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen, Sharpen More, Sharpen Edges and Solarize now map both ways in a smart object's filterFX. Photoshop stores these filters with hasoptions false, no Fltr descriptor and only the four-char filterID as a long (Blr , BlrM, Avrg, Dspc, FndE, Fct , Frgm, Shrp, ShrM, ShrE, Slrz). PSD saves write exactly that item (Nm, blendOptions, enab, hasoptions, FrgC, BckC, filterID) instead of dropping the filter with a warning, and Photoshop's entries open as the editable PhotoCraft filter instead of a verbatim pass-through. An entry that carries a Fltr with settings, or an unknown or mistyped filterID, is still kept verbatim. Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back. Scorecard FILE-215-2 note and docs/file-format-parity.md updated (25 filters mapped). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 112 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..be0a376c79 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 25] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -160,8 +160,24 @@ const KNOWN: [Known; 14] = [ Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). Known { class: "adaptCorrect", filter_id: 781, name: "Shado&ws/Highlights...", command: "image.adjustments.shadowsHighlights" }, + // Filters without options (measured in Photoshop): `hasoptions` false, no `Fltr`, only the + // four-char `filterID`. The class is that code; see `NO_OPTIONS`. + Known { class: "Blr ", filter_id: code(b"Blr "), name: "Blur", command: "filter.blur.blur" }, + Known { class: "BlrM", filter_id: code(b"BlrM"), name: "Blur More", command: "filter.blur.blurMore" }, + Known { class: "Avrg", filter_id: code(b"Avrg"), name: "Average", command: "filter.blur.average" }, + Known { class: "Dspc", filter_id: code(b"Dspc"), name: "Despeckle", command: "filter.noise.despeckle" }, + Known { class: "FndE", filter_id: code(b"FndE"), name: "Find Edges", command: "filter.stylize.findEdges" }, + Known { class: "Fct ", filter_id: code(b"Fct "), name: "Facet", command: "filter.pixelate.facet" }, + Known { class: "Frgm", filter_id: code(b"Frgm"), name: "Fragment", command: "filter.pixelate.fragment" }, + Known { class: "Shrp", filter_id: code(b"Shrp"), name: "Sharpen", command: "filter.sharpen.sharpen" }, + Known { class: "ShrM", filter_id: code(b"ShrM"), name: "Sharpen More", command: "filter.sharpen.sharpenMore" }, + Known { class: "ShrE", filter_id: code(b"ShrE"), name: "Sharpen Edges", command: "filter.sharpen.sharpenEdges" }, + Known { class: "Slrz", filter_id: code(b"Slrz"), name: "Solarize", command: "filter.stylize.solarize" }, ]; +/// Classes of the filters that have no options: Photoshop stores them without a `Fltr`. +const NO_OPTIONS: [&str; 11] = ["Blr ", "BlrM", "Avrg", "Dspc", "FndE", "Fct ", "Frgm", "Shrp", "ShrM", "ShrE", "Slrz"]; + fn known_by_command(command: &str) -> Option<&'static Known> { KNOWN.iter().find(|k| k.command == command) } @@ -264,6 +280,7 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) let k = KNOWN.iter().find(|k| k.class == class)?; let params = match (class, f) { ("adaptCorrect", None) => json!({}), + (c, None) if NO_OPTIONS.contains(&c) => json!({}), (_, None) => return None, ("GsnB" | "boxblur" | "Mdn " | "HghP" | "Mxm " | "Mnm ", Some(f)) => json!({"radius": px(f, "Rds ")?}), ("MtnB", Some(f)) => json!({"angle": int(f, "Angl")?, "distance": px(f, "Dstn")?}), @@ -603,6 +620,9 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } return Ok(None); } + // No settings to write; any parameter keys an old record carries have no effect on the + // filter either, so nothing is lost. + c if NO_OPTIONS.contains(&c) => return Ok(None), _ => return Err("not a recognized filter".into()), })) } @@ -1080,6 +1100,96 @@ mod tests { } } + /// The filters without options, their measured `filterID`s (as Photoshop wrote them). + const MEASURED_NO_OPTIONS: [(&str, i32); 11] = [ + ("filter.blur.blur", 1114403360), + ("filter.blur.blurMore", 1114403405), + ("filter.blur.average", 1098281575), + ("filter.noise.despeckle", 1148416099), + ("filter.stylize.findEdges", 1181639749), + ("filter.pixelate.facet", 1180922912), + ("filter.pixelate.fragment", 1181902701), + ("filter.sharpen.sharpen", 1399353968), + ("filter.sharpen.sharpenMore", 1399353933), + ("filter.sharpen.sharpenEdges", 1399353925), + ("filter.stylize.solarize", 1399616122), + ]; + + /// A filter without options as Photoshop saves it: `hasoptions` false, no `Fltr`, a localized + /// name and the current colours. + fn photoshop_no_options_item(name: &str, filter_id: Value) -> Descriptor { + let rgb = |r: f64, g: f64, b: f64| { + Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(r)).with("Grn ", Value::Double(g)).with("Bl ", Value::Double(b))) + }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 60.0)).with("Md ", enumv("BlnM", "Mltp")))) + .with("enab", Value::Boolean(false)) + .with("hasoptions", Value::Boolean(false)) + .with("FrgC", rgb(167.0, 157.0, 147.0)) + .with("BckC", rgb(255.0, 255.0, 255.0)) + .with("filterID", filter_id) + } + + #[test] + fn filters_without_options_round_trip_in_the_measured_shape() { + let keys = ["Nm ", "blendOptions", "enab", "hasoptions", "FrgC", "BckC", "filterID"]; + for (command, id) in MEASURED_NO_OPTIONS { + let mut f = sf(command, json!({})); + f.blend = BlendMode::Overlay; + f.opacity = 0.75; + f.visible = false; + let d = item_for_filter(&f).unwrap(); + let got: Vec = d.items.iter().map(|(k, _)| String::from_utf8_lossy(k.as_bytes()).into_owned()).collect(); + assert_eq!(got, keys, "{command}"); + assert_eq!(d.get("hasoptions"), Some(&Value::Boolean(false)), "{command}"); + assert_eq!(d.get("filterID"), Some(&Value::Integer(id)), "{command}"); + let back = filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()); + assert_eq!(back, f, "{command}"); + // Keys an old record may carry have no effect on these filters: still written. + let extra = item_for_filter(&sf(command, json!({"radius": 3.0, "layer": "L"}))).unwrap(); + assert_eq!(extra.get("filterID"), Some(&Value::Integer(id)), "{command}"); + for p in [json!(null), json!("x"), json!([1, 2])] { + assert!(item_for_filter(&sf(command, p)).is_ok(), "{command}"); + } + } + } + + #[test] + fn photoshop_filters_without_options_open_editable() { + for (command, id) in MEASURED_NO_OPTIONS { + let item = photoshop_no_options_item("Weichzeichnen", Value::Integer(id)); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, command); + assert_eq!(f.params, json!({})); + assert_eq!((f.visible, f.blend), (false, BlendMode::Multiply)); + assert!((f.opacity - 0.6).abs() < 1e-6); + } + } + + #[test] + fn malformed_filters_without_options_are_kept_verbatim() { + let blur = code(b"Blr "); + let cases = [ + // An id no filter has, a missing id, ids of the wrong type. + photoshop_no_options_item("X", Value::Integer(code(b"Zzzz"))), + Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)), + photoshop_no_options_item("X", Value::Double(f64::from(blur))), + photoshop_no_options_item("X", Value::Double(f64::NAN)), + photoshop_no_options_item("X", Value::Text(UnicodeString::new_nul("Blr "))), + photoshop_no_options_item("X", Value::Integer(-1)), + // Settings we don't know of on a filter that has none. + photoshop_no_options_item("X", Value::Integer(blur)).with("Fltr", Value::Descriptor(Descriptor::new("Blr ").with("Amnt", Value::Integer(5)))), + ]; + for item in cases { + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + // Written back (a bare item gains blending and visibility) and still not editable. + let back = filter_from_item(&item_for_filter(&f).unwrap()); + assert_eq!((back.command.as_str(), &back.params["filterID"]), (UNSUPPORTED_FILTER, &f.params["filterID"])); + } + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..6bfbac17e3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters without options, Blur to Solarize, open and save editable, FILE-215-2; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (25 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters without options (Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen, Sharpen More, Sharpen Edges, Solarize) map both ways as Photoshop stores them: `hasoptions` false, no `Fltr`, the four-char `filterID` (FILE-215-2) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..6dd813f4da 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 25 filters (incl. Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen, Sharpen More, Sharpen Edges and Solarize, which have no options) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..11088ab5cb 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 25 filters (incl. Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen, Sharpen More, Sharpen Edges and Solarize, which have no options) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From a99ad58591839e888239c68c1de64eb6dd4eb6df Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:48:16 +0200 Subject: [PATCH 0257/1452] PSD: Diffuse, Trace Contour, Crystallize and Pointillize smart filters open and save like Photoshop (#6302) smart_map.rs maps four more filters both ways in filterFX: - Diffuse: filterID `Dfs `, Fltr `Dfs ` { Md = DfsM enum Nrml / DrkO / LghO / anisotropic (long id) } <-> filter.stylize.diffuse `mode`. - Trace Contour: `TrcC` { Lvl long 0-255, Edg = CntE enum Upr / Lwr } <-> filter.stylize.traceContour `level`, `edge`. - Crystallize: `Crst` { ClSz long 3-300 } <-> filter.pixelate.crystallize. - Pointillize: `Pntl` { ClSz long 3-300 } plus the entry's BckC as the `background` colour <-> filter.pixelate.pointillize. Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back. Our random `seed` has no PSD key and is not written. Missing keys, wrong value types, out-of-range numbers and unknown enum values keep the entry verbatim; values with no PSD equivalent on save are reported instead of guessed. Scorecard FILE-215-2 note and docs/file-format-parity.md updated (stays partial). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 227 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 227 insertions(+), 9 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..f10742d25b 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 18] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -156,6 +156,10 @@ const KNOWN: [Known; 14] = [ Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, + Known { class: "Dfs ", filter_id: code(b"Dfs "), name: "Diffuse...", command: "filter.stylize.diffuse" }, + Known { class: "TrcC", filter_id: code(b"TrcC"), name: "Trace Contour...", command: "filter.stylize.traceContour" }, + Known { class: "Crst", filter_id: code(b"Crst"), name: "Crystallize...", command: "filter.pixelate.crystallize" }, + Known { class: "Pntl", filter_id: code(b"Pntl"), name: "Pointillize...", command: "filter.pixelate.pointillize" }, Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). @@ -258,9 +262,29 @@ fn curves_params(f: &Descriptor) -> Option { Some(J::Object(out)) } +/// Diffuse's `Md ` (`DfsM` enum, measured in Photoshop) ↔ our `mode`. +const DIFFUSE_MODES: [(&str, &str); 4] = [("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("anisotropic", "anisotropic")]; + +/// Trace Contour's `Edg ` (`CntE` enum, measured in Photoshop) ↔ our `edge`. +const TRACE_EDGES: [(&str, &str); 2] = [("Upr ", "upper"), ("Lwr ", "lower")]; + +/// The `long` value of `key` when it lies in `lo..=hi`. +fn int_in(d: &Descriptor, key: &str, lo: i32, hi: i32) -> Option { + int(d, key).filter(|v| (lo..=hi).contains(v)) +} + +/// Our name for the enum value of `key` (of type `type_id`) through `table` (Photoshop's value, ours). +fn enum_param(d: &Descriptor, key: &str, type_id: &str, table: &[(&str, &'static str)]) -> Option<&'static str> { + let Some(Value::Enumerated { type_id: t, value }) = d.get(key) else { return None }; + if t.as_bytes() != type_id.as_bytes() { + return None; + } + table.iter().find(|(psd, _)| value.as_bytes() == psd.as_bytes()).map(|(_, ours)| *ours) +} + /// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its -/// settings) is not modelled. -fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J)> { +/// settings) is not modelled. `item` is the whole `filterFX` entry (Pointillize reads its `BckC`). +fn known_params(class: &str, f: Option<&Descriptor>, item: &Descriptor) -> Option<(&'static str, J)> { let k = KNOWN.iter().find(|k| k.class == class)?; let params = match (class, f) { ("adaptCorrect", None) => json!({}), @@ -282,6 +306,17 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + ("Dfs ", Some(f)) => json!({"mode": enum_param(f, "Md ", "DfsM", &DIFFUSE_MODES)?}), + ("TrcC", Some(f)) => json!({"level": int_in(f, "Lvl ", 0, 255)?, "edge": enum_param(f, "Edg ", "CntE", &TRACE_EDGES)?}), + ("Crst", Some(f)) => json!({"cellSize": int_in(f, "ClSz", CELL_SIZE.0, CELL_SIZE.1)?}), + ("Pntl", Some(f)) => { + let mut p = json!({"cellSize": int_in(f, "ClSz", CELL_SIZE.0, CELL_SIZE.1)?}); + // Pointillize paints the gaps in the background colour the entry carries. + if item.get("BckC").is_some() { + p["background"] = get_desc(item, "BckC").and_then(rgbc_colour)?; + } + p + } ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -470,7 +505,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), - c => c.and_then(|c| known_params(c, fltr)), + c => c.and_then(|c| known_params(c, fltr, item)), }; let (command, params) = match known { Some((c, p)) => (c.to_string(), p), @@ -574,6 +609,24 @@ fn adjustments(p: &J, curves: bool) -> Result { Ok(Value::List(list)) } +/// Crystallize's and Pointillize's cell size range (`ClSz`, a `long`). +const CELL_SIZE: (i32, i32) = (3, 300); + +/// Our `key` (a string) as Photoshop's enum value through `table` (Photoshop's value, ours). +fn enum_out(p: &J, key: &str, default: &str, type_id: &str, table: &[(&str, &str)]) -> Result { + let ours = match p.get(key) { + None => default, + Some(v) => v.as_str().ok_or_else(|| format!("`{key}` is not a string"))?, + }; + let (psd, _) = table.iter().find(|(_, o)| *o == ours).ok_or_else(|| format!("{key} `{ours}` has no PSD equivalent"))?; + Ok(enumv(type_id, psd)) +} + +/// `key` rounded to a `long` in `lo..=hi`. +fn pi_in(p: &J, key: &str, default: f64, lo: i32, hi: i32) -> Value { + Value::Integer(pf(p, key, default).round().clamp(f64::from(lo), f64::from(hi)) as i32) +} + /// The `Fltr` descriptor of a modelled filter (`None` = written without options). fn fltr_for(k: &Known, p: &J) -> Result, String> { let d = Descriptor::new(k.class); @@ -595,6 +648,11 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + // Photoshop stores no random seed for these: `seed` is ours only and is not written. + "Dfs " => d.with("Md ", enum_out(p, "mode", "normal", "DfsM", &DIFFUSE_MODES)?), + "TrcC" => d.with("Lvl ", pi_in(p, "level", 128.0, 0, 255)).with("Edg ", enum_out(p, "edge", "lower", "CntE", &TRACE_EDGES)?), + "Crst" => d.with("ClSz", pi_in(p, "cellSize", 10.0, CELL_SIZE.0, CELL_SIZE.1)), + "Pntl" => d.with("ClSz", pi_in(p, "cellSize", 5.0, CELL_SIZE.0, CELL_SIZE.1)), "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -633,13 +691,15 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { } let k = known_by_command(&f.command).ok_or("this filter has no PSD equivalent")?; let fltr = fltr_for(k, &f.params)?; + // Pointillize's background colour is the entry's `BckC`; the others write the defaults. + let background = if k.class == "Pntl" { colour_rgbc(f.params.get("background"), 255.0)? } else { rgbc(255.0) }; let mut d = Descriptor::new("filterFX") .with("Nm ", Value::Text(UnicodeString::new_nul(k.name))) .with("blendOptions", blend_options(f)) .with("enab", Value::Boolean(f.visible)) .with("hasoptions", Value::Boolean(fltr.is_some())) .with("FrgC", rgbc(0.0)) - .with("BckC", rgbc(255.0)); + .with("BckC", background); if let Some(x) = fltr { d = d.with("Fltr", Value::Descriptor(x)); } @@ -1063,6 +1123,19 @@ mod tests { sf("filter.other.minimum", json!({"radius": 2.0})), sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + sf("filter.stylize.diffuse", json!({"mode": "normal"})), + sf("filter.stylize.diffuse", json!({"mode": "darkenOnly"})), + sf("filter.stylize.diffuse", json!({"mode": "lightenOnly"})), + sf("filter.stylize.diffuse", json!({"mode": "anisotropic"})), + sf("filter.stylize.traceContour", json!({"level": 60, "edge": "lower"})), + sf("filter.stylize.traceContour", json!({"level": 128, "edge": "upper"})), + sf("filter.stylize.traceContour", json!({"level": 0, "edge": "upper"})), + sf("filter.stylize.traceContour", json!({"level": 255, "edge": "lower"})), + sf("filter.pixelate.crystallize", json!({"cellSize": 10})), + sf("filter.pixelate.crystallize", json!({"cellSize": 3})), + sf("filter.pixelate.crystallize", json!({"cellSize": 300})), + sf("filter.pixelate.pointillize", json!({"cellSize": 7, "background": [1.0, 1.0, 1.0, 1.0]})), + sf("filter.pixelate.pointillize", json!({"cellSize": 300, "background": [0.0, 0.5, 1.0, 1.0]})), sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), sf("image.adjustments.shadowsHighlights", json!({})), @@ -1103,6 +1176,150 @@ mod tests { assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); } + /// A `filterFX` entry shaped as Photoshop saves it (measured in Photoshop: the filter applied + /// through Action Manager to a smart object, the PSD saved, `filterFX` read back). + fn photoshop_item(filter_id: &[u8; 4], fltr: Descriptor) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter..."))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(filter_id))) + } + + #[test] + fn stylize_and_pixelate_filters_open_from_photoshop_shapes() { + let cases = [ + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Nrml")), "filter.stylize.diffuse", json!({"mode": "normal"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "DrkO")), "filter.stylize.diffuse", json!({"mode": "darkenOnly"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "LghO")), "filter.stylize.diffuse", json!({"mode": "lightenOnly"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "anisotropic")), "filter.stylize.diffuse", json!({"mode": "anisotropic"})), + ( + b"TrcC", + Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Upr ")), + "filter.stylize.traceContour", + json!({"level": 128, "edge": "upper"}), + ), + ( + b"TrcC", + Descriptor::new("TrcC").with("Lvl ", Value::Integer(60)).with("Edg ", enumv("CntE", "Lwr ")), + "filter.stylize.traceContour", + json!({"level": 60, "edge": "lower"}), + ), + (b"Crst", Descriptor::new("Crst").with("ClSz", Value::Integer(10)), "filter.pixelate.crystallize", json!({"cellSize": 10})), + ( + b"Pntl", + Descriptor::new("Pntl").with("ClSz", Value::Integer(7)), + "filter.pixelate.pointillize", + json!({"cellSize": 7, "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ]; + for (id, fltr, command, params) in cases { + let item = photoshop_item(id, fltr); + // Through bytes, as read from a file. + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back in the measured shape: same class, keys, value types and filterID. + let back = item_for_filter(&f).unwrap(); + for key in ["Fltr", "filterID", "BckC"] { + assert_eq!(back.get(key), item.get(key), "{command} {key}"); + } + } + // The `anisotropic` mode is a long id, as Photoshop writes it. + let d = item_for_filter(&sf("filter.stylize.diffuse", json!({"mode": "anisotropic"}))).unwrap(); + let Some(Value::Descriptor(fltr)) = d.get("Fltr") else { panic!("no Fltr") }; + assert!(matches!(fltr.get("Md "), Some(Value::Enumerated { value: Id::Str(v), .. }) if v == b"anisotropic")); + // Pointillize takes the entry's background colour; the others ignore it. + let mut item = photoshop_item(b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", rgbc(51.0)); + let f = filter_from_item(&item); + assert_eq!(f.params["background"], json!([0.2, 0.2, 0.2, 1.0])); + assert_eq!(item_for_filter(&f).unwrap().get("BckC"), Some(&rgbc(51.0))); + let mut item = photoshop_item(b"Crst", Descriptor::new("Crst").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", rgbc(51.0)); + assert_eq!(filter_from_item(&item).params, json!({"cellSize": 7})); + // Our random seed has no PSD key: it is not written. + let fltr = |c: &str, p: J| item_for_filter(&sf(c, p)).unwrap().get("Fltr").cloned(); + assert_eq!( + fltr("filter.stylize.diffuse", json!({"mode": "normal", "seed": 7})), + Some(Value::Descriptor(Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Nrml")))) + ); + // Defaults and out-of-range values are written in Photoshop's ranges. + assert_eq!( + fltr("filter.stylize.traceContour", json!({})), + Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Lwr ")))) + ); + assert_eq!(fltr("filter.pixelate.crystallize", json!({})), Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(10))))); + assert_eq!(fltr("filter.pixelate.pointillize", json!({})), Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5))))); + assert_eq!( + fltr("filter.pixelate.crystallize", json!({"cellSize": 1e9})), + Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(300)))) + ); + assert_eq!( + fltr("filter.pixelate.pointillize", json!({"cellSize": "big"})), + Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5)))) + ); + assert_eq!( + fltr("filter.stylize.traceContour", json!({"level": -40, "edge": "upper"})), + Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(0)).with("Edg ", enumv("CntE", "Upr ")))) + ); + } + + #[test] + fn malformed_stylize_and_pixelate_filters_are_kept_verbatim() { + let long = Value::Integer; + let cases = [ + // Missing keys. + (b"Dfs ", Descriptor::new("Dfs ")), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(60))), + (b"TrcC", Descriptor::new("TrcC").with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst")), + (b"Pntl", Descriptor::new("Pntl")), + // Unknown enum values or enum types. + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Wild"))), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("BlnM", "Nrml"))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(60)).with("Edg ", enumv("CntE", "Mddl"))), + // Wrong types (also NaN). + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", long(1))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", Value::Double(60.0)).with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst").with("ClSz", unit(b"#Pxl", 10.0))), + (b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Double(f64::NAN))), + // Out of range. + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(256)).with("Edg ", enumv("CntE", "Upr "))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(-1)).with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst").with("ClSz", long(2))), + (b"Pntl", Descriptor::new("Pntl").with("ClSz", long(i32::MAX))), + ]; + for (id, fltr) in cases { + let item = photoshop_item(id, fltr); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // Saved without options (`hasoptions` false, no `Fltr`): kept verbatim. + let bare = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(code(b"Crst"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + // A Pointillize entry whose background colour is unreadable is kept verbatim too. + for bad in [Value::Integer(3), Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))] { + let mut item = photoshop_item(b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", bad); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + // Values with no PSD equivalent are reported, not guessed. + for (c, p) in [ + ("filter.stylize.diffuse", json!({"mode": "sideways"})), + ("filter.stylize.diffuse", json!({"mode": 3})), + ("filter.stylize.traceContour", json!({"edge": "middle"})), + ("filter.pixelate.pointillize", json!({"background": "teal"})), + ("filter.pixelate.pointillize", json!({"background": [1.0]})), + ] { + assert!(item_for_filter(&sf(c, p.clone())).is_err(), "{c} {p}"); + } + } + /// A Filter Gallery item as Photopea writes it (Chrome, Detail 12, Smoothness 4). fn gallery_item_with(effects: Vec) -> Descriptor { let rgb = diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..428df51497 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Diffuse, Trace Contour, Crystallize and Pointillize mapped both ways, #6302; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (18 mapped, incl. Diffuse, Trace Contour, Crystallize and Pointillize, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Diffuse (`Dfs `, `DfsM` mode), Trace Contour (`TrcC`, level and `CntE` edge), Crystallize (`Crst`) and Pointillize (`Pntl`, background from `BckC`) open as editable smart filters and save in the shape measured in Photoshop (#6302, FILE-215-2) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..fa018eec43 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 18 filters (incl. Diffuse, Trace Contour, Crystallize, Pointillize as measured in Photoshop) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..5e305af195 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 18 filters (incl. Diffuse, Trace Contour, Crystallize, Pointillize as measured in Photoshop) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From 96bff5d7b62fc01d94031f849dfb2b64205dac32 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 22:21:20 +0200 Subject: [PATCH 0258/1452] PSD: Smart Sharpen and Dust & Scratches smart filters open and save like Photoshop (#6300) Smart Sharpen (filter.sharpen.smartSharpen) and Dust & Scratches (filter.noise.dustAndScratches) smart filters are now mapped both ways in the PSD filterFX list instead of being dropped on save and opening as uneditable entries. - Smart Sharpen: filterID 1131 (numeric), stringID class smartSharpen; useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc, blur blurType enum. PhotoCraft removes a Gaussian blur, so it writes blur GsnB with useLegacy false. Entries with Lens Blur (Photoshop's default), Motion Blur (with Angl), legacy mode, any other key, wrong types/units or out-of-range values stay verbatim. presetKind (presetKindType) is accepted on open but not kept or written: the engine takes no such parameter, and Photoshop leaves it out of entries with explicit values. - Dust & Scratches: filterID / class DstS; Rds and Thsh long (radius 1..500, threshold 0..255; PhotoCraft params are rounded on write). Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back (Smart Sharpen default / Gaussian / Motion, Dust & Scratches 1,0 and 4,20). Tests cover the measured shapes, option round trips, malformed descriptors kept verbatim and hostile params. Scorecard FILE-215-2 note and file-format parity doc updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 298 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 302 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..30ea2b7a89 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 16] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -160,8 +160,66 @@ const KNOWN: [Known; 14] = [ Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). Known { class: "adaptCorrect", filter_id: 781, name: "Shado&ws/Highlights...", command: "image.adjustments.shadowsHighlights" }, + // Smart Sharpen has a numeric filterID and a stringID class, like Box Blur. + Known { class: SMART_SHARPEN, filter_id: 1131, name: "Smart Sharpen...", command: "filter.sharpen.smartSharpen" }, + Known { class: "DstS", filter_id: code(b"DstS"), name: "Dust & Scratches...", command: "filter.noise.dustAndScratches" }, ]; +// ---------- Smart Sharpen and Dust & Scratches ---------- +// +// Measured in Photoshop (applied through Action Manager to a smart object, saved, `filterFX` read +// back): +// +// ```text +// Smart Sharpen (filterID 1131): smartSharpen { presetKind = presetKindType enum (only on the +// default entry), useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc, +// blur = blurType enum (GsnB | lensBlur | MtnB), Angl long (Motion Blur only) } +// Dust & Scratches (filterID DstS): DstS { Rds long, Thsh long } +// ``` +// +// PhotoCraft's Smart Sharpen removes a Gaussian blur, non-legacy: Lens Blur, Motion Blur, legacy +// mode, Shadow/Highlight settings or any other key keep the whole entry verbatim. `presetKind` +// only names the dialog's preset: it is accepted on open but not kept (the engine takes no such +// parameter) and not written (Photoshop leaves it out of entries with explicit values). + +const SMART_SHARPEN: &str = "smartSharpen"; +const SMART_SHARPEN_KEYS: [&str; 6] = ["presetKind", "useLegacy", "Amnt", "Rds ", "noiseReduction", "blur"]; + +/// A `UntF` value in `unit`, finite and within `lo..=hi`. +fn unit_in(d: &Descriptor, key: &str, u: &[u8; 4], lo: f64, hi: f64) -> Option { + match d.get(key)? { + Value::UnitFloat { unit, value } if unit == u && value.is_finite() && (lo..=hi).contains(value) => Some(*value), + _ => None, + } +} + +/// A `long` value within `lo..=hi`. +fn int_in(d: &Descriptor, key: &str, lo: i32, hi: i32) -> Option { + int(d, key).filter(|v| (lo..=hi).contains(v)) +} + +/// `filter.sharpen.smartSharpen` params; `None` for a setting PhotoCraft doesn't render. +fn smart_sharpen_params(f: &Descriptor) -> Option { + if f.items.iter().any(|(k, _)| !SMART_SHARPEN_KEYS.iter().any(|x| k.is(x))) { + return None; + } + let Some(Value::Boolean(false)) = f.get("useLegacy") else { return None }; + let Some(Value::Enumerated { type_id, value }) = f.get("blur") else { return None }; + if !type_id.is("blurType") || !value.is("GsnB") { + return None; + } + let mut out = Map::new(); + out.insert("amount".into(), json!(unit_in(f, "Amnt", b"#Prc", 1.0, 500.0)?)); + out.insert("radius".into(), json!(unit_in(f, "Rds ", b"#Pxl", 0.1, 64.0)?)); + out.insert("reduceNoise".into(), json!(unit_in(f, "noiseReduction", b"#Prc", 0.0, 100.0)?)); + match f.get("presetKind") { + None => {} + Some(Value::Enumerated { type_id, .. }) if type_id.is("presetKindType") => {} + Some(_) => return None, + } + Some(J::Object(out)) +} + fn known_by_command(command: &str) -> Option<&'static Known> { KNOWN.iter().find(|k| k.command == command) } @@ -282,6 +340,13 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + (SMART_SHARPEN, Some(f)) => smart_sharpen_params(f)?, + ("DstS", Some(f)) => { + if f.items.iter().any(|(k, _)| !k.is("Rds ") && !k.is("Thsh")) { + return None; + } + json!({"radius": int_in(f, "Rds ", 1, 500)?, "threshold": int_in(f, "Thsh", 0, 255)?}) + } ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -595,6 +660,19 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + SMART_SHARPEN => { + // The engine's defaults and ranges (it clamps the same way when rendering). + d.with("useLegacy", Value::Boolean(false)) + .with("Amnt", unit(b"#Prc", pf(p, "amount", 100.0).clamp(1.0, 500.0))) + .with("Rds ", unit(b"#Pxl", pf(p, "radius", 1.0).clamp(0.1, 64.0))) + .with("noiseReduction", unit(b"#Prc", pf(p, "reduceNoise", 10.0).clamp(0.0, 100.0))) + .with("blur", enumv("blurType", "GsnB")) + } + // Photoshop stores whole pixels and levels. + "DstS" => { + let whole = |key: &str, default: f64, lo: f64, hi: f64| Value::Integer(pf(p, key, default).round().clamp(lo, hi) as i32); + d.with("Rds ", whole("radius", 1.0, 1.0, 500.0)).with("Thsh", whole("threshold", 0.0, 0.0, 255.0)) + } "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -1063,6 +1141,8 @@ mod tests { sf("filter.other.minimum", json!({"radius": 2.0})), sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + sf("filter.sharpen.smartSharpen", json!({"amount": 120.0, "radius": 2.5, "reduceNoise": 5.0})), + sf("filter.noise.dustAndScratches", json!({"radius": 4, "threshold": 20})), sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), sf("image.adjustments.shadowsHighlights", json!({})), @@ -1080,6 +1160,222 @@ mod tests { } } + /// A `filterFXList` item as Photoshop writes it, around filter `fltr`. + fn ps_item(name: &str, fltr: Descriptor, filter_id: i32) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(filter_id)) + } + + /// Smart Sharpen's `Fltr` in the measured shape (no `presetKind`, no `Angl`). + fn smart_sharpen(amount: f64, radius: f64, noise: f64, blur: &str) -> Descriptor { + Descriptor::new("smartSharpen") + .with("useLegacy", Value::Boolean(false)) + .with("Amnt", unit(b"#Prc", amount)) + .with("Rds ", unit(b"#Pxl", radius)) + .with("noiseReduction", unit(b"#Prc", noise)) + .with("blur", enumv("blurType", blur)) + } + + fn dust(radius: i32, threshold: i32) -> Descriptor { + Descriptor::new("DstS").with("Rds ", Value::Integer(radius)).with("Thsh", Value::Integer(threshold)) + } + + /// The `Fltr` descriptor PhotoCraft writes for `f`, after a byte round trip. + fn written_fltr(f: &SmartFilter) -> Descriptor { + let d = Descriptor::from_bytes(&item_for_filter(f).unwrap().to_bytes()).unwrap(); + get_desc(&d, "Fltr").unwrap().clone() + } + + /// Measured in Photoshop: Smart Sharpen 120 %, 2.5 px, noise 5 %, Gaussian Blur + /// (filterID 1131); the default entry adds `presetKind` first, which is not kept. + #[test] + fn smart_sharpen_photoshop_entry_opens_and_writes_back() { + let fltr = smart_sharpen(120.0, 2.5, 5.0, "GsnB"); + let item = ps_item("Smart Sharpen...", fltr.clone(), 1131); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, "filter.sharpen.smartSharpen"); + assert_eq!(f.params, json!({"amount": 120.0, "radius": 2.5, "reduceNoise": 5.0})); + assert_eq!(written_fltr(&f), fltr); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1131)); + assert_eq!(get_desc(&d, "Fltr").unwrap().class_id, Id::Str(b"smartSharpen".to_vec())); + // The four-character keys and enum values are codes, as Photoshop writes them. + let bytes = d.to_bytes(); + assert_eq!(count(&bytes, b"\0\0\0\0blurenum\0\0\0\x08blurType\0\0\0\0GsnB"), 1); + assert_eq!(count(&bytes, b"\0\0\0\0Rds UntF#Pxl"), 1); + + // With `presetKind` (any value): opens, but the params hold only the engine's own keys + // (it rejects unknown ones), and a re-save leaves it out as Photoshop does for explicit + // values. + for kind in ["presetKindDefault", "presetKindCustom", "Xxxx"] { + let mut with_preset = Descriptor::new("smartSharpen").with("presetKind", enumv("presetKindType", kind)); + with_preset.items.extend(smart_sharpen(200.0, 1.0, 10.0, "GsnB").items); + let f = filter_from_item(&Descriptor::from_bytes(&ps_item("Smart Sharpen...", with_preset, 1131).to_bytes()).unwrap()); + assert_eq!(f.command, "filter.sharpen.smartSharpen"); + let keys: Vec<&str> = f.params.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys.len(), 3, "{keys:?}"); + assert!(keys.iter().all(|k| ["amount", "radius", "reduceNoise"].contains(k)), "{keys:?}"); + assert_eq!(f.params, json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0})); + assert_eq!(written_fltr(&f), smart_sharpen(200.0, 1.0, 10.0, "GsnB")); + } + + // Without options PhotoCraft writes the engine's defaults, Gaussian Blur. + assert_eq!(written_fltr(&sf("filter.sharpen.smartSharpen", json!({}))), smart_sharpen(100.0, 1.0, 10.0, "GsnB")); + } + + /// Lens Blur (Photoshop's default), Motion Blur and legacy mode render differently from + /// PhotoCraft's Smart Sharpen, so those entries stay verbatim. + #[test] + fn smart_sharpen_settings_we_dont_render_stay_verbatim() { + let mut default_entry = Descriptor::new("smartSharpen").with("presetKind", enumv("presetKindType", "presetKindDefault")); + default_entry.items.extend(smart_sharpen(200.0, 1.0, 10.0, "lensBlur").items); + let motion = smart_sharpen(80.0, 1.5, 0.0, "MtnB").with("Angl", Value::Integer(30)); + let mut legacy = smart_sharpen(100.0, 1.0, 10.0, "GsnB"); + set(&mut legacy, "useLegacy", Value::Boolean(true)); + for fltr in [default_entry, motion, legacy] { + let item = ps_item("Smart Sharpen...", fltr, 1131); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(f.params["filterID"], 1131); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn malformed_smart_sharpen_is_kept_verbatim() { + let g = || smart_sharpen(100.0, 1.0, 10.0, "GsnB"); + let without = |key: &str| { + let mut d = g(); + d.items.retain(|(k, _)| !k.is(key)); + d + }; + let with = |key: &str, v: Value| { + let mut d = g(); + set(&mut d, key, v); + d + }; + let cases = vec![ + without("useLegacy"), + without("Amnt"), + without("Rds "), + without("noiseReduction"), + without("blur"), + with("Amnt", Value::Double(100.0)), + with("Amnt", unit(b"#Pxl", 100.0)), + with("Rds ", Value::Integer(1)), + with("useLegacy", Value::Integer(0)), + with("Amnt", unit(b"#Prc", 0.0)), + with("Amnt", unit(b"#Prc", 501.0)), + with("Rds ", unit(b"#Pxl", 0.05)), + with("Rds ", unit(b"#Pxl", 65.0)), + with("Rds ", unit(b"#Pxl", f64::NAN)), + with("noiseReduction", unit(b"#Prc", -1.0)), + with("noiseReduction", unit(b"#Prc", f64::INFINITY)), + with("blur", enumv("blurType", "Xxxx")), + with("blur", enumv("BlrM", "GsnB")), + with("blur", Value::Integer(0)), + with("presetKind", Value::Integer(1)), + g().with("Angl", Value::Integer(30)), + g().with("shadowMode", Value::Descriptor(Descriptor::new("adaptCorrectTones"))), + ]; + for fltr in cases { + let item = ps_item("Smart Sharpen...", fltr, 1131); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // No `Fltr` at all (hasoptions false). + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(1131)); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + /// Measured in Photoshop: Dust & Scratches at its defaults (1, 0) and at 4, 20 (filterID + /// `DstS`, 1148417107; both settings `long`). + #[test] + fn dust_and_scratches_photoshop_entry_opens_and_writes_back() { + for (radius, threshold) in [(1, 0), (4, 20), (500, 255)] { + let fltr = dust(radius, threshold); + let item = ps_item("Dust & Scratches...", fltr.clone(), 1_148_417_107); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, "filter.noise.dustAndScratches"); + assert_eq!(f.params, json!({"radius": radius, "threshold": threshold})); + assert_eq!(written_fltr(&f), fltr); + assert_eq!(int(&item_for_filter(&f).unwrap(), "filterID"), Some(1_148_417_107)); + } + // No `Fltr` (hasoptions false): kept verbatim. + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"DstS"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn malformed_dust_and_scratches_is_kept_verbatim() { + let cases = vec![ + Descriptor::new("DstS").with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", Value::Integer(1)), + Descriptor::new("DstS").with("Rds ", Value::Double(1.0)).with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", unit(b"#Pxl", 1.0)).with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", Value::Integer(1)).with("Thsh", Value::Boolean(false)), + dust(0, 0), + dust(501, 0), + dust(1, -1), + dust(1, 256), + dust(i32::MIN, i32::MAX), + dust(1, 0).with("Xtra", Value::Integer(1)), + ]; + for fltr in cases { + let item = ps_item("Dust & Scratches...", fltr, code(b"DstS")); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + /// Every option's range ends round trip; hostile params are written in range (the engine + /// clamps the same way), never a panic. + #[test] + fn smart_sharpen_and_dust_option_values_round_trip() { + let mut cases = Vec::new(); + for amount in [1.0, 100.0, 500.0] { + for radius in [0.1, 1.0, 64.0] { + for noise in [0.0, 10.0, 100.0] { + cases.push(sf("filter.sharpen.smartSharpen", json!({"amount": amount, "radius": radius, "reduceNoise": noise}))); + } + } + } + for radius in [1, 2, 100, 500] { + for threshold in [0, 1, 128, 255] { + cases.push(sf("filter.noise.dustAndScratches", json!({"radius": radius, "threshold": threshold}))); + } + } + for f in cases { + let back = filter_from_item(&Descriptor::from_bytes(&item_for_filter(&f).unwrap().to_bytes()).unwrap()); + assert_eq!(back, f); + } + let defaults = json!({"amount": 100.0, "radius": 1.0, "reduceNoise": 10.0}); + for (command, p, want) in [ + ( + "filter.sharpen.smartSharpen", + json!({"amount": 1e9, "radius": -5.0, "reduceNoise": "x"}), + json!({"amount": 500.0, "radius": 0.1, "reduceNoise": 10.0}), + ), + ("filter.sharpen.smartSharpen", json!({"presetKind": "presetKindDefault", "amount": null}), defaults.clone()), + ("filter.sharpen.smartSharpen", json!(null), defaults.clone()), + ("filter.noise.dustAndScratches", json!({"radius": 2.4, "threshold": 1e12}), json!({"radius": 2, "threshold": 255})), + ("filter.noise.dustAndScratches", json!({"radius": -3, "threshold": -3}), json!({"radius": 1, "threshold": 0})), + ("filter.noise.dustAndScratches", json!([]), json!({"radius": 1, "threshold": 0})), + ] { + let back = filter_from_item(&item_for_filter(&sf(command, p.clone())).unwrap()); + assert_eq!((back.command.as_str(), &back.params), (command, &want), "{p}"); + } + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..3fd7195121 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Smart Sharpen and Dust & Scratches mapped both ways, #6300; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (16 mapped, incl. Smart Sharpen and Dust & Scratches, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Smart Sharpen (filterID 1131, Gaussian removal; Lens/Motion Blur and legacy entries stay verbatim) and Dust & Scratches (`DstS`) open as editable filters and save in Photoshop's measured shape (#6300) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..0f21935c01 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 16 filters (incl. Smart Sharpen with Gaussian removal and Dust & Scratches, #6300) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..563745bb89 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 16 filters (incl. Smart Sharpen with Gaussian removal and Dust & Scratches, #6300) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From 9f8d4bc0acb5862b8342819583ac308ba379ce4b Mon Sep 17 00:00:00 2001 From: Theo Wilenius Date: Fri, 16 Oct 2026 00:03:53 +0300 Subject: [PATCH 0259/1452] Guides: a guide let go over either ruler is removed, not only off the canvas (#6286) A guide was kept whenever the release still read a position inside the document on the guide's own axis. The two rulers measure one axis each, so a horizontal guide let go on the left ruler, or a vertical one let go on the top ruler, was moved, and a new one dragged out of a ruler was created, instead of discarded. `dropped_on_a_ruler` reports whether the release point is in a ruler band while the rulers are shown. `GuideDrag` carries the answer and `finish_drag` treats such a drop as off-canvas. The canvas sets it from the release position in screen space, where the rulers are; the ruler's own drag sets it from its pointer position. Two of the four new tests fail on main: a guide dragged out of the top ruler and let go on the left ruler is created, and an existing guide dragged onto the left ruler is moved. A guide released on the canvas still lands where it was let go. --- crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/move_hover.rs | 2 +- crates/ui-egui/src/rulers.rs | 189 ++++++++++++++++++++++++++++--- docs/ui-parity.md | 5 +- 4 files changed, 189 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..2fea271cf7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3631,7 +3631,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if press_stroke { buttons.clicked = false; } - let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); + let at = response.interact_pointer_pos(); + // #6286: a guide let go over a ruler is removed, whichever ruler it is. Tested here in + // screen space, where the rulers are: on the guide's own axis the release can still be + // inside the document, which is what `finish_drag` sees. + if app.guide_drag.is_some() { + let on_ruler = at.is_some_and(|p| crate::rulers::dropped_on_a_ruler(app, full, p)); + if let Some(g) = app.guide_drag.as_mut() { + g.over_ruler = on_ruler; + } + } + let p = at.map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); if let Some(d) = p { tap_at_peak_pressure(app); tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs index c3d07f313d..ff9d425f84 100644 --- a/crates/ui-egui/src/move_hover.rs +++ b/crates/ui-egui/src/move_hover.rs @@ -185,7 +185,7 @@ mod tests { assert_eq!(active_outline(&mut app, None, false, false), Some(a)); // When a drag or transform is in progress, no outline: - app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 61ea25d96f..320a99beaf 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -1,6 +1,6 @@ //! View extras: rulers (⌘R), grid (⌘'), guides (⌘;). Guides are dragged out of the rulers and -//! moved with the Move tool (drag one off the canvas to delete it), all through undoable engine -//! commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`). +//! moved with the Move tool (drag one off the canvas or onto a ruler to delete it), all through +//! undoable engine commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`). use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::Document; @@ -96,13 +96,16 @@ pub struct GuideDrag { /// Where the pointer went down, on the guide's axis: released there, an existing guide is /// clicked (selected), not moved. pub press: f64, + /// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released + /// there is removed, whatever its own axis reads. + pub over_ruler: bool, } impl GuideDrag { /// An existing guide taken at document point `p`. pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { let at = if vertical { p[0] } else { p[1] }; - Self { vertical, index: Some(index), pos: at, press: at } + Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false } } } @@ -111,6 +114,20 @@ pub fn content_rect(app: &PhotocraftApp, rect: Rect) -> Rect { if app.ui.extras.rulers { Rect::from_min_max(rect.min + vec2(RULER, RULER), rect.max) } else { rect } } +/// Whether the screen point `p` is over a ruler, inside the full canvas rect `full`. A guide let go +/// there is removed, as in Photoshop, and either ruler removes it (#6286): the top ruler measures +/// document x and the left one y, so the ruler the guide was not dragged out of can lie over a +/// coordinate that is still inside the document on the guide's own axis. +pub(crate) fn dropped_on_a_ruler(app: &PhotocraftApp, full: Rect, p: Pos2) -> bool { + if !app.ui.extras.rulers { + return false; + } + let content = content_rect(app, full); + let top = Rect::from_min_max(full.min, pos2(full.max.x, content.min.y)); + let left = Rect::from_min_max(full.min, pos2(content.min.x, full.max.y)); + top.contains(p) || left.contains(p) +} + /// Tick step (document px) so major ticks are at least ~60 screen px apart: 1, 2, 5, 10, 20, 50… #[cfg(test)] fn tick_step(zoom: f32) -> f64 { @@ -235,9 +252,9 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { None } -/// Finish a guide drag: create, move, or delete (dropped outside the canvas). An existing guide -/// released where it was pressed is a click: it's selected and stays put, as in Photoshop; a -/// moved one stays selected. +/// Finish a guide drag: create, move, or delete (dropped outside the canvas or on a ruler). An +/// existing guide released where it was pressed is a click: it's selected and stays put, as in +/// Photoshop; a moved one stays selected. pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { let Some(size) = app.session.active().map(|s| s.doc.size) else { return }; if let Some(i) = d.index @@ -248,7 +265,7 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { } let extent = if d.vertical { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; - let inside = d.pos >= 0.0 && d.pos <= extent; + let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent; let pos = (d.pos * 1000.0).round() / 1000.0; let _ = match (d.index, inside) { (None, true) => app.run("view.newGuide", json!({"orientation": orientation, "position": pos})), @@ -437,11 +454,13 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; - app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false }); } if resp.drag_stopped() - && let Some(d) = app.guide_drag.take() + && let Some(mut d) = app.guide_drag.take() { + // Let go over a ruler (the other one, say) the drag is discarded, not placed (#6286). + d.over_ruler = resp.interact_pointer_pos().is_some_and(|p| dropped_on_a_ruler(app, full, p)); finish_drag(app, d); } resp.context_menu(|ui| { @@ -675,13 +694,13 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.hover_doc = None; - app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0 }); + app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0, over_ruler: false }); let mut h = egui_kittest::Harness::builder() .with_size(vec2(260.0, 240.0)) .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); h.run_steps(2); assert!(h.query_by_label("42").is_some(), "vertical guide: X readout"); - h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0 }); + h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0, over_ruler: false }); h.run_steps(2); assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); } @@ -810,14 +829,14 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); app.sync_views(); - finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0 }); - finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0 }); // off canvas: ignored + finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0, over_ruler: false }); + finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0, over_ruler: false }); // off canvas: ignored assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![50.0]); assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty()); assert_eq!(guide_at(&app, 51.0, 10.0), Some((true, 0))); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0, over_ruler: false }); assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![120.0]); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0, over_ruler: false }); assert!(app.session.active().unwrap().doc.guides.vertical.is_empty()); app.ui.extras.lock_guides = true; assert_eq!(guide_at(&app, 0.0, 0.0), None); @@ -1001,6 +1020,146 @@ mod tests { assert_eq!(drag(left, egui::Modifiers::ALT), (vec![], vec![90.0]), "Alt from the left ruler: horizontal"); } + /// #6286: a guide let go over a ruler is removed, whichever ruler it is. The top ruler measures + /// document x and the left one y, so a horizontal guide released on the left ruler is still + /// inside the document on its own axis, and used to be placed instead of discarded. + #[test] + fn a_guide_dropped_on_either_ruler_is_removed() { + let new_app = || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + app.ui.extras.rulers = true; + // No snapping, so a guide that is kept lands exactly where it is let go. + app.ui.extras.snap = false; + app + }; + let drag = |from: Pos2, to: Pos2| { + let (mut h, _, _) = ruler_harness(new_app()); + h.event(egui::Event::PointerMoved(from)); + press(&mut h, from, true, egui::Modifiers::NONE); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(1); + press(&mut h, to, false, egui::Modifiers::NONE); + let g = &h.state().session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) + }; + // #5698: out of the top ruler is a horizontal guide, out of the left one a vertical guide. + let (top, left) = (pos2(200.0, RULER / 2.0), pos2(RULER / 2.0, 150.0)); + assert_eq!(drag(top, left), (vec![], vec![]), "out of the top ruler, released on the left ruler"); + assert_eq!(drag(left, top), (vec![], vec![]), "out of the left ruler, released on the top ruler"); + // Released on the canvas the guide lands where it was let go, as before. + assert_eq!(drag(top, pos2(200.0, 150.0)), (vec![], vec![56.0]), "released on the canvas"); + assert_eq!(drag(left, pos2(120.0, 150.0)), (vec![40.0], vec![]), "released on the canvas"); + } + + /// #6286: the guide is removed when it is let go over a ruler, and moved when it is not. The + /// release can be inside the document on the guide's own axis and still be a ruler drop, so the + /// two are told apart by where the pointer is, not by the position it reads. + #[test] + fn a_guide_let_go_on_a_ruler_is_removed_not_moved() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true }); + assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty(), "let go on a ruler: removed"); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 10.0, over_ruler: false }); + assert_eq!(app.session.active().unwrap().doc.guides.horizontal, vec![80.0], "let go on the canvas: moved"); + } + + /// Only the top and left ruler bands count as a ruler, and only while the rulers are shown: with + /// them hidden the whole canvas rect is canvas, so a guide dropped in the corner is kept (#6286). + #[test] + fn only_the_ruler_bands_count_as_a_ruler() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let full = Rect::from_min_size(pos2(16.0, 42.0), vec2(968.0, 642.0)); + app.ui.extras.rulers = true; + assert!(dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "the left ruler"); + assert!(dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "the top ruler"); + assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, 371.0)), "the canvas"); + app.ui.extras.rulers = false; + assert!(!dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "rulers hidden: no band"); + assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "rulers hidden: no band"); + } + + /// The canvas the guides live on, rulers on, at 100 %. + fn canvas_harness(tool: crate::state::Tool) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = true; + app.ui.extras.snap = false; + app.ui.tool = tool; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h + } + + /// #6286: an existing guide dragged onto a ruler with the Move tool is deleted, though the + /// release is inside the document on the guide's own axis: the left ruler measures y, so a + /// horizontal guide let go there still reads a y inside the canvas and used to be moved. + #[test] + fn a_guide_dragged_onto_a_ruler_is_deleted_through_the_canvas() { + let mut h = canvas_harness(crate::state::Tool::Move); + h.state_mut().run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + h.run_steps(1); + let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) + }; + let on_the_guide = at(&h, 200.0, 150.0); + // 50 document px below the press, in the left ruler's band: still inside the canvas on the + // guide's own axis, which is where the old code moved the guide instead of removing it. + let canvas = h.state().last_canvas_rect; + let onto_the_left_ruler = pos2(canvas.min.x + RULER / 2.0, on_the_guide.y + 50.0); + h.event(egui::Event::PointerMoved(on_the_guide)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: on_the_guide, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Past egui's click distance, so the drag starts at the press, on the guide. + h.event(egui::Event::PointerMoved(pos2(on_the_guide.x, on_the_guide.y + 12.0))); + h.run_steps(2); + assert!(h.state().guide_drag.is_some(), "the Move tool takes the guide"); + h.event(egui::Event::PointerMoved(onto_the_left_ruler)); + h.run_steps(1); + h.event(egui::Event::PointerButton { + pos: onto_the_left_ruler, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + assert!(g.horizontal.is_empty(), "released on the left ruler: the guide is gone, got {:?}", g.horizontal); + assert!(h.state().guide_drag.is_none(), "the drag is over"); + } + /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws /// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..fbddcfd0cc 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (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; a guide let go over either ruler is removed, #6286; 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) · **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, @@ -194,7 +194,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | -| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | +| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | ## Panels and workspace @@ -227,6 +227,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | A guide let go over either ruler is removed, not only off the canvas: the top ruler measures document x and the left one y, so the ruler a guide was not dragged out of can lie over a coordinate inside the document (#6286) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From 3d9d12ec387fa2cd1de4a6498e9bdf94b92b5c3d Mon Sep 17 00:00:00 2001 From: Theo Wilenius Date: Fri, 16 Oct 2026 00:03:56 +0300 Subject: [PATCH 0260/1452] Contributors: credit theoabw by real name in the About window --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..350ae2ea7a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,6 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.theoabw] +real_name = "Theo Wilenius" From 26cc0369ee1da1cfd52ad11530b62e034157b969 Mon Sep 17 00:00:00 2001 From: Theo Wilenius Date: Fri, 16 Oct 2026 00:23:51 +0300 Subject: [PATCH 0261/1452] Guides: a ruler drop removes the guide even when the drag held its own axis Review found that a horizontal guide dragged sideways onto the left ruler keeps its y, and that reads as a click on the guide: the click branch selected it and returned before the ruler drop was considered, so the guide survived the drop. Require `!over_ruler` in that branch, so a drop on a ruler always removes the guide, and cover the constant-axis case in the direct test and in the canvas test, which now runs the gesture at two release offsets. `dropped_on_a_ruler` is now the strips `content_rect` trims off the canvas rect, strictly outside it, with the boundary, corner and outside-rect cases asserted. --- crates/ui-egui/src/rulers.rs | 131 +++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 52 deletions(-) diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 320a99beaf..8a26adfbef 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -122,10 +122,10 @@ pub(crate) fn dropped_on_a_ruler(app: &PhotocraftApp, full: Rect, p: Pos2) -> bo if !app.ui.extras.rulers { return false; } + // The rulers take the strips [`content_rect`] trims off the canvas rect, and nothing else: a + // canvas point on the boundary is canvas. let content = content_rect(app, full); - let top = Rect::from_min_max(full.min, pos2(full.max.x, content.min.y)); - let left = Rect::from_min_max(full.min, pos2(content.min.x, full.max.y)); - top.contains(p) || left.contains(p) + full.contains(p) && (p.x < content.min.x || p.y < content.min.y) } /// Tick step (document px) so major ticks are at least ~60 screen px apart: 1, 2, 5, 10, 20, 50… @@ -257,7 +257,10 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { /// Photoshop; a moved one stays selected. pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { let Some(size) = app.session.active().map(|s| s.doc.size) else { return }; + // A drop on a ruler removes the guide even when the drag held its own axis, which otherwise + // reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y. if let Some(i) = d.index + && !d.over_ruler && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 { crate::guide_select::select(app, d.vertical, i); @@ -1060,27 +1063,46 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.sync_views(); - app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); - finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true }); - assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty(), "let go on a ruler: removed"); + let horizontal = |app: &PhotocraftApp| app.session.active().unwrap().doc.guides.horizontal.clone(); + for drag in [ + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true }, + // Dragged sideways onto the left ruler, a horizontal guide holds its y, which on its own + // reads as a click: the ruler still removes it. + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 50.0, over_ruler: true }, + ] { + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); + finish_drag(&mut app, drag); + assert!(horizontal(&app).is_empty(), "let go on a ruler: removed, {drag:?}"); + } app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 10.0, over_ruler: false }); - assert_eq!(app.session.active().unwrap().doc.guides.horizontal, vec![80.0], "let go on the canvas: moved"); + assert_eq!(horizontal(&app), vec![80.0], "let go on the canvas: moved"); + // Clicked where it was pressed, on the canvas: selected and kept, as before. + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 80.0, over_ruler: false }); + assert_eq!(horizontal(&app), vec![80.0], "clicked on the canvas: kept"); + assert!(app.ui.selected_guide.is_some(), "clicked on the canvas: selected"); } - /// Only the top and left ruler bands count as a ruler, and only while the rulers are shown: with - /// them hidden the whole canvas rect is canvas, so a guide dropped in the corner is kept (#6286). + /// The ruler bands are the strips the rulers take off the canvas rect, and only while the rulers + /// are shown: with them hidden the whole canvas rect is canvas (#6286). #[test] fn only_the_ruler_bands_count_as_a_ruler() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let full = Rect::from_min_size(pos2(16.0, 42.0), vec2(968.0, 642.0)); + let content = Rect::from_min_size(pos2(32.0, 58.0), vec2(952.0, 626.0)); app.ui.extras.rulers = true; assert!(dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "the left ruler"); assert!(dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "the top ruler"); + assert!(dropped_on_a_ruler(&app, full, full.min), "the ruler corner"); assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, 371.0)), "the canvas"); + assert!(!dropped_on_a_ruler(&app, full, content.min), "the canvas' own top-left corner"); + assert!(!dropped_on_a_ruler(&app, full, pos2(content.min.x + 0.5, 371.0)), "the first canvas column"); + assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, content.min.y + 0.5)), "the first canvas row"); + assert!(!dropped_on_a_ruler(&app, full, pos2(full.min.x - 1.0, 371.0)), "outside the canvas rect"); app.ui.extras.rulers = false; - assert!(!dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "rulers hidden: no band"); - assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "rulers hidden: no band"); + for p in [pos2(full.min.x + RULER / 2.0, 371.0), pos2(508.0, full.min.y + RULER / 2.0), full.min] { + assert!(!dropped_on_a_ruler(&app, full, p), "rulers hidden: no band at {p:?}"); + } } /// The canvas the guides live on, rulers on, at 100 %. @@ -1114,50 +1136,55 @@ mod tests { /// #6286: an existing guide dragged onto a ruler with the Move tool is deleted, though the /// release is inside the document on the guide's own axis: the left ruler measures y, so a - /// horizontal guide let go there still reads a y inside the canvas and used to be moved. + /// horizontal guide let go there still reads a y inside the canvas and used to be moved. Held + /// level, that release reads as a click on the guide, which a ruler drop also outranks. #[test] fn a_guide_dragged_onto_a_ruler_is_deleted_through_the_canvas() { - let mut h = canvas_harness(crate::state::Tool::Move); - h.state_mut().run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); - h.run_steps(1); - let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { - let app = h.state(); - let v = &app.ui.views[0]; - let xf = ViewXform { - rect: content_rect(app, app.last_canvas_rect), - zoom: v.zoom, - center: v.center, - flip: app.ui.view.flip_horizontal, - rotation: v.rotation, - aspect: app.ui.view.display_aspect(), + for below in [50.0f32, 0.0] { + let mut h = canvas_harness(crate::state::Tool::Move); + h.state_mut().run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + h.run_steps(1); + let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) }; - xf.to_screen(x, y) - }; - let on_the_guide = at(&h, 200.0, 150.0); - // 50 document px below the press, in the left ruler's band: still inside the canvas on the - // guide's own axis, which is where the old code moved the guide instead of removing it. - let canvas = h.state().last_canvas_rect; - let onto_the_left_ruler = pos2(canvas.min.x + RULER / 2.0, on_the_guide.y + 50.0); - h.event(egui::Event::PointerMoved(on_the_guide)); - h.run_steps(1); - h.event(egui::Event::PointerButton { pos: on_the_guide, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); - h.run_steps(1); - // Past egui's click distance, so the drag starts at the press, on the guide. - h.event(egui::Event::PointerMoved(pos2(on_the_guide.x, on_the_guide.y + 12.0))); - h.run_steps(2); - assert!(h.state().guide_drag.is_some(), "the Move tool takes the guide"); - h.event(egui::Event::PointerMoved(onto_the_left_ruler)); - h.run_steps(1); - h.event(egui::Event::PointerButton { - pos: onto_the_left_ruler, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }); - h.run_steps(2); - let g = &h.state().session.active().unwrap().doc.guides; - assert!(g.horizontal.is_empty(), "released on the left ruler: the guide is gone, got {:?}", g.horizontal); - assert!(h.state().guide_drag.is_none(), "the drag is over"); + let on_the_guide = at(&h, 200.0, 150.0); + // In the left ruler's band, `below` document px under the press: inside the canvas on the + // guide's own axis, which is where the old code moved the guide instead of removing it. + let canvas = h.state().last_canvas_rect; + let onto_the_left_ruler = pos2(canvas.min.x + RULER / 2.0, on_the_guide.y + below); + // Along the guide, so a held-level release keeps its y, the way a sideways drag does. + let dragged = pos2(on_the_guide.x - 12.0, on_the_guide.y); + h.event(egui::Event::PointerMoved(on_the_guide)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: on_the_guide, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Past egui's click distance, so the drag starts at the press, on the guide. + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + assert!(h.state().guide_drag.is_some(), "the Move tool takes the guide"); + h.event(egui::Event::PointerMoved(onto_the_left_ruler)); + h.run_steps(1); + h.event(egui::Event::PointerButton { + pos: onto_the_left_ruler, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + assert!(g.horizontal.is_empty(), "{below} px below the press, released on the left ruler: got {:?}", g.horizontal); + assert!(h.state().guide_drag.is_none(), "the drag is over"); + } } /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws From badfd6df70fa73841ea4a63d5ad6f0228eac22b6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 23:10:37 +0200 Subject: [PATCH 0262/1452] PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316) Mezzotint and Color Halftone applied as smart filters were dropped from PSD saves with a warning, and the same filters in a Photoshop PSD opened as unsupported (verbatim) smart filters. smart_map.rs now maps them both ways: - Mezzotint: filterID/class `Mztn`, `MztT` enum (FnDt, MdmD, GrnD, CrsD, ShrL, MdmL, LngL, ShSt, MdmS, LngS) <-> `type` (fineDots .. longStrokes). PhotoCraft's `seed` has no Photoshop equivalent and is not saved. - Color Halftone: filterID/class `ClrH`, `Rds ` and `Ang1`-`Ang4` longs <-> `maxRadius`, `channel1`-`channel4`, written within the dialog ranges (4..127, -360..360). Unknown enum codes, wrong value types and out-of-range values are kept verbatim as unsupported filters, never a panic. Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back; codes confirmed by rendering each option. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 164 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..233adc8dab 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 16] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -155,6 +155,8 @@ const KNOWN: [Known; 14] = [ Known { class: "Mxm ", filter_id: code(b"Mxm "), name: "Maximum...", command: "filter.other.maximum" }, Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, + Known { class: "Mztn", filter_id: code(b"Mztn"), name: "Mezzotint...", command: "filter.pixelate.mezzotint" }, + Known { class: "ClrH", filter_id: code(b"ClrH"), name: "Color Halftone...", command: "filter.pixelate.colorHalftone" }, Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, @@ -170,6 +172,28 @@ fn known_by_command(command: &str) -> Option<&'static Known> { /// composite: top-level keys). const CHANNELS: [(&[u8; 4], &str); 4] = [(b"Cmps", ""), (b"Rd ", "red"), (b"Grn ", "green"), (b"Bl ", "blue")]; +/// Mezzotint's `MztT` enum codes and our `type` values (measured in Photoshop by rendering each). +const MEZZOTINT_TYPES: [(&[u8; 4], &str); 10] = [ + (b"FnDt", "fineDots"), + (b"MdmD", "mediumDots"), + (b"GrnD", "grainyDots"), + (b"CrsD", "coarseDots"), + (b"ShrL", "shortLines"), + (b"MdmL", "mediumLines"), + (b"LngL", "longLines"), + (b"ShSt", "shortStrokes"), + (b"MdmS", "mediumStrokes"), + (b"LngS", "longStrokes"), +]; + +/// Color Halftone's `Rds ` (max radius) and `Ang1`..`Ang4` (screen angles) longs, read within +/// the dialog's ranges; anything else is kept verbatim. +fn color_halftone_params(f: &Descriptor) -> Option { + let radius = int(f, "Rds ").filter(|r| (4..=127).contains(r))?; + let angle = |k: &str| int(f, k).filter(|a| (-360..=360).contains(a)); + Some(json!({"maxRadius": radius, "channel1": angle("Ang1")?, "channel2": angle("Ang2")?, "channel3": angle("Ang3")?, "channel4": angle("Ang4")?})) +} + fn px(d: &Descriptor, key: &str) -> Option { num(d.get(key)).filter(|v| v.is_finite()) } @@ -281,6 +305,11 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) json!({"amount": px(f, "Nose")?, "distribution": dist, "monochromatic": mono, "seed": int(f, "FlRs").unwrap_or(0)}) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), + ("Mztn", Some(f)) => { + let t = enum_of(f, "MztT")?; + json!({"type": MEZZOTINT_TYPES.iter().find(|(c, _)| t == &c[..])?.1}) + } + ("ClrH", Some(f)) => color_halftone_params(f)?, ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, @@ -594,6 +623,21 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { .with("FlRs", pi(p, "seed", 0.0)) } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), + // Photoshop's Mezzotint has no seed (it is random on every render), so `seed` is not saved. + "Mztn" => { + let t = p.get("type").and_then(J::as_str).unwrap_or("fineDots"); + let code = MEZZOTINT_TYPES.iter().find(|(_, k)| *k == t).map_or(b"FnDt", |(c, _)| *c); + d.with("MztT", Value::Enumerated { type_id: Id::new("MztT"), value: Id::Code(*code) }) + } + "ClrH" => { + // Longs within the dialog's ranges (what the engine accepts: radius 4..127, angles ±360). + let long = |key: &str, default: f64, lo: f64, hi: f64| Value::Integer(pf(p, key, default).round().clamp(lo, hi) as i32); + d.with("Rds ", long("maxRadius", 8.0, 4.0, 127.0)) + .with("Ang1", long("channel1", 108.0, -360.0, 360.0)) + .with("Ang2", long("channel2", 162.0, -360.0, 360.0)) + .with("Ang3", long("channel3", 90.0, -360.0, 360.0)) + .with("Ang4", long("channel4", 45.0, -360.0, 360.0)) + } "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), @@ -1062,6 +1106,8 @@ mod tests { sf("filter.other.maximum", json!({"radius": 2.0})), sf("filter.other.minimum", json!({"radius": 2.0})), sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), + sf("filter.pixelate.mezzotint", json!({"type": "mediumStrokes"})), + sf("filter.pixelate.colorHalftone", json!({"maxRadius": 6, "channel1": 10, "channel2": 20, "channel3": 30, "channel4": 40})), sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), @@ -1080,6 +1126,122 @@ mod tests { } } + /// A `filterFX` item holding `fltr`, as Photoshop writes a filter with options. + fn measured_item(name: &str, fltr: Descriptor, filter_id: i32) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "Nrml")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(filter_id)) + } + + #[test] + fn mezzotint_round_trips_every_type_and_reads_photoshops_shape() { + for (code, ty) in MEZZOTINT_TYPES { + let f = sf("filter.pixelate.mezzotint", json!({"type": ty})); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1_299_870_830)); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("Mztn")); + assert_eq!(enum_of(fltr, "MztT"), Some(&code[..])); + assert!(matches!(fltr.get("MztT"), Some(Value::Enumerated { type_id, .. }) if type_id.is("MztT"))); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f, "{ty}"); + } + // Measured in Photoshop: `{'_class': 'Mztn', 'MztT': 'MztT.CrsD'}` and `LngS`, filterID 1299870830. + for (code, ty) in [("CrsD", "coarseDots"), ("LngS", "longStrokes")] { + let item = measured_item("Mezzotint...", Descriptor::new("Mztn").with("MztT", enumv("MztT", code)), 1_299_870_830); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), ("filter.pixelate.mezzotint", &json!({"type": ty}))); + } + // No type or an unknown type is Fine Dots, like the engine; the seed + // (PhotoCraft only) is not saved. + for p in [json!({}), json!({"type": "sparkles"}), json!({"type": "fineDots", "seed": 77})] { + let d = item_for_filter(&sf("filter.pixelate.mezzotint", p)).unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert_eq!(enum_of(fltr, "MztT"), Some(&b"FnDt"[..])); + assert_eq!(fltr.items.len(), 1); + } + } + + #[test] + fn color_halftone_round_trips_and_reads_photoshops_shape() { + for p in [ + json!({"maxRadius": 8, "channel1": 108, "channel2": 162, "channel3": 90, "channel4": 45}), + json!({"maxRadius": 4, "channel1": -360, "channel2": 0, "channel3": 360, "channel4": -45}), + json!({"maxRadius": 127, "channel1": 1, "channel2": 2, "channel3": 3, "channel4": 4}), + ] { + let f = sf("filter.pixelate.colorHalftone", p); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1_131_180_616)); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("ClrH")); + for k in ["Rds ", "Ang1", "Ang2", "Ang3", "Ang4"] { + assert!(matches!(fltr.get(k), Some(Value::Integer(_))), "{k} is a long"); + } + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + // Measured in Photoshop: `{'_class': 'ClrH', 'Rds ': 6, 'Ang1': 10, 'Ang2': 20, 'Ang3': 30, 'Ang4': 40}`. + let measured = Descriptor::new("ClrH") + .with("Rds ", Value::Integer(6)) + .with("Ang1", Value::Integer(10)) + .with("Ang2", Value::Integer(20)) + .with("Ang3", Value::Integer(30)) + .with("Ang4", Value::Integer(40)); + let item = measured_item("Color Halftone...", measured.clone(), 1_131_180_616); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.pixelate.colorHalftone"); + assert_eq!(f.params, json!({"maxRadius": 6, "channel1": 10, "channel2": 20, "channel3": 30, "channel4": 40})); + assert_eq!(get_desc(&item_for_filter(&f).unwrap(), "Fltr"), Some(&measured)); + // Defaults and hostile values write longs within the dialog's ranges. + let d = item_for_filter(&sf("filter.pixelate.colorHalftone", json!({"maxRadius": f64::NAN, "channel1": 1e12, "channel2": -1e12, "channel3": "x"}))) + .unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + let got: Vec<_> = ["Rds ", "Ang1", "Ang2", "Ang3", "Ang4"].iter().map(|k| int(fltr, k)).collect(); + assert_eq!(got, [Some(8), Some(360), Some(-360), Some(90), Some(45)]); + } + + #[test] + fn malformed_pixelate_filters_stay_verbatim() { + let mz = |v: Value| Descriptor::new("Mztn").with("MztT", v); + let ch = |rds: Value, ang1: Value| { + Descriptor::new("ClrH") + .with("Rds ", rds) + .with("Ang1", ang1) + .with("Ang2", Value::Integer(20)) + .with("Ang3", Value::Integer(30)) + .with("Ang4", Value::Integer(40)) + }; + let cases = [ + measured_item("Mezzotint...", Descriptor::new("Mztn"), code(b"Mztn")), + measured_item("Mezzotint...", mz(enumv("MztT", "Sprk")), code(b"Mztn")), + measured_item("Mezzotint...", mz(Value::Integer(1)), code(b"Mztn")), + measured_item("Color Halftone...", Descriptor::new("ClrH"), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Double(6.0), Value::Integer(10)), code(b"ClrH")), + measured_item("Color Halftone...", ch(unit(b"#Pxl", 6.0), Value::Integer(10)), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Integer(3), Value::Integer(10)), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Integer(128), Value::Integer(10)), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(361)), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(i32::MIN)), code(b"ClrH")), + measured_item("Color Halftone...", ch(Value::Integer(6), Value::Double(f64::NAN)), code(b"ClrH")), + ]; + for item in cases { + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + // Written back with its own settings (blending is rewritten from the model). + let back = item_for_filter(&f).unwrap(); + // Compared as bytes: a NaN setting is not equal to itself. + let fltr = |d: &Descriptor| get_desc(d, "Fltr").map(Descriptor::to_bytes); + assert_eq!((fltr(&back), int(&back, "filterID")), (fltr(&item), int(&item, "filterID"))); + } + // Without a `Fltr` the filter has no settings to read: kept verbatim too. + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"ClrH"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..2688d0bfdb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Mezzotint and Color Halftone mapped both ways, FILE-215-2; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (16 mapped, incl. Mezzotint and Color Halftone, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Mezzotint (`Mztn`, `MztT` enum, all 10 types) and Color Halftone (`ClrH`, `Rds `/`Ang1`–`Ang4` longs) open as editable filters and save in Photoshop's shape, measured in Photoshop (FILE-215-2); Mezzotint's PhotoCraft-only seed is not saved | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..c8074ea90c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 16 filters (incl. Mezzotint and Color Halftone) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..5998494d9f 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 16 filters (incl. Mezzotint and Color Halftone) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From b43be752645d6f64040215b05252222fb145e804 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 23:10:26 +0200 Subject: [PATCH 0263/1452] PSD: Wind, Extrude and Tiles smart filters open and save like Photoshop (#6314) Wind, Extrude and Tiles applied as smart filters were dropped from PSD saves with a warning, and the same filters in a Photoshop PSD opened only as verbatim, uneditable entries. smart_map.rs now maps them both ways in filterFX: - Wind (`Wnd `, filterID 1466852384): `WndM` enum (`Wnd `/`Blst`/`Stgr`) <-> method; `Drct` enum: `Left` <-> fromRight (streaks extend to the left), `Rght` <-> fromLeft. - Extrude (`Extr`, 1165522034): `ExtT` enum (`Blks`/`Pyrm`) <-> type, `ExtS`/`ExtD` long <-> size/depth, `ExtR` enum (`Rndm`/`LvlB`) <-> depthMode, `ExtF`/`ExtM` bool <-> solidFrontFaces/maskIncompleteBlocks. - Tiles (`Tls `, 1416393504): `TlNm`/`TlOf` long <-> count/maxOffset, `FlCl` enum (`FlBc`/`FlFr`/`FlIn`/`FlSm`) <-> fill; the entry's `FrgC`/`BckC` <-> foreground/background. Our random seed has no PSD key and is not written. Missing keys, wrong value types, out-of-range numbers and unknown enum values keep the entry verbatim; params with no PSD equivalent are reported, never guessed. Measured in Photoshop: applied through Action Manager to a smart object, saved, filterFX read back; each code also checked on a test image (pixels compared). Scorecard FILE-215-2 note and file-format parity updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/smart_map.rs | 374 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 4 files changed, 372 insertions(+), 11 deletions(-) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..ecdad180f6 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -144,7 +144,7 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 17] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -156,6 +156,9 @@ const KNOWN: [Known; 14] = [ Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, + Known { class: "Wnd ", filter_id: code(b"Wnd "), name: "Wind...", command: "filter.stylize.wind" }, + Known { class: "Extr", filter_id: code(b"Extr"), name: "Extrude...", command: "filter.stylize.extrude" }, + Known { class: "Tls ", filter_id: code(b"Tls "), name: "Tiles...", command: "filter.stylize.tiles" }, Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). @@ -258,9 +261,67 @@ fn curves_params(f: &Descriptor) -> Option { Some(J::Object(out)) } +// Wind, Extrude and Tiles, measured in Photoshop (each code changes the rendered result). + +/// Wind's `WndM` (enum `WndM`) ↔ our `method`. +const WIND_METHODS: [(&str, &str); 3] = [("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")]; +/// Wind's `Drct` (enum `Drct`) ↔ our `direction`. Photoshop's `Left` streaks extend to the left +/// of an edge, which is our wind from the right. +const WIND_DIRECTIONS: [(&str, &str); 2] = [("Left", "fromRight"), ("Rght", "fromLeft")]; +/// Extrude's `ExtT` (enum `ExtT`) ↔ our `type`. +const EXTRUDE_TYPES: [(&str, &str); 2] = [("Blks", "blocks"), ("Pyrm", "pyramids")]; +/// Extrude's `ExtR` (enum `ExtR`) ↔ our `depthMode`. +const EXTRUDE_DEPTHS: [(&str, &str); 2] = [("Rndm", "random"), ("LvlB", "levelBased")]; +/// Tiles' `FlCl` (enum `FlCl`) ↔ our `fill`. +const TILE_FILLS: [(&str, &str); 4] = [("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")]; +/// Extrude's size and depth (`ExtS`, `ExtD`) and Tiles' count and offset (`TlNm`, `TlOf`), all +/// `long`s, in the engine's (and Photoshop's dialog) ranges. +const EXTRUDE_SIZE: (i32, i32) = (2, 255); +const EXTRUDE_DEPTH: (i32, i32) = (1, 255); +const TILE_COUNT: (i32, i32) = (1, 99); +const TILE_OFFSET: (i32, i32) = (1, 99); + +/// The `long` at `key` when it lies in `range` (inclusive). +fn long_within(d: &Descriptor, key: &str, range: (i32, i32)) -> Option { + int(d, key).filter(|v| (range.0..=range.1).contains(v)) +} + +/// The `bool` at `key`. +fn bool_at(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Boolean(b) => Some(*b), + _ => None, + } +} + +/// Our name for the enum at `key`, which must be of type `type_id`, through `table`. +fn enum_from(d: &Descriptor, key: &str, type_id: &str, table: &[(&str, &'static str)]) -> Option<&'static str> { + match d.get(key)? { + Value::Enumerated { type_id: t, value } if t.as_bytes() == type_id.as_bytes() => { + table.iter().find(|(psd, _)| value.as_bytes() == psd.as_bytes()).map(|(_, ours)| *ours) + } + _ => None, + } +} + +/// Tiles' params; background and foreground fills use the colours the entry carries (`FrgC`, +/// `BckC`). `None` when a setting or a colour that is there can't be read. +fn tiles_params(f: &Descriptor, item: &Descriptor) -> Option { + let mut out = Map::new(); + out.insert("count".into(), json!(long_within(f, "TlNm", TILE_COUNT)?)); + out.insert("maxOffset".into(), json!(long_within(f, "TlOf", TILE_OFFSET)?)); + out.insert("fill".into(), json!(enum_from(f, "FlCl", "FlCl", &TILE_FILLS)?)); + for (psd, ours) in [("FrgC", "foreground"), ("BckC", "background")] { + if item.get(psd).is_some() { + out.insert(ours.into(), get_desc(item, psd).and_then(rgbc_colour)?); + } + } + Some(J::Object(out)) +} + /// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its -/// settings) is not modelled. -fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J)> { +/// settings) is not modelled. `item` is the whole `filterFX` entry (Tiles reads its colours). +fn known_params(class: &str, f: Option<&Descriptor>, item: &Descriptor) -> Option<(&'static str, J)> { let k = KNOWN.iter().find(|k| k.class == class)?; let params = match (class, f) { ("adaptCorrect", None) => json!({}), @@ -282,6 +343,19 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) } ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + ("Wnd ", Some(f)) => json!({ + "method": enum_from(f, "WndM", "WndM", &WIND_METHODS)?, + "direction": enum_from(f, "Drct", "Drct", &WIND_DIRECTIONS)?, + }), + ("Extr", Some(f)) => json!({ + "type": enum_from(f, "ExtT", "ExtT", &EXTRUDE_TYPES)?, + "size": long_within(f, "ExtS", EXTRUDE_SIZE)?, + "depth": long_within(f, "ExtD", EXTRUDE_DEPTH)?, + "depthMode": enum_from(f, "ExtR", "ExtR", &EXTRUDE_DEPTHS)?, + "solidFrontFaces": bool_at(f, "ExtF")?, + "maskIncompleteBlocks": bool_at(f, "ExtM")?, + }), + ("Tls ", Some(f)) => tiles_params(f, item)?, ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -470,7 +544,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), - c => c.and_then(|c| known_params(c, fltr)), + c => c.and_then(|c| known_params(c, fltr, item)), }; let (command, params) = match known { Some((c, p)) => (c.to_string(), p), @@ -574,6 +648,26 @@ fn adjustments(p: &J, curves: bool) -> Result { Ok(Value::List(list)) } +/// Our enum param `key` (`default` when absent) as Photoshop's enum of type `type_id`. +fn enum_to(p: &J, key: &str, default: &str, type_id: &str, table: &[(&str, &str)]) -> Result { + let ours = match p.get(key) { + None => default, + Some(v) => v.as_str().ok_or_else(|| format!("`{key}` is not a string"))?, + }; + let (psd, _) = table.iter().find(|(_, o)| *o == ours).ok_or_else(|| format!("{key} `{ours}` has no PSD equivalent"))?; + Ok(enumv(type_id, psd)) +} + +/// Param `key` (`default` when absent or not a finite number) as a `long` clamped to `range`. +fn long_clamped(p: &J, key: &str, default: f64, range: (i32, i32)) -> Value { + Value::Integer(pf(p, key, default).round().clamp(f64::from(range.0), f64::from(range.1)) as i32) +} + +/// Param `key` as a `bool` (false when absent or not a bool, as the engine reads it). +fn bool_param(p: &J, key: &str) -> Value { + Value::Boolean(p.get(key).and_then(J::as_bool).unwrap_or(false)) +} + /// The `Fltr` descriptor of a modelled filter (`None` = written without options). fn fltr_for(k: &Known, p: &J) -> Result, String> { let d = Descriptor::new(k.class); @@ -595,6 +689,21 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + // Photoshop stores no random seed for these: our `seed` is not written. + "Wnd " => { + d.with("WndM", enum_to(p, "method", "wind", "WndM", &WIND_METHODS)?).with("Drct", enum_to(p, "direction", "fromRight", "Drct", &WIND_DIRECTIONS)?) + } + "Extr" => d + .with("ExtT", enum_to(p, "type", "blocks", "ExtT", &EXTRUDE_TYPES)?) + .with("ExtS", long_clamped(p, "size", 30.0, EXTRUDE_SIZE)) + .with("ExtD", long_clamped(p, "depth", 30.0, EXTRUDE_DEPTH)) + .with("ExtR", enum_to(p, "depthMode", "random", "ExtR", &EXTRUDE_DEPTHS)?) + .with("ExtF", bool_param(p, "solidFrontFaces")) + .with("ExtM", bool_param(p, "maskIncompleteBlocks")), + "Tls " => d + .with("TlNm", long_clamped(p, "count", 10.0, TILE_COUNT)) + .with("TlOf", long_clamped(p, "maxOffset", 10.0, TILE_OFFSET)) + .with("FlCl", enum_to(p, "fill", "background", "FlCl", &TILE_FILLS)?), "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -633,13 +742,19 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { } let k = known_by_command(&f.command).ok_or("this filter has no PSD equivalent")?; let fltr = fltr_for(k, &f.params)?; + // Tiles fills with the entry's colours; the others write the defaults. + let (foreground, background) = if k.class == "Tls " { + (colour_rgbc(f.params.get("foreground"), 0.0)?, colour_rgbc(f.params.get("background"), 255.0)?) + } else { + (rgbc(0.0), rgbc(255.0)) + }; let mut d = Descriptor::new("filterFX") .with("Nm ", Value::Text(UnicodeString::new_nul(k.name))) .with("blendOptions", blend_options(f)) .with("enab", Value::Boolean(f.visible)) .with("hasoptions", Value::Boolean(fltr.is_some())) - .with("FrgC", rgbc(0.0)) - .with("BckC", rgbc(255.0)); + .with("FrgC", foreground) + .with("BckC", background); if let Some(x) = fltr { d = d.with("Fltr", Value::Descriptor(x)); } @@ -1069,7 +1184,7 @@ mod tests { // A pencil-drawn curve: 256 output levels (`Mpng`). sf("image.adjustments.curves", json!({"pencil": true, "points": (0..256).rev().collect::>()})), ]; - for mut f in filters { + for mut f in filters.into_iter().chain(wind_extrude_tiles_settings()) { f.blend = BlendMode::Overlay; f.opacity = 0.75; f.visible = false; @@ -1080,6 +1195,251 @@ mod tests { } } + /// Every option value of Wind, Extrude and Tiles (and the ends of their ranges). + fn wind_extrude_tiles_settings() -> Vec { + let mut v = Vec::new(); + for method in ["wind", "blast", "stagger"] { + for direction in ["fromRight", "fromLeft"] { + v.push(sf("filter.stylize.wind", json!({"method": method, "direction": direction}))); + } + } + for t in ["blocks", "pyramids"] { + for mode in ["random", "levelBased"] { + for (solid, mask) in [(false, false), (true, false), (false, true), (true, true)] { + v.push(sf( + "filter.stylize.extrude", + json!({"type": t, "size": 7, "depth": 9, "depthMode": mode, "solidFrontFaces": solid, "maskIncompleteBlocks": mask}), + )); + } + } + } + v.push(sf( + "filter.stylize.extrude", + json!({"type": "blocks", "size": 2, "depth": 1, "depthMode": "random", "solidFrontFaces": false, "maskIncompleteBlocks": false}), + )); + v.push(sf( + "filter.stylize.extrude", + json!({"type": "pyramids", "size": 255, "depth": 255, "depthMode": "levelBased", "solidFrontFaces": true, "maskIncompleteBlocks": true}), + )); + for fill in ["background", "foreground", "inverse", "unaltered"] { + v.push(sf( + "filter.stylize.tiles", + json!({"count": 6, "maxOffset": 20, "fill": fill, "foreground": [1.0, 0.0, 0.5, 1.0], "background": [0.0, 0.5, 1.0, 1.0]}), + )); + } + for (count, offset) in [(1, 1), (99, 99)] { + v.push(sf( + "filter.stylize.tiles", + json!({"count": count, "maxOffset": offset, "fill": "background", "foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + )); + } + v + } + + /// A `filterFX` entry shaped as Photoshop saves Wind, Extrude and Tiles (measured in Photoshop: + /// applied through Action Manager to a smart object, saved, `filterFX` read back): the item's + /// `FrgC`/`BckC` are the document colours, `Fltr` holds the settings, `filterID` the class code. + fn measured_item(fltr: Descriptor, fg: f64, bg: f64) -> Descriptor { + let id = code(fltr.class_id.as_bytes().try_into().unwrap()); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter..."))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(fg)) + .with("BckC", rgbc(bg)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(id)) + } + + fn wind(method: &str, direction: &str) -> Descriptor { + Descriptor::new("Wnd ").with("WndM", enumv("WndM", method)).with("Drct", enumv("Drct", direction)) + } + + fn extrude(t: &str, size: Value, depth: Value, mode: &str, solid: Value, mask: Value) -> Descriptor { + Descriptor::new("Extr") + .with("ExtT", enumv("ExtT", t)) + .with("ExtS", size) + .with("ExtD", depth) + .with("ExtR", enumv("ExtR", mode)) + .with("ExtF", solid) + .with("ExtM", mask) + } + + fn tiles(count: Value, offset: Value, fill: &str) -> Descriptor { + Descriptor::new("Tls ").with("TlNm", count).with("TlOf", offset).with("FlCl", enumv("FlCl", fill)) + } + + #[test] + fn wind_extrude_tiles_open_from_photoshop_shapes() { + let (long, yes, no) = (Value::Integer, Value::Boolean(true), Value::Boolean(false)); + let cases = [ + // The measured files. + (wind("Blst", "Left"), "filter.stylize.wind", json!({"method": "blast", "direction": "fromRight"})), + (wind("Stgr", "Rght"), "filter.stylize.wind", json!({"method": "stagger", "direction": "fromLeft"})), + (wind("Wnd ", "Rght"), "filter.stylize.wind", json!({"method": "wind", "direction": "fromLeft"})), + ( + extrude("Blks", long(7), long(9), "Rndm", yes.clone(), no.clone()), + "filter.stylize.extrude", + json!({"type": "blocks", "size": 7, "depth": 9, "depthMode": "random", "solidFrontFaces": true, "maskIncompleteBlocks": false}), + ), + ( + extrude("Pyrm", long(12), long(20), "LvlB", no, yes), + "filter.stylize.extrude", + json!({"type": "pyramids", "size": 12, "depth": 20, "depthMode": "levelBased", "solidFrontFaces": false, "maskIncompleteBlocks": true}), + ), + ( + tiles(long(6), long(20), "FlFr"), + "filter.stylize.tiles", + json!({"count": 6, "maxOffset": 20, "fill": "foreground", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(4), long(5), "FlIn"), + "filter.stylize.tiles", + json!({"count": 4, "maxOffset": 5, "fill": "inverse", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(10), long(10), "FlBc"), + "filter.stylize.tiles", + json!({"count": 10, "maxOffset": 10, "fill": "background", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(99), long(1), "FlSm"), + "filter.stylize.tiles", + json!({"count": 99, "maxOffset": 1, "fill": "unaltered", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ]; + for (fltr, command, params) in cases { + let item = measured_item(fltr, 51.0, 255.0); + // Through bytes, as read from a file. + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back in the measured shape: same class, keys and their order, value types + // (long, bool, enum with its type), filterID; Tiles also its colours. + let back = item_for_filter(&f).unwrap(); + assert_eq!(get_desc(&back, "Fltr").map(Descriptor::to_bytes), get_desc(&item, "Fltr").map(Descriptor::to_bytes), "{command}"); + assert_eq!(back.get("filterID"), item.get("filterID"), "{command}"); + if command == "filter.stylize.tiles" { + assert_eq!((back.get("FrgC"), back.get("BckC")), (item.get("FrgC"), item.get("BckC"))); + } + } + // The class codes: numeric filterIDs of the four-char classes. + for (c, id) in [("filter.stylize.wind", 1466852384), ("filter.stylize.extrude", 1165522034), ("filter.stylize.tiles", 1416393504)] { + assert_eq!(item_for_filter(&sf(c, json!({}))).unwrap().get("filterID"), Some(&Value::Integer(id))); + } + // A filter found by its filterID alone (no `Fltr` class to go by) still needs its settings. + let bare = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(1466852384)); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + // Only Tiles reads the entry's colours; Wind and Extrude write the defaults. + let f = filter_from_item(&measured_item(wind("Wnd ", "Left"), 51.0, 0.0)); + assert_eq!(f.params, json!({"method": "wind", "direction": "fromRight"})); + let back = item_for_filter(&f).unwrap(); + assert_eq!((back.get("FrgC"), back.get("BckC")), (Some(&rgbc(0.0)), Some(&rgbc(255.0)))); + // Tiles without colours in its params (or the entry) writes the engine's black and white. + let f = filter_from_item(&Descriptor::new("filterFX").with("Fltr", Value::Descriptor(tiles(long(4), long(5), "FlBc")))); + assert_eq!(f.params, json!({"count": 4, "maxOffset": 5, "fill": "background"})); + let back = item_for_filter(&f).unwrap(); + assert_eq!((back.get("FrgC"), back.get("BckC")), (Some(&rgbc(0.0)), Some(&rgbc(255.0)))); + } + + #[test] + fn wind_extrude_tiles_write_defaults_and_photoshop_ranges() { + let fltr = |c: &str, p: J| match item_for_filter(&sf(c, p)).unwrap().get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("no Fltr: {other:?}"), + }; + let long = Value::Integer; + let (yes, no) = (Value::Boolean(true), Value::Boolean(false)); + // Defaults are the engine's; our random seed has no PSD key and is not written. + assert_eq!(fltr("filter.stylize.wind", json!({"seed": 9})), wind("Wnd ", "Left")); + assert_eq!(fltr("filter.stylize.extrude", json!({"seed": 9})), extrude("Blks", long(30), long(30), "Rndm", no.clone(), no.clone())); + assert_eq!(fltr("filter.stylize.tiles", json!({"seed": 9})), tiles(long(10), long(10), "FlBc")); + // Out of range, fractional, non-numeric and non-bool values as the engine reads them. + assert_eq!( + fltr("filter.stylize.extrude", json!({"size": 1e12, "depth": -5, "solidFrontFaces": "yes", "maskIncompleteBlocks": true})), + extrude("Blks", long(255), long(1), "Rndm", no.clone(), yes) + ); + assert_eq!(fltr("filter.stylize.tiles", json!({"count": 0, "maxOffset": 12.6})), tiles(long(1), long(13), "FlBc")); + assert_eq!(fltr("filter.stylize.tiles", json!({"count": "many", "maxOffset": 1000})), tiles(long(10), long(99), "FlBc")); + assert_eq!(fltr("filter.stylize.extrude", json!({"size": f64::NAN})), extrude("Blks", long(30), long(30), "Rndm", no.clone(), no)); + } + + #[test] + fn malformed_wind_extrude_tiles_are_kept_verbatim() { + let long = Value::Integer; + let (yes, no) = (Value::Boolean(true), Value::Boolean(false)); + let cases = [ + // Missing keys. + Descriptor::new("Wnd "), + Descriptor::new("Wnd ").with("WndM", enumv("WndM", "Blst")), + Descriptor::new("Wnd ").with("Drct", enumv("Drct", "Left")), + Descriptor::new("Extr").with("ExtT", enumv("ExtT", "Blks")).with("ExtS", long(7)).with("ExtD", long(9)).with("ExtR", enumv("ExtR", "Rndm")), + Descriptor::new("Tls ").with("TlNm", long(6)).with("TlOf", long(20)), + Descriptor::new("Tls ").with("TlNm", long(6)).with("FlCl", enumv("FlCl", "FlFr")), + // Unknown enum values and enum types (Photoshop falls back to a default for these; + // we keep them as they are rather than guess). + wind("Wild", "Left"), + wind("Blst", "Up "), + Descriptor::new("Wnd ").with("WndM", enumv("Drct", "Blst")).with("Drct", enumv("Drct", "Left")), + extrude("Cube", long(7), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(9), "Sine", yes.clone(), no.clone()), + tiles(long(6), long(20), "FlXx"), + Descriptor::new("Tls ").with("TlNm", long(6)).with("TlOf", long(20)).with("FlCl", enumv("BlnM", "FlFr")), + // Wrong types (also NaN). + Descriptor::new("Wnd ").with("WndM", long(1)).with("Drct", enumv("Drct", "Left")), + extrude("Blks", Value::Double(7.0), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), unit(b"#Pxl", 9.0), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(9), "Rndm", long(1), no.clone()), + extrude("Blks", long(7), long(9), "Rndm", yes.clone(), Value::Double(f64::NAN)), + tiles(Value::Double(f64::NAN), long(20), "FlFr"), + tiles(long(6), Value::Text(UnicodeString::new_nul("20")), "FlFr"), + // Out of range. + extrude("Blks", long(1), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(256), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(0), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(i32::MAX), "Rndm", yes, no), + tiles(long(0), long(20), "FlFr"), + tiles(long(100), long(20), "FlFr"), + tiles(long(6), long(0), "FlFr"), + tiles(long(6), long(i32::MIN), "FlFr"), + ]; + for fltr in cases { + let item = measured_item(fltr, 0.0, 255.0); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // A Tiles entry whose colour is unreadable is kept verbatim too. + for (key, bad) in [ + ("FrgC", Value::Integer(3)), + ("BckC", Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))), + ( + "BckC", + Value::Descriptor( + Descriptor::new("RGBC").with("Rd ", Value::Double(f64::NAN)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)), + ), + ), + ] { + let mut item = measured_item(tiles(long(6), long(20), "FlFr"), 0.0, 255.0); + set(&mut item, key, bad); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + // Values with no PSD equivalent are reported, not guessed. + for (c, p) in [ + ("filter.stylize.wind", json!({"method": "gale"})), + ("filter.stylize.wind", json!({"direction": "fromTop"})), + ("filter.stylize.wind", json!({"direction": 1})), + ("filter.stylize.extrude", json!({"type": "cubes"})), + ("filter.stylize.extrude", json!({"depthMode": null})), + ("filter.stylize.tiles", json!({"fill": "transparent"})), + ("filter.stylize.tiles", json!({"foreground": "teal"})), + ("filter.stylize.tiles", json!({"background": [1.0]})), + ("filter.stylize.tiles", json!({"background": [f64::NAN, 0.0, 0.0]})), + ] { + assert!(item_for_filter(&sf(c, p.clone())).is_err(), "{c} {p}"); + } + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..7108f7d31f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PSD smart filters: Wind, Extrude and Tiles mapped both ways, #6314; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (17 mapped, incl. Wind, Extrude and Tiles, plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD smart filters: Wind (`Wnd `, `WndM` method, `Drct` direction), Extrude (`Extr`, `ExtT` type, size, depth, `ExtR` depth mode, solid front faces, mask incomplete blocks) and Tiles (`Tls `, count, offset, `FlCl` fill with the entry's `FrgC`/`BckC`) open as editable smart filters and save in the shape measured in Photoshop (#6314, FILE-215-2) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..cc47d47994 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -99,7 +99,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 17 filters (incl. Wind, Extrude, Tiles as measured in Photoshop) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..129c6de68d 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 17 filters (incl. Wind, Extrude, Tiles as measured in Photoshop) plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From a008842a853ce81882d2e8b6056df4e99c4a459e Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 17:32:01 -0400 Subject: [PATCH 0264/1452] =?UTF-8?q?Help=20=E2=80=BA=20Search:=20?= =?UTF-8?q?=E2=86=90=20/=20=E2=86=92=20and=20Space=20edit=20the=20field=20?= =?UTF-8?q?instead=20of=20driving=20the=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a text field in an open menu has the keyboard, Nav::keys leaves ← / → (cursor) and Space (a typed space) to it: ← / → no longer switch Help to Window / File, and Space no longer runs the lit result. ↑ / ↓, ↩ and Esc are unchanged. Follow-up to #5996 / #6030 (letters). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_nav.rs | 7 +++++- crates/ui-egui/src/menu_nav_tests.rs | 35 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 822fcae2bd..879bd4397a 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -336,7 +336,12 @@ impl Nav { let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return }; let level = self.level.min(self.rows.len().saturating_sub(1)); self.level = level; - let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its + // cursor and Space types a space. + let typing = ctx.text_edit_focused(); + let press = |k: Key| { + !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) + }; let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { let n = tops.len(); diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 1643e2765b..659a794b3e 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -265,6 +265,41 @@ fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() { assert!(!is_open(&h.ctx), "shortcuts work again"); } +/// In Help › Search, ← / → move the field's cursor instead of switching to Window / File, and +/// Space types a space instead of running the lit row. +#[test] +fn help_search_keeps_the_arrows_and_space_that_edit_it() { + let mut h = harness((1280.0, 720.0, 1.0)); + open(&mut h, "Help"); + let field = egui::Id::new("help-menu-search").with("field"); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(field), "the search field has the keyboard"); + let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("help-menu-search"))).unwrap_or_default(); + let typing = |h: &mut Harness<'static, PhotocraftApp>, s: &str| { + h.event(egui::Event::Text(s.to_string())); + h.run_steps(3); + }; + typing(&mut h, "levls"); + key(&mut h, egui::Key::ArrowLeft); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "← keeps Help open"); + key(&mut h, egui::Key::ArrowLeft); + typing(&mut h, "e"); + assert_eq!(text(&h), "levels", "← moved the cursor"); + key(&mut h, egui::Key::ArrowRight); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open"); + // The pointer resting on a result lights it; a space typed then is text, not a click. + let levels = h.get_by_label_contains("› Levels").rect().center(); + h.hover_at(levels); + h.run_steps(3); + assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit"); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::Text(" ".into())); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert!(is_open(&h.ctx) && h.query_by_label_contains("About PhotoCraft").is_some(), "Space keeps Help open"); + assert!(h.state().ui.dialogs.is_empty(), "Space ran no command: {:?}", h.state().ui.dialogs); + assert_eq!(text(&h), "levels "); +} + /// A level's visible height (its view) and the full height of its rows: equal means no scrolling. fn fits(h: &Harness<'static, PhotocraftApp>, level: usize) -> (f32, f32) { let nav = Nav::current(&h.ctx); From 4258089716133d7776d02a618c5830c143555bde Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:38:01 +0200 Subject: [PATCH 0265/1452] Replace Color: the sample eyedropper is armed when the dialog opens (#6279) Photoshop's first click on the image after opening Replace Color samples the colour. The dialog now opens (and resets) with its canvas eyedropper active; the pipette button still turns it off and on. --- crates/ui-egui/src/filter_dialog.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..83944a1b03 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -388,6 +388,11 @@ fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map Date: Thu, 15 Oct 2026 23:46:54 +0200 Subject: [PATCH 0266/1452] Smart filters: an edited filter's dialog previews again (#6324) Re-opening a smart filter dropped the dialog's Preview, since the live preview ran the filter command and would have stacked a second copy. The preview now runs layer.smartFilter.setParams on the proxy, as OK does, at full size and reduced afterwards (the smart object renders again from its source, which the proxy doesn't scale), so it shows the filter replaced in its stack. --- crates/ui-egui/src/canvas.rs | 10 ++++-- crates/ui-egui/src/filter_dialog.rs | 7 +++-- crates/ui-egui/src/smart_ui.rs | 48 +++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..22568ba3f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1704,8 +1704,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e return None; } let cmd = d.fields.get("__command")?.as_str()?.to_string(); - // Previews edit what the command will: a targeted layer mask included (#780). - let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + // Previews edit what the command will: a targeted layer mask included (#780), and a smart + // filter being edited is replaced in its stack rather than stacked again (#6324). + let (cmd, mut params) = if d.fields.contains_key("__smartFilter") { + crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields)) + } else { + let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + (cmd, params) + }; // Select and Mask previews in its view mode; holding X shows the image without one. if crate::select_mask_view::owns(&d.fields) { if crate::select_mask_view::hides_view(&d.fields) { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..80bcd57d23 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -607,6 +607,8 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { /// eyedropper like Photoshop's. While armed, the next click on the image writes the merged /// composite colour into the `color` field (the same sampler the Color Picker uses). const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +/// What OK on an edited smart filter runs (`smart_ui::confirm_command`); its preview too. +const SMART_FILTER_SET_PARAMS: &str = "layer.smartFilter.setParams"; const COLOR_EYEDROPPER: &str = "__colorEyedropper"; fn color_eyedropper_owns(f: &Map) -> bool { @@ -896,8 +898,9 @@ pub(crate) fn preview_document_for_target( return crate::select_mask_view::preview_document(doc, active, params, k, cancel); } // Warp Text renders the type again from the layer model (font size, position), which a - // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). - let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; + // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). So does + // a smart filter being edited: the smart object renders again from its source (#6324). + let (run_k, reduce_k) = if command == "type.warpText" || command == SMART_FILTER_SET_PARAMS { (1, k) } else { (k, 1) }; let proxy = crate::proxy::proxy_document(doc, run_k); let mut s = photocraft_engine::Session::new(); s.set_inline_job_ctx(cancel.cloned()); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 7f49843c53..722e8fe910 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -314,8 +314,12 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: /// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { if let Some(d) = app.ui.dialog_mut(id) { - // The live preview would stack a second copy of the filter on the smart object. - d.fields.remove("__preview"); + // A filter's live preview runs `layer.smartFilter.setParams` on the proxy, as OK does + // (`canvas::ensure_filter_preview`, #6324). An adjustment's previews on the layer and + // would stack a second copy of it on the smart object. + if d.fields.contains_key("__adjust") { + d.fields.remove("__preview"); + } d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); if let Value::Object(p) = params { for (k, v) in p { @@ -452,4 +456,44 @@ mod tests { assert_eq!(sm.smart_filters[0].params.get("brightness").and_then(Value::as_f64), Some(40.0)); assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); } + + /// #6324: a smart filter opened for editing previews, and its preview replaces the filter in + /// the stack (as OK does) instead of stacking a second one. + #[test] + fn an_edited_smart_filter_previews_what_ok_gives() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "#ffffff"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 4, "color": "#000000", "location": "inside"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1.0})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.background_jobs = false; + let smart = |app: &PhotocraftApp, doc: &Document| match &doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?} ({})", app.ui.status), + }; + let sm = smart(&app, &app.session.active().unwrap().doc); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last_mut().expect("the filter's dialog opened"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "the dialog previews"); + d.fields.insert("radius".into(), json!(6.0)); + let dialog = d.id; + + crate::canvas::ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + let preview = app.filter_preview.as_ref().unwrap(); + assert_eq!(preview.key.command, "layer.smartFilter.setParams"); + let shown = preview.result.clone().expect("the preview's document"); + let filters = smart(&app, &shown).smart_filters; + assert_eq!(filters.len(), 1, "the edited filter is replaced, not stacked"); + assert_eq!(filters[0].params.get("radius").and_then(Value::as_f64), Some(6.0)); + assert_eq!(smart(&app, &app.session.active().unwrap().doc).smart_filters[0].params["radius"], json!(1.0), "the document is untouched"); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(smart(&app, doc).smart_filters.len(), 1); + assert!(photocraft_compose::flatten(&shown).px == photocraft_compose::flatten(doc).px, "OK gives what the preview showed"); + } } From f71eb28f844f8d47c73edf88227137a499cc5088 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:55:58 +0200 Subject: [PATCH 0267/1452] Replace Color: a Result swatch whose Color Picker sets the sliders (#6278) Photoshop's Replace Color shows the replacement colour under the Hue, Saturation and Lightness sliders; picking one in its Color Picker sets the sliders so the sampled colour becomes it. tone::replace_color_result gives the swatch (the sampled colour after the shift) and tone::replace_color_shift the slider values for a picked colour. --- crates/algo/src/tone.rs | 51 +++++++++++++++++ crates/ui-egui/src/filter_dialog.rs | 85 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 5 files changed, 139 insertions(+) diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 2632d4a29a..5a0f752fac 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: mask } +/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100) +/// shift, as a fully matched pixel of that colour becomes. +pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let mut px = [[color[0], color[1], color[2], 1.0]]; + replace_color_masked(&mut px, &[1.0], hue, saturation, lightness); + [px[0][0], px[0][1], px[0][2]] +} + +/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as +/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has +/// no hue to turn, black or white no saturation to give) goes as far as they do. +pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) { + let [fh, fs, fl] = rgb_to_hsl(from); + let [th, ts, tl] = rgb_to_hsl(to); + // Hue turns the short way round; a grey target keeps the hue where it is. + let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 }; + // Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0. + let amount = |from: f32, to: f32| { + if to >= from { + if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 } + } else if from <= 0.0 { + 0.0 + } else { + (to / from - 1.0) * 100.0 + } + }; + (hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0)) +} + /// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` /// (`0..=1`, one per pixel: the colour match, localized or not) covers it. pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { @@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { mod tests { use super::*; + /// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets + /// the sliders that give it. + #[test] + fn replace_color_result_and_shift_round_trip() { + let from = [0.8, 0.2, 0.2]; + let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3); + assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change"); + for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] { + let (h, s, l) = replace_color_shift(from, to); + assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l)); + assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l)); + } + // Red to blue turns the short way (−120°, not +240°). + assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5); + // A grey target: saturation all the way down, the hue left alone. + let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]); + assert_eq!((h, s), (0.0, -100.0)); + // Black has no saturation to raise: the slider stays put rather than dividing by zero. + let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]); + assert!(h.is_finite() && s.is_finite() && l > 0.0); + } + fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> { (0..w * h) .map(|i| { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..f2212efa6c 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -637,6 +637,49 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - true } +/// The Color Picker of Replace Color's Result swatch writes here (#6278). +const RESULT_PICKED: &str = "__resultPicked"; +/// The Result the picker opened on: a picked colour that differs from it sets the sliders. +const RESULT_FROM: &str = "__resultFrom"; + +/// Replace Color's Result swatch (#6278), as in Photoshop: the sampled colour after the Hue, +/// Saturation and Lightness shift. Picking a Result in its Color Picker sets the three sliders so +/// the sampled colour becomes it. +fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { + use photocraft_algo::tone; + let t = Tokens::get(ui.ctx()); + let from = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([0.0; 3]); + let picked = f.get(RESULT_PICKED).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex); + if let Some(to) = picked + && f.get(RESULT_PICKED) != f.get(RESULT_FROM) + { + let (hue, saturation, lightness) = tone::replace_color_shift(from, to); + for (key, v) in [("hue", hue), ("saturation", saturation), ("lightness", lightness)] { + f.insert(key.into(), json!(v.round())); + } + f.remove(RESULT_PICKED); + f.remove(RESULT_FROM); + } + let num = |key: &str| f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let rgb = tone::replace_color_result(from, num("hue"), num("saturation"), num("lightness")); + let bytes = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let name = tl!("Result"); + let mut clicked = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), name).clicked(); + ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); + }); + }); + if clicked { + let hex = json!(crate::color_picker_ui::hex(rgb)); + f.insert(RESULT_PICKED.into(), hex.clone()); + f.insert(RESULT_FROM.into(), hex); + crate::color_picker_ui::request_field(f, RESULT_PICKED, &crate::color_picker_ui::title_for(name)); + } +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -832,6 +875,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } } + if cmd == REPLACE_COLOR { + replace_color_result(ui, f); + } // Read-only context the dialog opener supplies (e.g. the monitor profile in use). if let Some(note) = f.get("__note").and_then(Value::as_str) { ui.add_space(4.0); @@ -2383,6 +2429,45 @@ mod tests { assert!(!color_eyedropper_armed(h.state())); } + /// #6278: the Result swatch shows the sampled colour after the shift, and a colour picked for + /// it sets Hue, Saturation and Lightness so the sampled colour becomes it. + #[test] + fn replace_color_result_swatch_sets_the_sliders() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#cc3333"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + // No shift yet: the Result is the sampled colour. + assert_eq!(h.get_all_by_label("#cc3333").count(), 2, "the sampled colour and the Result both read #cc3333"); + h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Result").click(); + h.run_steps(3); + let picker = h.state().ui.dialogs.last().unwrap(); + assert_ne!(picker.id, id, "the Color Picker opened over the dialog"); + assert_eq!(picker.fields["color"], json!("#cc3333"), "on the current Result"); + let picker = picker.id; + h.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#3355cc")); + crate::dialogs::confirm(h.state_mut(), picker).unwrap(); + h.state_mut().ui.dialogs.retain(|d| d.id != picker); + h.run_steps(3); + let f = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + let n = |k: &str| f[k].as_f64().unwrap() as f32; + let got = photocraft_algo::tone::replace_color_result([0.8, 0.2, 0.2], n("hue"), n("saturation"), n("lightness")); + let want = crate::color_picker_ui::parse_hex("#3355cc").unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "the sliders turn the sampled colour into the pick: {got:?}"); + assert!(n("hue") != 0.0, "the hue moved"); + // Moving a slider afterwards is not mistaken for another pick. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("lightness".into(), json!(10.0)); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["lightness"], json!(10.0)); + } + /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point /// and both settings reach the command. #[test] diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2e12c33799..723d048568 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3374,3 +3374,4 @@ curves Smooth 매끄럽게 Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 Undo Changes 변경 사항 취소 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. + Result 결과 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbf3c9d08f..f02dfc1ff7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2812,3 +2812,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 Undo Changes 撤消更改 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 + Result 结果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 30aa3f5a94..3d82e5b4d3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2797,3 +2797,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 Undo Changes 還原變更 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 + Result 結果 From 8369b4b55cfb1cffd04c02fc8150c19174115435 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:04:50 +0200 Subject: [PATCH 0268/1452] =?UTF-8?q?File=20=E2=80=BA=20Open:=20a=20file?= =?UTF-8?q?=20type=20filter=20per=20format=20(#6337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After All Formats the Open dialog listed only PhotoCraft, OpenRaster, Krita, SVG and Paint.NET. Every format it reads now has its own filter (Photoshop, Affinity, HEIF, PNG, JPEG, TIFF, Portable Bitmap, OpenEXR, Radiance HDR, camera raw, …), and together they cover All Formats exactly. --- apps/photocraft/src/services.rs | 74 +++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..b65a4b69e1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -36,16 +36,47 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec { extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() } -/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to -/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens. +/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder +/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of +/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native +/// picker defaults to another filter. +const OPEN_FILTERS: &[(&str, &[&str])] = &[ + ("PhotoCraft", &["pcraft"]), + ("Photoshop", &["psd", "psb", "psdt"]), + ("Affinity", &["af", "afdesign", "afphoto", "afpub"]), + ("Krita", &["kra", "krz"]), + ("OpenRaster", &["ora"]), + ("Paint.NET", &["pdn"]), + ("SVG", SVG_EXTS), + ("PNG", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("HEIF", &["heic", "heif", "hif"]), + ("TIFF", &["tif", "tiff"]), + ("WebP", &["webp"]), + ("GIF", &["gif"]), + ("BMP", &["bmp"]), + ("Targa", &["tga"]), + ("ICO", &["ico"]), + ("QOI", &["qoi"]), + ("PCX", &["pcx"]), + ("DDS", &["dds"]), + ("XPM", &["xpm"]), + ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]), + ("OpenEXR", &["exr"]), + ("Radiance HDR", &["hdr"]), + ("FITS", &["fits", "fit", "fts"]), + ("DPX and Cineon", &["dpx", "cin"]), + ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]), + ("Brushes", &["abr"]), + ("Gradients", &["grd"]), + ("Swatches", &["aco", "ase"]), + ("Keyboard Shortcuts", &["kys"]), +]; + fn open_filters() -> Vec<(&'static str, Vec)> { - vec![ - ("All Formats", open_filter_extensions(OPEN_EXTS)), - ("PhotoCraft", open_filter_extensions(&["pcraft"])), - ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra", "krz"])), - ("SVG", open_filter_extensions(SVG_EXTS)), - ] + std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS))) + .chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts)))) + .collect() } /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's @@ -127,10 +158,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, let dialog = if let Some(exts) = extensions { dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::>())) } else { - open_filters() - .into_iter() - .fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) - .add_filter("Paint.NET", &open_filter_extensions(&["pdn"])) + open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) }; if multiple { let picked = dialog.pick_files(); @@ -619,6 +647,26 @@ mod tests { assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz"))); } + /// #6337: after All Formats, every format File › Open reads has its own filter, and no filter + /// offers an extension All Formats doesn't. + #[test] + fn every_open_format_has_its_own_filter() { + let filters = open_filters(); + assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first"); + let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect(); + let total = named.len(); + named.sort_unstable(); + named.dedup(); + assert_eq!(named.len(), total, "an extension is listed under two filters"); + let mut all = OPEN_EXTS.to_vec(); + all.sort_unstable(); + assert_eq!(named, all, "the named filters cover All Formats exactly"); + let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect(); + for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] { + assert!(names.contains(&name), "{name}"); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] #[test] fn open_filters_keep_literal_extensions_on_windows_and_macos() { From 4e131ecf2d37fc75259187b9372af7c062de96f3 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Wed, 14 Oct 2026 12:45:33 +0700 Subject: [PATCH 0269/1452] fix(pen): an unclosed path stays with its document (#5373) Leaving a path open with the Pen and creating a new document showed that path on the new document: `UiState::pen` had no document owner, so it survived the switch. Follow-up to #5015, which did the same for the Polygonal Lasso. The path now remembers the document it was started on (`UiState::pen_doc`, set at its first anchor by `pen_down` and `curvature_down`) and is dropped when another document becomes active, as the Polygonal Lasso and the Crop tool already do. `cancel_stale` also runs at the top of `pen_commit`: the control channel is drained after `sync_views` (`logic` calls them in that order), so a document switch in the same frame would otherwise commit the path into the wrong document. The Crop tool guards its commit path the same way. Adds a regression test that File > New drops the path, that a switch before the commit writes it to neither document, and that it still commits to the document it was drawn on. --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/src/vector_ui.rs | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a981c41f3..152328656f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1191,6 +1191,8 @@ impl PhotocraftApp { // ... and to the document it was started on. crate::canvas::polygon_cancel_stale(self); crate::crop_ui::cancel_stale(self); + // A pen path under construction belongs to the document it was started on too. + vector_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. let now = |i: usize| match self.view_docs.get(i) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..5d690beba1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1150,6 +1150,10 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, + /// The document the pen path is drawn on (set at its first anchor): another document becoming + /// active drops it (`vector_ui::cancel_stale`). + #[serde(default)] + pub pen_doc: Option, #[serde(default)] pub stroke_editor: Option, /// Direct Selection tool: selected anchors and the drag in progress (#790). @@ -1346,6 +1350,7 @@ impl Default for UiState { layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), pen: None, + pen_doc: None, stroke_editor: None, direct_selection: Default::default(), corner_radius: None, diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..e89a9c7faa 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -223,6 +223,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) @@ -423,8 +426,26 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } +/// The path in progress belongs to the document it was started on (`UiState::pen_doc`): once +/// another document becomes active it is dropped, so it doesn't show on (or get committed to) the +/// other document. Switching tools is handled in `PhotocraftApp::logic`, which commits the path +/// to its own document. Like the Polygonal Lasso's ([`crate::canvas::polygon_cancel_stale`]) and +/// the Crop tool's ([`crate::crop_ui::cancel_stale`]). +pub fn cancel_stale(app: &mut PhotocraftApp) { + let active = app.session.active().map(|st| st.doc.id); + if app.ui.pen.is_none() { + app.ui.pen_doc = None; + } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { + app.ui.pen = None; + app.ui.pen_doc = None; + } +} + /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { + // The control channel is drained after `sync_views`, so a document switch in this frame has + // not been seen yet: never commit the path into the document it was not drawn on. + cancel_stale(app); let Some(mut pen) = app.ui.pen.take() else { return }; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. @@ -600,6 +621,9 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen_commit(app, true); return; } + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if hit.is_none() { pen.knots.push([[x, y]; 3]); @@ -2236,6 +2260,39 @@ mod tests { let k = app.ui.pen.as_ref().unwrap().knots[2]; assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); pen_up(&mut app); + + /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank + /// document that showed the first document's path; a document switch between `sync_views` and + /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. + #[test] + fn an_unclosed_path_stays_with_its_document() { + let open_path = |app: &mut PhotocraftApp| { + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { + pen_down(app, x, y, false); + pen_up(app); + } + }; + let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); + // File › New drops the first document's path instead of carrying it over. + let mut app = app(); + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "the path is claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "the first document's path is gone"); + assert_eq!(work(&app, 1), None, "the new document is blank"); + // The control channel is drained after `sync_views`, so a switch in the same frame reaches + // the commit unseen: it must not write the path into the other document. + open_path(&mut app); + assert!(app.session.set_active(0)); + pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert_eq!((work(&app, 0), work(&app, 1)), (None, None), "neither document took it"); + // Staying on its own document, the path still commits there. + open_path(&mut app); + pen_commit(&mut app, false); + assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); + assert_eq!(work(&app, 1), None); } /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new From 590787b84825b5bae41c69fa3212ca9249c8def6 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Wed, 14 Oct 2026 15:28:29 +0700 Subject: [PATCH 0270/1452] fix(pen): drop the Direct Selection with the stale path `pen_commit` resets `ui.direct_selection` when it was targeting the Pen's path; `cancel_stale` dropped the path without it, leaving the target pointing at anchors that no longer exist. Spotted by @Daktyl198 on #5412. The test now drives the Curvature Pen as well as the Pen through File > New, and covers the Direct Selection reset. --- crates/ui-egui/src/vector_ui.rs | 36 ++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e89a9c7faa..0f190fb036 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -438,6 +438,10 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { app.ui.pen = None; app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + // Its anchors went with it, as on commit. + app.ui.direct_selection = Default::default(); + } } } @@ -2266,21 +2270,33 @@ mod tests { /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. #[test] fn an_unclosed_path_stays_with_its_document() { + // Both pens claim the document at their first anchor. let open_path = |app: &mut PhotocraftApp| { - app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { - pen_down(app, x, y, false); - pen_up(app); + if app.ui.tool == Tool::CurvaturePen { + curvature_down(app, x, y); + curvature_up(app); + } else { + pen_down(app, x, y, false); + pen_up(app); + } } }; let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); // File › New drops the first document's path instead of carrying it over. + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); + } + // Two documents; the path below is drawn on the second (active) one. let mut app = app(); - open_path(&mut app); - assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "the path is claimed by its document"); + app.ui.tool = Tool::Pen; app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); - assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "the first document's path is gone"); - assert_eq!(work(&app, 1), None, "the new document is blank"); // The control channel is drained after `sync_views`, so a switch in the same frame reaches // the commit unseen: it must not write the path into the other document. open_path(&mut app); @@ -2293,6 +2309,12 @@ mod tests { pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); assert_eq!(work(&app, 1), None); + // A Direct Selection on the path goes with it, as on commit: no target left dangling. + open_path(&mut app); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); } /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new From a528b9993e3d8e1ffc4908b06c0491f51f4390a4 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Thu, 15 Oct 2026 08:26:23 +0700 Subject: [PATCH 0271/1452] fix(pen): guard every entry point against a stale path Review on #5412: `sync_views` and `pen_commit` left three ways for a control batch to reach another document's path in the same frame, before the next sync. `cancel_stale` now also runs at the top of `pen_down`, `curvature_down` and `pen_undo_last_point`, and `draw_overlay` draws the path only on the document it was started on, as #5427 does. The regression test covers all three entry points after a document switch and the overlay, comparing the same view with and without the path so the count measures the path alone. Co-authored-by: Daktyl198 <2475672+Daktyl198@users.noreply.github.com> Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> --- crates/ui-egui/src/vector_ui.rs | 59 ++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 0f190fb036..80f45e5d4d 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -222,6 +222,7 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + cancel_stale(app); let tol = 6.0 / app.point_zoom().max(0.01) as f64; if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); @@ -288,6 +289,7 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { + cancel_stale(app); let Some(pen) = app.ui.pen.as_mut() else { return false }; if pen.knots.pop().is_none() { return false; @@ -617,6 +619,7 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { + cancel_stale(app); if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -1049,7 +1052,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) { + // Only on the document it is drawn on: a view of another document never shows it (#5373). + let active = app.session.active().map(|st| st.doc.id); + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| Some(d) == active)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -2264,6 +2269,7 @@ mod tests { let k = app.ui.pen.as_ref().unwrap().knots[2]; assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); pen_up(&mut app); + } /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank /// document that showed the first document's path; a document switch between `sync_views` and @@ -2309,6 +2315,57 @@ mod tests { pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); assert_eq!(work(&app, 1), None); + // A control batch can switch documents and act on the path in the same frame, before the + // next `sync_views`: no entry point extends, undoes or keeps a stale path (#5427). + for act in [0, 1, 2] { + open_path(&mut app); + assert!(app.session.set_active(1)); + match act { + 0 => pen_down(&mut app, 50.0, 50.0, false), + 1 => curvature_down(&mut app, 50.0, 50.0), + _ => assert!(!pen_undo_last_point(&mut app), "nothing of this document's to undo"), + } + let knots = app.ui.pen.as_ref().map_or(0, |p| p.knots.len()); + assert!(knots <= 1, "act {act}: a new path, not the other document's (knots {knots})"); + // The two drawing entry points start a path of their own here; undo has none to take. + let expected = if act == 2 { None } else { app.session.active().map(|st| st.doc.id) }; + assert_eq!(app.ui.pen_doc, expected, "act {act}"); + app.ui.pen = None; + app.ui.pen_doc = None; + assert!(app.session.set_active(0)); + } + // The overlay draws the path only on its own document, so a view of the other one in the + // same frame shows nothing (#5427). + open_path(&mut app); + let shapes = |app: &PhotocraftApp| { + let ctx = egui::Context::default(); + let doc = app.session.active().unwrap().doc.clone(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 75.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes.len() + }; + // Same view, pen present vs absent: the difference is the path itself, nothing else. + let drawn = |app: &mut PhotocraftApp| { + let with_pen = shapes(app); + let pen = app.ui.pen.take(); + let without = shapes(app); + app.ui.pen = pen; + with_pen - without + }; + assert!(drawn(&mut app) > 0, "its own document draws the path"); + assert!(app.session.set_active(1)); + assert_eq!(drawn(&mut app), 0, "the other document draws nothing of it"); + assert!(app.session.set_active(0)); + app.ui.pen = None; + app.ui.pen_doc = None; // A Direct Selection on the path goes with it, as on commit: no target left dangling. open_path(&mut app); app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); From 8176b613ca2a95cb8afc6a535bd4ae8ff532c64f Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:15:50 +0200 Subject: [PATCH 0272/1452] Smart filters: the dialog's preview window shows the edited filter too (#6324) The filter dialog's own preview window matched the canvas preview by the filter's command, but an edited smart filter previews as layer.smartFilter.setParams, so the window kept the unfiltered image. smart_ui::preview_command names the command the preview runs. --- crates/ui-egui/src/filter_preview_ui.rs | 4 +++- crates/ui-egui/src/smart_ui.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..66499ab9f0 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -43,7 +43,9 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu // decides whether the filtered result (rather than the original) is shown. let preview_on = f.get("__preview").and_then(Value::as_bool) == Some(true); let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { - (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == cmd).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() + // An edited smart filter previews as `layer.smartFilter.setParams` (#6324). + let previewed = crate::smart_ui::preview_command(f, &cmd); + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 722e8fe910..2acc77ceaf 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -331,6 +331,12 @@ fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, param } } +/// The command a filter dialog's live preview runs ([`confirm_command`]'s): `layer.smartFilter.setParams` +/// while it edits a smart filter (#6324), else `command`. +pub fn preview_command<'a>(fields: &serde_json::Map, command: &'a str) -> &'a str { + if fields.contains_key("__smartFilter") { "layer.smartFilter.setParams" } else { command } +} + /// The command a confirmed filter dialog runs: smart-filter edits update the recorded filter. pub fn confirm_command(fields: &serde_json::Map, command: String, params: Value) -> (String, Value) { match fields.get("__smartFilter") { @@ -485,6 +491,10 @@ mod tests { crate::canvas::ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); let preview = app.filter_preview.as_ref().unwrap(); assert_eq!(preview.key.command, "layer.smartFilter.setParams"); + // The dialog's own preview window matches it to the dialog by that command. + let fields = &app.ui.dialogs.last().unwrap().fields; + assert_eq!(preview_command(fields, "filter.blur.gaussianBlur"), preview.key.command); + assert_eq!(preview_command(&serde_json::Map::new(), "filter.blur.gaussianBlur"), "filter.blur.gaussianBlur"); let shown = preview.result.clone().expect("the preview's document"); let filters = smart(&app, &shown).smart_filters; assert_eq!(filters.len(), 1, "the edited filter is replaced, not stacked"); From c04da11bb3986a8da42642dbbcf272473e0bdfbe Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:16:06 +0200 Subject: [PATCH 0273/1452] Smart filters: rustfmt --- crates/ui-egui/src/filter_preview_ui.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 66499ab9f0..859a829568 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -45,7 +45,9 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { // An edited smart filter previews as `layer.smartFilter.setParams` (#6324). let previewed = crate::smart_ui::preview_command(f, &cmd); - (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed) + .then(|| p.result.clone().map(|r| (p.key.clone(), r))) + .flatten() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { From 89d158c98ac465dca0dddaaf6584447dcba737e1 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Fri, 16 Oct 2026 05:23:26 +0700 Subject: [PATCH 0274/1452] fix(pen): preserve stale drafts in their owning Work Path --- crates/ui-egui/src/direct_select.rs | 9 +- crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/state.rs | 3 +- crates/ui-egui/src/vector_ui.rs | 266 ++++++++++++++++++++++++---- 4 files changed, 251 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index b27d9ea466..71fccc3870 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -174,7 +174,14 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { /// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then /// every visible shape layer. pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { - let pen = app.ui.pen.as_ref().filter(|p| !p.dragging && !p.knots.is_empty()).map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); + let active = app.session.active().map(|st| st.doc.id); + let pen = app + .ui + .pen + .as_ref() + .filter(|_| app.ui.pen_doc.is_none_or(|owner| Some(owner) == active)) + .filter(|p| !p.dragging && !p.knots.is_empty()) + .map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); let mut out: Vec<_> = pen.into_iter().chain(active_paths(app)).collect(); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 152328656f..fd922edeb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1035,6 +1035,9 @@ impl PhotocraftApp { } fn run_command(&mut self, id: &str, params: Value) -> Result { + if !vector_ui::cancel_stale(self) { + return Err(self.ui.status.clone()); + } if matches!(id, "paint.setSymmetry" | "paint.symmetryFromPath" | "paint.symmetryDisable") { self.ui.symmetry_transform = None; } @@ -1086,10 +1089,6 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; - // A draft Pen path belongs to the current document, not the one about to be created. - if id == "file.new" && self.ui.pen.is_some() { - vector_ui::pen_commit(self, false); - } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1147,18 +1146,18 @@ impl PhotocraftApp { self.perf.command_ms = gpu_canvas::now_ms() - t0; match &r { Ok(_) => { + if self.ui.status_error { + self.ui.status.clear(); + self.ui.status_error = false; + } self.sync_views(); let removed = self.session.take_samplers_removed(); - if removed > 0 { + if removed > 0 && !self.ui.status_error { // A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color // Sampler points that fell outside the new canvas (#1046). self.ui.status = crate::i18n::trn(crate::i18n::current(), removed as u64, "{n} sample point removed", "{n} sample points removed"); self.ui.status_error = false; } - if self.ui.status_error { - self.ui.status.clear(); - self.ui.status_error = false; - } } Err(e) => { self.ui.status = e.clone(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5d690beba1..347078b0cf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1150,8 +1150,7 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, - /// The document the pen path is drawn on (set at its first anchor): another document becoming - /// active drops it (`vector_ui::cancel_stale`). + /// The document the pen path is drawn on; switching away commits it to that Work Path. #[serde(default)] pub pen_doc: Option, #[serde(default)] diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 80f45e5d4d..f6b65260a6 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -222,7 +222,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { - cancel_stale(app); + if !cancel_stale(app) { + return; + } let tol = 6.0 / app.point_zoom().max(0.01) as f64; if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); @@ -289,7 +291,9 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { - cancel_stale(app); + if !cancel_stale(app) { + return false; + } let Some(pen) = app.ui.pen.as_mut() else { return false }; if pen.knots.pop().is_none() { return false; @@ -428,31 +432,50 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } -/// The path in progress belongs to the document it was started on (`UiState::pen_doc`): once -/// another document becomes active it is dropped, so it doesn't show on (or get committed to) the -/// other document. Switching tools is handled in `PhotocraftApp::logic`, which commits the path -/// to its own document. Like the Polygonal Lasso's ([`crate::canvas::polygon_cancel_stale`]) and -/// the Crop tool's ([`crate::crop_ui::cancel_stale`]). -pub fn cancel_stale(app: &mut PhotocraftApp) { +/// Preserve a foreign document's draft as its Work Path before another document can edit it. +/// A failed commit retains the draft and blocks the caller until it can be retried. +pub fn cancel_stale(app: &mut PhotocraftApp) -> bool { let active = app.session.active().map(|st| st.doc.id); if app.ui.pen.is_none() { app.ui.pen_doc = None; - } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { - app.ui.pen = None; - app.ui.pen_doc = None; - if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { - // Its anchors went with it, as on commit. - app.ui.direct_selection = Default::default(); + return true; + } + let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true }; + if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) { + let Some(mut pen) = app.ui.pen.clone() else { return true }; + if pen.curvature { + curvature_refresh(&mut pen, false); + } + let active = app.session.active_index(); + app.session.set_active(index); + // Bypass shell targets and sync_views: both belong to the newly active document. + let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})); + if let Some(index) = active { + app.session.set_active(index); + } + if let Err(e) = result { + app.ui.status = e.to_string(); + app.ui.status_error = true; + return false; } } + app.ui.pen = None; + app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + true } /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { // The control channel is drained after `sync_views`, so a document switch in this frame has // not been seen yet: never commit the path into the document it was not drawn on. - cancel_stale(app); + if !cancel_stale(app) { + return; + } let Some(mut pen) = app.ui.pen.take() else { return }; + app.ui.pen_doc = None; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. app.ui.direct_selection = Default::default(); @@ -619,7 +642,9 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { - cancel_stale(app); + if !cancel_stale(app) { + return; + } if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -1025,7 +1050,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays. // Free Transform Path draws the path through its box instead (transform_tool). let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none()); - if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { + if app.session.active().is_some_and(|st| st.doc.id == doc.id) && crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { // Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790). crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths); if paths { @@ -1053,8 +1078,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); // Only on the document it is drawn on: a view of another document never shows it (#5373). - let active = app.session.active().map(|st| st.doc.id); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| Some(d) == active)) { + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| d == doc.id)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -2271,9 +2295,7 @@ mod tests { pen_up(&mut app); } - /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank - /// document that showed the first document's path; a document switch between `sync_views` and - /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. + /// #5373: an unfinished path stays with its own document through same-frame switches. #[test] fn an_unclosed_path_stays_with_its_document() { // Both pens claim the document at their first anchor. @@ -2289,7 +2311,7 @@ mod tests { } }; let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); - // File › New drops the first document's path instead of carrying it over. + // File › New commits the path to its original document. for tool in [Tool::Pen, Tool::CurvaturePen] { let mut app = app(); app.ui.tool = tool; @@ -2297,26 +2319,24 @@ mod tests { assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert!(work(&app, 0).is_some(), "{tool:?}: the original keeps its Work Path"); assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); } // Two documents; the path below is drawn on the second (active) one. let mut app = app(); app.ui.tool = Tool::Pen; app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); - // The control channel is drained after `sync_views`, so a switch in the same frame reaches - // the commit unseen: it must not write the path into the other document. open_path(&mut app); assert!(app.session.set_active(0)); pen_commit(&mut app, false); assert!(app.ui.pen.is_none()); - assert_eq!((work(&app, 0), work(&app, 1)), (None, None), "neither document took it"); + assert!(work(&app, 0).is_none(), "the active document stays untouched"); + assert!(work(&app, 1).is_some(), "the owner keeps its Work Path"); // Staying on its own document, the path still commits there. open_path(&mut app); pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); - assert_eq!(work(&app, 1), None); - // A control batch can switch documents and act on the path in the same frame, before the - // next `sync_views`: no entry point extends, undoes or keeps a stale path (#5427). + assert!(work(&app, 1).is_some()); for act in [0, 1, 2] { open_path(&mut app); assert!(app.session.set_active(1)); @@ -2334,8 +2354,6 @@ mod tests { app.ui.pen_doc = None; assert!(app.session.set_active(0)); } - // The overlay draws the path only on its own document, so a view of the other one in the - // same frame shows nothing (#5427). open_path(&mut app); let shapes = |app: &PhotocraftApp| { let ctx = egui::Context::default(); @@ -2366,7 +2384,6 @@ mod tests { assert!(app.session.set_active(0)); app.ui.pen = None; app.ui.pen_doc = None; - // A Direct Selection on the path goes with it, as on commit: no target left dangling. open_path(&mut app); app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); app.ui.direct_selection.anchors = vec![[0, 1]]; @@ -2374,6 +2391,191 @@ mod tests { assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); } + #[test] + fn pen_overlay_uses_the_view_document_even_during_direct_selection() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let owner = app.session.active().unwrap().doc.clone(); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let other = app.session.active().unwrap().doc.clone(); + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let shapes = |app: &PhotocraftApp, doc: &Document, modifiers| { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }, |ui| { + draw_overlay(app, ui.painter(), &xf, doc) + }); + output.textures_delta.clear(); + output.shapes.len() + }; + for active in [0, 1] { + assert!(app.session.set_active(active)); + for tool in [Tool::Pen, Tool::DirectSelection] { + app.ui.tool = tool; + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::COMMAND] { + for (doc, visible) in [(&owner, true), (&other, false)] { + let with_pen = shapes(&app, doc, modifiers); + let pen = app.ui.pen.take(); + let without = shapes(&app, doc, modifiers); + app.ui.pen = pen; + assert_eq!(with_pen > without, visible, "active {active}, {tool:?}, {modifiers:?}"); + } + } + } + } + } + + #[test] + fn failed_stale_pen_commit_preserves_the_draft_until_retry() { + for action in ["pen", "curvature", "undo", "commit", "command"] { + let mut app = app(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[5, 5], [10, 10]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone().unwrap(); + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (80.0, 60.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + let draft = app.ui.pen.clone(); + let owner = app.ui.pen_doc; + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.selected_path = Some("foreign named path".into()); + app.session.authorize = Some(|_, _| Err(photocraft_engine::EngineError::Other("test rejection".into()))); + match action { + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => { + assert!(app.run("file.close", json!({})).is_err()); + } + } + assert_eq!(app.ui.pen, draft, "{action}: failed save retains all anchors"); + assert_eq!(app.ui.pen_doc, owner); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original)); + assert!(app.session.documents()[1].doc.work_path.is_none()); + assert!(app.ui.status_error); + assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen)); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.ui.selected_path.as_deref(), Some("foreign named path")); + let saved = app.session.documents()[0].doc.work_path.clone().unwrap(); + assert_eq!(saved.subpaths.len(), original.subpaths.len() + 1); + assert_eq!(saved.subpaths[0], original.subpaths[0]); + app.sync_views(); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&saved), "retry never duplicates the path"); + assert!(app.session.set_active(0)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.work_path.as_ref(), Some(&original)); + } + } + + #[test] + fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { + let mut app = app(); + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let draft = app.ui.pen.clone(); + app.session.authorize = Some(|id, _| if id == "path.set" { Err(photocraft_engine::EngineError::Other("test rejection".into())) } else { Ok(()) }); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("test rejection")); + assert_eq!(app.ui.pen, draft); + assert_eq!(app.session.active_index(), Some(1)); + assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert!(app.session.documents()[0].doc.work_path.is_some()); + assert!(app.session.documents()[1].doc.work_path.is_none()); + } + + #[test] + fn new_document_preserves_a_single_pen_anchor_as_a_work_path() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + if tool == Tool::Pen { + pen_down(&mut app, 20.0, 30.0, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, 20.0, 30.0); + curvature_up(&mut app); + } + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.pen.is_none()); + let path = app.session.documents()[0].doc.work_path.as_ref().unwrap(); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 30.0)); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + } + } + + #[test] + fn stale_pen_commits_to_its_owner_across_document_changes() { + use photocraft_doc::{ColorMode, SampleType, Size}; + for tool in [Tool::Pen, Tool::CurvaturePen] { + for action in ["switch", "open", "closeOther", "pen", "curvature", "undo", "commit"] { + let mut app = app(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + for (x, y) in [(20.0, 20.0), (80.0, 60.0), (120.0, 30.0)] { + if tool == Tool::Pen { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, x, y); + curvature_up(&mut app); + } + } + let owner = app.ui.pen_doc.unwrap(); + let expected = photocraft_engine::vector_cmds::parse_path(&pen_to_json(app.ui.pen.as_ref().unwrap(), false)).unwrap(); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + assert!(app.session.set_active(0)); + match action { + "open" => { + app.services.import = + Some(Box::new(|name, _, _| Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))); + app.open_file("synthetic.png", &[]).unwrap(); + } + "closeOther" => { + app.run("file.close", json!({"document": 0})).unwrap(); + } + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => app.sync_views(), + } + let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap(); + let path = source.doc.work_path.as_ref().expect("the owner keeps the unfinished path"); + assert_eq!(path, &expected, "{tool:?} {action}"); + assert!(app.session.documents().iter().filter(|d| d.doc.id != owner).all(|d| d.doc.work_path.is_none())); + if action != "closeOther" { + assert_ne!(app.session.active().unwrap().doc.id, owner, "the active document is restored"); + } + assert_eq!(app.ui.direct_selection, Default::default()); + let index = app.session.documents().iter().position(|d| d.doc.id == owner).unwrap(); + assert!(app.session.set_active(index)); + assert!(app.session.undo()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert!(app.session.redo()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + } + } + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. From 5f9c7d307ef6abd6ec4336d8564328246da3cc44 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 18:25:28 -0400 Subject: [PATCH 0275/1452] New Document: Color Profile, the working space or another profile of the mode (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's New Document has Advanced Options › Color Profile; PhotoCraft's always opened in the working space, so a document in another space had to be made and then assigned a profile. `file.new` takes `profile` (working, a built-in id or an .icc path), checked against the colour mode before the document opens, and the New Document dialog lists the working space (by its profile) and the built-in and installed profiles of the mode's colour space. Changing the mode drops a profile of the old space. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 14 +++- crates/engine/src/document_preset_cmds.rs | 1 + crates/engine/src/tests.rs | 25 +++++++ crates/ui-egui/src/new_doc_ui.rs | 82 +++++++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..df882a5ee5 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -467,7 +467,7 @@ fn build() -> Vec { "New…", ["File"], Some("Cmd+N"), - r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str}"##, + r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##, always, |s, p| { use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type}; @@ -503,6 +503,18 @@ fn build() -> Vec { } }; doc.resolution_dpi = res; + // Photoshop's New Document › Color Profile: tag the document with another profile + // than the working space (#321). Checked before the document opens. + if let Some(spec) = p.get("profile").and_then(Value::as_str).filter(|spec| !matches!(*spec, "working" | "none")) { + let prof = s.color.resolve(spec, Some(&doc), None)?; + if prof.color_space != crate::color_cmds::mode_space(doc.mode) { + return Err(EngineError::BadParams { + cmd: "file.new".into(), + msg: format!("profile `{}` is {:?}; the document is {:?}", prof.description, prof.color_space, doc.mode), + }); + } + doc.icc_profile = Some(prof.to_bytes()); + } let i = s.add_document(doc, None); Ok(json!({ "document": i })) } diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index 0bb409b818..f0847e4b8b 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> { continue; } "name" => (value.is_string(), "a string".into()), + "profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()), _ => return Err(bad(cmd, format!("unknown parameter `{key}`"))), }; if !valid { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..9c105158c9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -158,6 +158,31 @@ fn file_new_keeps_defaults_and_supported_modes_and_depths() { } } +#[test] +fn file_new_tags_the_document_with_the_chosen_profile() { + // #321: New Document › Color Profile, as in Photoshop: another profile than the working space. + let desc = |s: &Session| { + let bytes = s.active().unwrap().doc.icc_profile.clone(); + bytes.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description) + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "profile": "adobe-rgb-compat"})).unwrap(); + assert_eq!(desc(&s).as_deref(), Some(photocraft_cms::Builtin::AdobeRgbCompat.description())); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk", "profile": "coated-cmyk"})).unwrap(); + assert!(desc(&s).is_some()); + // The working space (the default, or asked for) leaves it as before. + for p in [json!({"width": 4, "height": 4}), json!({"width": 4, "height": 4, "profile": "working"})] { + s.execute("file.new", p).unwrap(); + assert_eq!(desc(&s), None); + } + // A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing. + let open = s.documents().len(); + for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] { + assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), open); +} + #[test] fn file_new_variants() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..fd43bc1535 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -231,6 +231,33 @@ pub fn apply_preset(f: &mut Map, p: &Preset, units: &UnitsAndRule f.insert("__unit".into(), json!(unit)); } +/// The colour mode of the dialog's `mode` value. +fn color_mode_of(mode: &str) -> Option { + use photocraft_color::ColorMode; + match mode { + "rgb" => Some(ColorMode::Rgb), + "gray" | "grayscale" => Some(ColorMode::Grayscale), + "cmyk" => Some(ColorMode::Cmyk), + "lab" => Some(ColorMode::Lab), + _ => None, + } +} + +/// The Color Profile menu for a document of colour `space` (#321): the working space (shown with +/// its profile's description), then the built-in and installed profiles of that space, as +/// `file.new`'s `profile` values (an id or a path) with their labels. +fn profile_options( + space: Option, + working: &str, + installed: &[photocraft_engine::installed_profiles::InstalledProfile], +) -> Vec<(String, String)> { + let mut v = vec![("working".to_string(), crate::i18n::fmt(tl!("Working: {working}"), &[("working", working)]))]; + let Some(space) = space else { return v }; + v.extend(photocraft_cms::Builtin::ALL.iter().filter(|b| b.profile().color_space == space).map(|b| (b.id().to_string(), b.description().to_string()))); + v.extend(installed.iter().filter(|i| i.is_destination() && i.color_space == space).map(|i| (i.path.clone(), i.description.clone()))); + v +} + /// Fields `file.new` takes (drops the dialog's `__` UI keys). pub fn command_params(f: &Map) -> Value { Value::Object(f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect()) @@ -695,6 +722,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); + if widgets::dropdown(ui, "nd-profile", &mut profile, &shown, 240.0) { + if profile == "working" { + f.remove("profile"); + } else { + f.insert("profile".into(), json!(profile)); + } + f.remove("__savedPreset"); + } + } + ui.add_space(4.0); small_label(ui, tl!("Background Contents")); // A custom colour is kept as its "#rrggbb", which `file.new` takes as is (#4789). let current = get_s(f, "background", "white"); @@ -768,6 +821,35 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(); + let rgb = values("rgb"); + assert_eq!(rgb[0], "working"); + assert!(rgb.contains(&Builtin::AdobeRgbCompat.id().to_string()) && rgb.contains(&"panel.icc".to_string())); + assert!(!rgb.contains(&Builtin::CoatedCmyk.id().to_string()) && !rgb.contains(&"press.icc".to_string())); + let cmyk = values("cmyk"); + assert!(cmyk.contains(&"press.icc".to_string()) && !cmyk.contains(&Builtin::Srgb.id().to_string())); + assert!(profile_options(None, "sRGB", &installed).len() == 1, "an unknown mode offers only the working space"); + // Every entry is one `file.new` takes for that mode. + for mode in ["rgb", "gray", "cmyk", "lab"] { + for v in values(mode).into_iter().filter(|v| !v.ends_with(".icc")) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2, "mode": mode, "profile": v})).unwrap_or_else(|e| panic!("{mode} {v}: {e}")); + } + } + } + #[test] fn units_round_trip_through_pixels() { assert_eq!(to_unit(2100.0, "in", 300.0), 7.0); From 944d0cf722e504594bc0433454be6d73a304e675 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 18:27:56 -0400 Subject: [PATCH 0276/1452] Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056) The Path Selection and Direct Selection tools had no canvas menu, so Make Selection, Fill and Stroke Path, Free Transform Path, Delete Path and the combine and style rows needed the Pen tool or the Paths panel. Like Photoshop, their right-click now opens the path menu for the selected path, with the Pen menu's rows and the same enablement. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas_tool_menu.rs | 44 ++++++++++++++++++++++---- docs/context-menu-parity.md | 4 +-- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index da835a786f..a02859c5cd 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -284,8 +284,14 @@ pub fn applies(tool: Tool) -> bool { | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection - | Tool::Pen - ) + ) || path_menu(tool) +} + +/// Tools whose canvas right-click opens the path menu ([`PEN_MENU`]): the Pen, and the Path +/// Selection and Direct Selection tools, whose Photoshop menu offers the same whole-path actions +/// for the selected path (#1056). +pub fn path_menu(tool: Tool) -> bool { + matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection) } /// Tools whose plain canvas right-click offers the view menu. @@ -310,7 +316,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU - } else if menu.tool == Tool::Pen { + } else if path_menu(menu.tool) { PEN_MENU } else { selection_rows(menu.has_selection) @@ -471,7 +477,7 @@ pub fn pasteboard_at(app: &PhotocraftApp, tool: Tool, doc: [f64; 2]) -> bool { let Some(st) = app.session.active() else { return false }; let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height)); let outside = doc.iter().all(|v| v.is_finite()) && (doc[0] < 0.0 || doc[1] < 0.0 || doc[0] >= w || doc[1] >= h); - outside && tool != Tool::Pen && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() + outside && !path_menu(tool) && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() } /// Right-click on the pasteboard: its colour menu at screen point `pos`. @@ -572,7 +578,7 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } - if menu.transform || menu.tool != Tool::Pen { + if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } let Some(st) = app.session.active() else { return false }; @@ -699,7 +705,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_spelling(app, &menu, command) } else if menu.text { choose_type(app, ctx, command) - } else if menu.tool == Tool::Pen && !menu.transform { + } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. @@ -1492,6 +1498,32 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + /// #1056: the Path Selection and Direct Selection tools' right-click opens the path menu for + /// the selected path, as the Pen's does, instead of nothing. + #[test] + fn path_selection_tools_open_the_path_menu() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app(); + app.ui.tool = Tool::Pen; + for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + crate::vector_ui::pen_commit(&mut app, false); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + app.ui.tool = tool; + assert!(applies(tool)); + assert!(open(&mut app, &Context::default(), tool, [10.0, 10.0]), "{tool:?}"); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert_eq!(rows(&menu), PEN_MENU, "{tool:?}"); + assert!(menu.has_path && entry_enabled(&app, &menu, "path.toSelection"), "{tool:?}"); + choose(&mut app, &Context::default(), "path.toSelection"); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "{tool:?}: Make Selection made one"); + } + } + #[test] fn pen_menu_has_all_reference_rows_in_order_and_guards_unavailable_actions() { let labels: Vec<_> = PEN_MENU.iter().map(|row| row.map(|(label, _)| label)).collect(); diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 76d6724b7e..1e8abf9a67 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (Path Selection canvas menu, #1056) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -29,7 +29,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | | Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | -| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | +| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | | Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 | | Any tool while Free Transform is active | Scale, Rotate, Skew, Distort, Perspective, Warp, rotate/flip variants, commit/cancel **(inferred exact context placement)** | Missing per docs/scorecard.md TOOL-214-5 | P0; transform operations [documented by Adobe](https://helpx.adobe.com/photoshop/desktop/crop-resize-transform/transform-manipulate-reshape/adjust-scale-rotation-and-perspective.html); type restrictions [documented here](https://helpx.adobe.com/photoshop/using/creating-type.html) | From 6755ab7f22410def3838827ca4e04a17638005d5 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:32:07 +0800 Subject: [PATCH 0277/1452] test: reproduce static brush cursor under pen rotation --- crates/ui-egui/src/tool_cursor/tests.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index d68be241f5..bed77ebaf4 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -380,6 +380,30 @@ fn harness() -> Harness<'static, crate::PhotocraftApp> { h } +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn brush_cursor_follows_pen_orientation_6350() { + for control in ["penTilt", "rotation"] { + let mut h = harness(); + h.state_mut() + .run( + "tools.setBrush", + json!({"brush": { + "size": 60, "hardness": 1.0, "roundness": 0.25, + "shapeDynamics": {"enabled": true, "angle": {"control": control}} + }}), + ) + .unwrap(); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 45.0, ..Default::default() })); + h.run_steps(3); + let initial = h.output().platform_output.cursor_image.clone().expect("initial brush cursor"); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_y: -45.0, rotation: 90.0, ..Default::default() })); + h.run_steps(3); + let turned = h.output().platform_output.cursor_image.clone().expect("turned brush cursor"); + assert_ne!(initial.rgba, turned.rgba, "cursor must follow pen orientation: {control}"); + } +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes() { From addac100bf54306981167826ed2f1a049c7ff12a Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:33:09 +0800 Subject: [PATCH 0278/1452] test: compare pen cursor with dab angle and static reference --- crates/ui-egui/src/tool_cursor/tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index bed77ebaf4..6caf4fe747 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -400,7 +400,23 @@ fn brush_cursor_follows_pen_orientation_6350() { h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_y: -45.0, rotation: 90.0, ..Default::default() })); h.run_steps(3); let turned = h.output().platform_output.cursor_image.clone().expect("turned brush cursor"); + let pen = h.state().stylus.sample().expect("simulated pen reaches the canvas"); + assert_eq!((pen.tilt_y, pen.rotation), (-45.0, 90.0)); + let point = photocraft_paint::StrokePoint { tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() }; + let step = photocraft_paint::dynamics::StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 }; + let mut dabs = Vec::new(); + photocraft_paint::dynamics::DabBuilder::new(&h.state().session.tools.brush).build(&step, &mut dabs); + assert_eq!(dabs.len(), 1); + assert!((dabs[0].angle.to_degrees() - 90.0).abs() < 0.001, "painted dab turns: {control}"); assert_ne!(initial.rgba, turned.rgba, "cursor must follow pen orientation: {control}"); + // The dynamically rotated cursor must match the same tip set to that angle directly. + h.state_mut().session.tools.brush.shape_dynamics.enabled = false; + h.state_mut().session.tools.brush.angle = 90.0; + h.run_steps(3); + let reference = h.output().platform_output.cursor_image.clone().expect("static rotated reference"); + assert_eq!(turned.size, reference.size); + assert_eq!(turned.hotspot, reference.hotspot); + assert_eq!(turned.rgba, reference.rgba, "dynamic cursor matches the dab angle: {control}"); } } From a76d3295ba2b2d22d94f87fe2a2d3a5205cdc041 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:38:48 +0200 Subject: [PATCH 0279/1452] Type: the text caret shows on a white background (part of #6085) In the Photoshop themes the caret was a 1.5 pt white line with a 0.75 pt black hairline inside, so on a white background only a faint grey line showed. It is now a solid 1 pt black line inside a 3 pt white halo, readable on any background, in every theme. --- crates/ui-egui/src/type_tool.rs | 11 +++++-- crates/ui-egui/src/type_tool_tests.rs | 43 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..d85964ed62 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1088,13 +1088,18 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if ed.dragging || (time * 1000.0 / 530.0) as i64 % 2 == 0 { let (x, top, bot) = l.caret(a); let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) }; - let c = if crate::theme::Tokens::get(painter.ctx()).pro { Color32::WHITE } else { Color32::BLACK }; - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(1.5, c)); - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(0.75, Color32::BLACK)); + for stroke in CARET_STROKES { + painter.line_segment([scr(x, top), scr(x, bot)], stroke); + } } } } +/// The text caret, drawn in order: a white halo, then a solid black line inside it, so it reads on +/// any background (#6085). The black line used to be a 0.75 pt hairline inside a white one, which +/// on a white background left only a faint grey line. +const CARET_STROKES: [Stroke; 2] = [Stroke { width: 3.0, color: Color32::WHITE }, Stroke { width: 1.0, color: Color32::BLACK }]; + /// The Type Mask tools while typing: Quick Mask's colour over the canvas with the glyphs clear, /// as Photoshop shows the selection-to-be. The glyphs come from the hidden working layer's pixels. fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, id: LayerId) { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c7b54c8871..d27fdbcdac 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2065,3 +2065,46 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } + +/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white +/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. +#[test] +fn the_caret_reads_on_white_and_on_black() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = new_app(); + app.ui.tool = crate::state::Tool::Type; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). + let ed = app.ui.text_edit.as_mut().expect("editing"); + ed.anchor = ed.caret; + ed.dragging = true; + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 100.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut h = + Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + // Vertical line segments are the caret (the frame and underline run sideways). + let caret: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), + _ => None, + }) + .collect(); + assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); + let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); + assert_eq!(line, egui::Color32::BLACK); + assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); + assert_eq!(halo, egui::Color32::WHITE); + assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); +} From c1c44507d1bf7b36c2a74e3597348a7e7c39197c Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 18:40:18 -0400 Subject: [PATCH 0280/1452] Automation: file.new's profile path is an ambient read, gated like Assign Profile's (#321) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 23b64ae30e..6919041b82 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -323,6 +323,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } "image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"], "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"], + // New Document's Color Profile (#321) can name an .icc file. + "file.new" => &["profile"], "edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"], _ => &[], }; @@ -712,6 +714,8 @@ mod tests { assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok()); assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err()); assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err()); assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err()); From eb3191e9bf0232b1c5f101909cd4e204100ab4f8 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:46:00 +0800 Subject: [PATCH 0281/1452] fix: apply pen orientation to cached brush cursor outlines --- crates/ui-egui/src/brush_cursor.rs | 90 ++++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/tool_cursor/tests.rs | 18 +++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 49c444c154..fac5989c2a 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -50,6 +50,47 @@ pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] { [xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))] } +/// Apply pen orientation after the cached tip shape, in document space before the view. +/// Keeping this outside `rings` avoids retracing a sampled tip on every tablet sample. +pub(crate) fn pen_axes(xf: &crate::canvas::ViewXform, brush: &BrushSettings, pen: Option) -> [Vec2; 2] { + use photocraft_paint::brush::Control; + use photocraft_paint::dynamics::{StepInput, apply_pose, control_angle}; + let axes = tip_axes(xf, brush.size); + if !brush.shape_dynamics.enabled { + return axes; + } + let Some(pen) = pen else { return axes }; + let pen = pen.sanitized(); + let point = apply_pose( + brush, + photocraft_paint::StrokePoint { pressure: pen.pressure, tilt_x: pen.tilt_x, tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() }, + ); + let sd = &brush.shape_dynamics; + let step = StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 }; + let mut angle = match sd.angle.control { + Control::PenTilt | Control::Rotation => control_angle(sd.angle.control, sd.angle.fade_steps, &step), + _ => 0.0, + }; + if sd.brush_projection { + angle += point.rotation; + } + let (sn, cs) = angle.to_radians().sin_cos(); + let project = |v: Vec2| { + if !sd.brush_projection { + return v; + } + let tilt = point.tilt_x.hypot(point.tilt_y).clamp(0.0, 90.0); + if tilt == 0.0 { + return v; + } + let direction = Vec2::new(point.tilt_x, point.tilt_y).normalized(); + v + direction * v.dot(direction) * (tilt.to_radians().cos().max(0.05) - 1.0) + }; + let x = project(Vec2::new(cs, -sn)); + let y = project(Vec2::new(sn, cs)); + [axes[0] * x.x + axes[1] * x.y, axes[0] * y.x + axes[1] * y.y] +} + /// Rings in radius units (document orientation) on screen, through [`tip_axes`]. fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect() @@ -59,7 +100,12 @@ fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { /// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is /// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon { - if let Some(rings) = rings(painter.ctx(), brush, !normal) { + let anisotropic = (axes[0].length_sq() - axes[1].length_sq()).abs() > 0.001 || axes[0].dot(axes[1]).abs() > 0.001; + let outline = rings(painter.ctx(), brush, !normal).or_else(|| { + // A round tip becomes an ellipse under projection (or a non-square pixel aspect). + anisotropic.then(|| photocraft_paint::outline::tip_outline(brush, !normal).map(Arc::new)).flatten() + }); + if let Some(rings) = outline { let pts = on_screen(&rings, axes); let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); if extent < MIN_RADIUS { @@ -139,6 +185,48 @@ mod tests { (a - b).length() < 1e-4 } + #[test] + fn pen_orientation_preserves_mouse_and_disabled_dynamics() { + let mut brush = BrushSettings::default(); + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + let pen = crate::stylus::PenSample { rotation: 90.0, ..Default::default() }; + let xf = view(30.0, true, 2.0); + assert_eq!(pen_axes(&xf, &brush, Some(pen)), tip_axes(&xf, brush.size)); + brush.shape_dynamics.enabled = true; + assert_eq!(pen_axes(&xf, &brush, None), tip_axes(&xf, brush.size)); + } + + #[test] + fn pen_pose_rotation_precedes_the_view_and_reuses_the_tip_cache() { + let mut brush = BrushSettings { size: 20.0, roundness: 0.25, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + brush.pose.enabled = true; + brush.pose.override_rotation = true; + brush.pose.rotation = 90.0; + let ctx = egui::Context::default(); + let cached = rings(&ctx, &brush, true).unwrap(); + for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, true, 2.0)] { + let xf = view(rotation, flip, aspect); + let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default())); + assert!(close(axes[0], xf.map_vec(Vec2::new(0.0, -10.0)))); + assert!(close(axes[1], xf.map_vec(Vec2::new(10.0, 0.0)))); + assert!(Arc::ptr_eq(&cached, &rings(&ctx, &brush, true).unwrap())); + } + } + + #[test] + fn brush_projection_shortens_the_tilt_axis() { + let mut brush = BrushSettings { size: 20.0, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.brush_projection = true; + let xf = view(0.0, false, 1.0); + let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() })); + assert!(close(axes[0], Vec2::new(10.0, 0.0))); + assert!(close(axes[1], Vec2::new(0.0, 20.0))); + assert_eq!(pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default())), tip_axes(&xf, brush.size)); + } + /// The axes are the screen offsets of the document points one radius right of and below the /// pointer, whatever the view does. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..aea4b2849c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3958,7 +3958,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); - let axes = crate::brush_cursor::tip_axes(&xf, brush.size); + let axes = crate::brush_cursor::pen_axes(&xf, brush, app.stylus.sample()); crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre) } } diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 6caf4fe747..91a9224772 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -420,6 +420,24 @@ fn brush_cursor_follows_pen_orientation_6350() { } } +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn round_cursor_shows_brush_projection_6350() { + let mut h = harness(); + h.state_mut().session.tools.brush.shape_dynamics.enabled = true; + h.state_mut().session.tools.brush.shape_dynamics.brush_projection = true; + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample::default())); + h.run_steps(3); + let upright = h.output().platform_output.cursor_image.clone().unwrap(); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() })); + h.run_steps(3); + let tilted = h.output().platform_output.cursor_image.clone().unwrap(); + assert_ne!(upright.rgba, tilted.rgba, "a projected round brush must not keep a circular cursor"); + h.state_mut().stylus.feed.set(None); + h.run_steps(3); + assert_eq!(upright.rgba, h.output().platform_output.cursor_image.clone().unwrap().rgba, "mouse restores the unprojected tip"); +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes() { From f4f106578589b9212083fa4c99925f95e37d2a6b Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 00:46:44 +0200 Subject: [PATCH 0282/1452] Move tool: Ctrl-hover shows the distance to another layer (#4946) With Smart Guides on and the Move tool, holding Ctrl (Cmd on the Mac) with no button down and hovering another layer now draws magenta measurement lines with labels between the selected layers' bounds and that layer's: the gap between facing edges, or the distances between corresponding edges on an axis where the two overlap. Over empty canvas, the Background or the selection itself the distances run to the canvas edges. While an Alt-drag duplicates the layers, the distance from the original to the copy shows. Overlay only: no command, no history state. The hovered layer is the Auto-Select hit test, cached per document revision, selection and pointer pixel. Values use the ruler unit. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/move_distance.rs | 391 ++++++++++++++++++++++++++++ crates/ui-egui/src/move_mods.rs | 5 + crates/ui-egui/src/snap_ui.rs | 7 +- 5 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/move_distance.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..78017a39ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3793,6 +3793,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } crate::snap_ui::draw(app, &painter, &xf); + crate::move_distance::draw(app, &painter, &xf, idx, response.hover_pos()); // The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview), // nor while the Crop tool hides everything outside its box (Show Cropped Area off). if border == photocraft_engine::prefs::CanvasBorder::Line && !beyond && !crate::crop_shield::hides_outside(app) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a981c41f3..b23401ede0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -133,6 +133,7 @@ pub mod menu_nav; pub mod menus; pub mod monitor_status; pub mod move_box; +pub mod move_distance; pub mod move_hover; pub mod move_lock; pub mod move_mods; @@ -578,6 +579,8 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). pub(crate) move_hover: Option, + /// Smart Guides distances shown with ⌘ held over the canvas: the last answer (`move_distance`). + pub(crate) move_distance: Option, /// Object Selection › Object Finder: the hover preview (`object_finder`). pub(crate) object_finder: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). @@ -832,6 +835,7 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, move_hover: None, + move_distance: None, object_finder: None, blend_preview: None, patch_preview: None, diff --git a/crates/ui-egui/src/move_distance.rs b/crates/ui-egui/src/move_distance.rs new file mode 100644 index 0000000000..a5603590c4 --- /dev/null +++ b/crates/ui-egui/src/move_distance.rs @@ -0,0 +1,391 @@ +//! Smart Guides › measuring distances, as in Photoshop (#4946): with Smart Guides on (View › Show +//! › Smart Guides, Extras on) and the Move tool, holding ⌘ (Ctrl off the Mac) with no button down +//! and hovering another layer shows the distances between the selected layers' bounds and that +//! layer's, as magenta lines labelled with the value in the ruler unit. Over empty canvas (or the +//! Background, or the selection itself) the distances run to the canvas edges instead. While an +//! ⌥-drag duplicates the layers, the distance between the original and the copy shows. +//! +//! It's an overlay only: nothing changes in the document and there is no command or History +//! state. The hovered layer is the one a click there would Auto-Select +//! (`pick_cmds::auto_select_target`), cached per document revision, selection and pointer pixel, +//! so a pointer resting on the canvas costs nothing per frame. + +use egui::{Color32, Pos2, Stroke, pos2, vec2}; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::snap::{layer_rect, union}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// One measured distance: a line along x (`horizontal`, at y = `at`) or along y (at x = `at`), +/// from `from` to `to` on that axis (`from < to`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Measure { + pub horizontal: bool, + pub at: f64, + pub from: f64, + pub to: f64, +} + +impl Measure { + pub fn len(&self) -> f64 { + self.to - self.from + } +} + +/// What to draw between two boxes: the measured lines, and dashed extensions `[x0, y0, x1, y1]` +/// of the other box's edges where a line ends beside the box rather than on it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Overlay { + pub measures: Vec, + pub extensions: Vec<[f64; 4]>, +} + +/// The distances from box `a` (the selection, `[x0, y0, x1, y1]`) to box `b` (the hovered layer +/// or the canvas), per axis: the gap between facing edges when the boxes are apart on that axis, +/// else the distances between their corresponding edges (left to left, right to right). Zero +/// distances aren't shown. A line runs through the middle of where the boxes overlap on the other +/// axis, or through `a`'s centre when they don't. +pub(crate) fn measure(a: [f64; 4], b: [f64; 4]) -> Overlay { + let mut out = Overlay::default(); + if !a.iter().chain(b.iter()).all(|v| v.is_finite()) { + return out; + } + for horizontal in [true, false] { + // Spans along the measured axis (`s`) and across it (`o`). + let (k, o) = if horizontal { (0, 1) } else { (1, 0) }; + let (sa, sb) = ([a[k], a[k + 2]], [b[k], b[k + 2]]); + let (oa, ob) = ([a[o], a[o + 2]], [b[o], b[o + 2]]); + let (lo, hi) = (oa[0].max(ob[0]), oa[1].min(ob[1])); + let at = if lo < hi { (lo + hi) / 2.0 } else { (oa[0] + oa[1]) / 2.0 }; + // Each pair: the two ends, and which of them lies on `b`'s edge. + let pairs: Vec<(f64, f64, f64)> = if sa[1] <= sb[0] { + vec![(sa[1], sb[0], sb[0])] + } else if sb[1] <= sa[0] { + vec![(sb[1], sa[0], sb[1])] + } else { + vec![(sa[0].min(sb[0]), sa[0].max(sb[0]), sb[0]), (sa[1].min(sb[1]), sa[1].max(sb[1]), sb[1])] + }; + for (from, to, edge) in pairs { + if to - from <= 0.0 { + continue; + } + out.measures.push(Measure { horizontal, at, from, to }); + // The line ends beside `b`: extend `b`'s edge to it. + if at < ob[0] || at > ob[1] { + let near = if at < ob[0] { ob[0] } else { ob[1] }; + out.extensions.push(if horizontal { [edge, near, edge, at] } else { [near, edge, at, edge] }); + } + } + } + out +} + +/// The bounds of layer `id` and, for a group, of the layers in it (`snap::layer_rect`: what the +/// Move tool moves and snaps). +fn tree_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> { + let rows = doc.walk(); + let (prefix, _, _) = rows.iter().find(|(_, _, l)| l.id == id)?; + union(rows.iter().filter(|(p, _, _)| p.starts_with(prefix)).filter_map(|(_, _, l)| layer_rect(doc, l.id))) +} + +/// The last answer: the boxes measured with the pointer at `at` on that revision and selection. +pub(crate) struct Cache { + doc: DocId, + revision: u64, + at: [i32; 2], + group: bool, + selected: Vec, + boxes: Option<([f64; 4], [f64; 4])>, +} + +/// The selected layers' box and the box to measure it to with the pointer at `at` (document px): +/// the layer a click there would Auto-Select, or the canvas where that is no other layer. +fn boxes(app: &mut PhotocraftApp, at: [f64; 2]) -> Option<([f64; 4], [f64; 4])> { + if !at[0].is_finite() || !at[1].is_finite() { + return None; + } + let at = [at[0].floor() as i32, at[1].floor() as i32]; + let group = app.ui.tool_options.move_target == "group"; + let st = app.session.active()?; + let selected = st.selected_layers(); + let (doc, revision) = (st.doc.id, st.revision); + if let Some(c) = app.move_distance.as_ref().filter(|c| c.doc == doc && c.revision == revision && c.at == at && c.group == group && c.selected == selected) { + return c.boxes; + } + let d = &st.doc; + let a = union(selected.iter().filter(|id| !d.is_background(**id)).filter_map(|id| tree_rect(d, *id))); + let canvas = [0.0, 0.0, d.size.width as f64, d.size.height as f64]; + let hovered = photocraft_engine::pick_cmds::auto_select_target(d, at[0], at[1], group).filter(|id| { + // The selection itself (or a layer in a selected group) isn't another layer. + !d.is_background(*id) && !selected.iter().any(|s| *s == *id || d.path_of(*id).zip(d.path_of(*s)).is_some_and(|(p, q)| p.starts_with(&q))) + }); + let boxes = a.map(|a| (a, hovered.and_then(|id| tree_rect(d, id)).unwrap_or(canvas))); + app.move_distance = Some(Cache { doc, revision, at, group, selected, boxes }); + boxes +} + +/// What to measure now: the ⌥-drag's original and copy while it duplicates, else, with ⌘ held +/// (`command`) and no button down (`pressed`), the selection and what's under the pointer `at`. +/// `None` when Smart Guides are off or another tool is current. +pub(crate) fn current(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option { + if app.ui.tool != Tool::Move || !app.ui.view.shows(app.ui.view.show.smart_guides) || app.ui.transform.is_some() { + return None; + } + if let Some((original, copy)) = duplicate_drag(app) { + return Some(measure(original, copy)); + } + if !command || pressed || app.drag.is_some() || app.guide_drag.is_some() { + return None; + } + let (a, b) = boxes(app, at?)?; + Some(measure(a, b)) +} + +/// An ⌥-drag that has duplicated the layers: the original's box and the copy's where it is now. +fn duplicate_drag(app: &PhotocraftApp) -> Option<([f64; 4], [f64; 4])> { + if !crate::move_mods::duplicated(app) { + return None; + } + let snap = app.prefs_rt.snap.as_ref()?; + let crate::snap_ui::Gesture::Move { rect } = &snap.gesture else { return None }; + let p = app.drag.as_ref()?.points.last()?; + let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; + Some((*rect, [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]])) +} + +/// Black or white, whichever reads better on `c`. +fn on(c: Color32) -> Color32 { + let l = 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()); + if l > 150.0 { Color32::BLACK } else { Color32::WHITE } +} + +/// Draws the measurements on the active document's canvas in the Smart Guides colour. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let (mods, pressed) = painter.ctx().input(|i| (i.modifiers, i.pointer.any_down())); + let command = crate::workspace_ui::sticky_mods(app, mods).command; + let at = hover.map(|p| xf.to_doc(p)); + let Some(overlay) = current(app, at, command, pressed) else { return }; + let Some(st) = app.session.active() else { return }; + let (dpi, size) = (f64::from(st.doc.resolution_dpi), st.doc.size); + let ur = &app.session.prefs().units_and_rulers; + let color = crate::snap_ui::smart_color(app); + let stroke = Stroke::new(1.0, color); + let px = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5); + for e in &overlay.extensions { + let (a, b) = (xf.to_screen(e[0] as f32, e[1] as f32), xf.to_screen(e[2] as f32, e[3] as f32)); + painter.add(egui::Shape::dashed_line(&[px(a), px(b)], stroke, 3.0, 3.0)); + } + let t = crate::theme::Tokens::get(painter.ctx()); + let font = egui::FontId::proportional(11.0); + for m in &overlay.measures { + let (a, b) = if m.horizontal { + (xf.to_screen(m.from as f32, m.at as f32), xf.to_screen(m.to as f32, m.at as f32)) + } else { + (xf.to_screen(m.at as f32, m.from as f32), xf.to_screen(m.at as f32, m.to as f32)) + }; + let (a, b) = (px(a), px(b)); + painter.line_segment([a, b], stroke); + // End caps across the line. + let dir = (b - a).normalized(); + let cap = vec2(-dir.y, dir.x) * 4.0; + for p in [a, b] { + painter.line_segment([p - cap, p + cap], stroke); + } + let extent = if m.horizontal { size.width } else { size.height } as f64; + let text = format!("{} {}", ur.format(m.len(), dpi, extent), ur.rulers.suffix()); + let galley = painter.layout_no_wrap(text, font.clone(), on(color)); + let mid = a + (b - a) / 2.0; + let r = egui::Rect::from_center_size(mid, galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, t.radius_sm, color); + painter.galley(r.center() - galley.size() / 2.0, galley, on(color)); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn m(horizontal: bool, at: f64, from: f64, to: f64) -> Measure { + Measure { horizontal, at, from, to } + } + + #[test] + fn boxes_apart_measure_the_gap_between_facing_edges() { + // `b` 60 px right of `a`, overlapping it vertically from 20 to 40: the gap runs through + // the middle of that overlap (y = 30). + let o = measure([0.0, 0.0, 40.0, 40.0], [100.0, 20.0, 150.0, 80.0]); + // Vertically they overlap: the corresponding edges (tops 0 → 20, bottoms 40 → 80), through + // `a`'s centre (x = 20) as they don't overlap horizontally, with `b`'s top and bottom + // edges extended to it. + assert_eq!(o.measures, vec![m(true, 30.0, 40.0, 100.0), m(false, 20.0, 0.0, 20.0), m(false, 20.0, 40.0, 80.0)]); + assert_eq!(o.extensions, vec![[100.0, 20.0, 20.0, 20.0], [100.0, 80.0, 20.0, 80.0]]); + } + + #[test] + fn diagonal_boxes_measure_both_gaps_from_the_selection_centre() { + // `b` below-left of `a`: 30 px apart horizontally, 50 px vertically. + let o = measure([100.0, 0.0, 140.0, 20.0], [20.0, 70.0, 70.0, 90.0]); + assert_eq!(o.measures, vec![m(true, 10.0, 70.0, 100.0), m(false, 120.0, 20.0, 70.0)]); + // Both lines end beside `b`: its right edge reaches up to y = 10, its top edge across to x = 120. + assert_eq!(o.extensions, vec![[70.0, 70.0, 70.0, 10.0], [70.0, 70.0, 120.0, 70.0]]); + } + + #[test] + fn inside_the_canvas_measures_to_all_four_edges() { + let o = measure([40.0, 40.0, 90.0, 80.0], [0.0, 0.0, 400.0, 300.0]); + assert_eq!(o.measures, vec![m(true, 60.0, 0.0, 40.0), m(true, 60.0, 90.0, 400.0), m(false, 65.0, 0.0, 40.0), m(false, 65.0, 80.0, 300.0)]); + assert!(o.extensions.is_empty()); + // Edges that touch measure nothing; nonsense boxes nothing at all. + assert_eq!(measure([0.0, 0.0, 400.0, 300.0], [0.0, 0.0, 400.0, 300.0]), Overlay::default()); + assert_eq!(measure([f64::NAN, 0.0, 1.0, 1.0], [0.0, 0.0, 400.0, 300.0]), Overlay::default()); + } + + /// The `snap_ui` test document: a target box at (300..350, 200..260) and the selected mover + /// at (40..90, 40..80) on a 400×300 canvas. + fn app_with_boxes() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({"name": "target"})).unwrap(); + app.run("select.rect", json!({"x": 300, "y": 200, "width": 50, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layer", json!({"name": "mover"})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 40, "width": 50, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.view.extras = true; + app.ui.view.show.smart_guides = true; + app + } + + #[test] + fn command_hover_over_another_layer_measures_to_it() { + let mut app = app_with_boxes(); + let revision = app.session.active().unwrap().revision; + let o = current(&mut app, Some([320.5, 230.0]), true, false).unwrap(); + // 210 px across (90 → 300), 120 px down (80 → 200), from the mover's centre. + assert_eq!(o.measures, vec![m(true, 60.0, 90.0, 300.0), m(false, 65.0, 80.0, 200.0)]); + // Over empty canvas (the Background): to the canvas edges. + let o = current(&mut app, Some([200.0, 150.0]), true, false).unwrap(); + assert_eq!(o.measures, vec![m(true, 60.0, 0.0, 40.0), m(true, 60.0, 90.0, 400.0), m(false, 65.0, 0.0, 40.0), m(false, 65.0, 80.0, 300.0)]); + // Over the selected layer itself: also the canvas edges. + assert_eq!(current(&mut app, Some([60.0, 60.0]), true, false).unwrap().measures.len(), 4); + // Only an overlay: the document didn't change. + assert_eq!(app.session.active().unwrap().revision, revision); + } + + #[test] + fn nothing_without_command_smart_guides_or_the_move_tool() { + let mut app = app_with_boxes(); + let at = Some([320.5, 230.0]); + assert!(current(&mut app, at, true, false).is_some()); + // ⌘ not held, or a button down (⌘-click auto-selects, unchanged). + assert_eq!(current(&mut app, at, false, false), None); + assert_eq!(current(&mut app, at, true, true), None); + assert_eq!(current(&mut app, None, true, false), None); + // Smart Guides off, or Extras off. + app.ui.view.show.smart_guides = false; + assert_eq!(current(&mut app, at, true, false), None); + app.ui.view.show.smart_guides = true; + app.ui.view.extras = false; + assert_eq!(current(&mut app, at, true, false), None); + app.ui.view.extras = true; + // Another tool. + app.ui.tool = Tool::Brush; + assert_eq!(current(&mut app, at, true, false), None); + // Nothing to measure from: the Background is selected (the Move tool doesn't move it). + app.ui.tool = Tool::Move; + let bg = app.session.active().unwrap().doc.walk().first().unwrap().2.id; + app.run("layer.select", json!({"layer": bg.0})).unwrap(); + assert_eq!(current(&mut app, at, true, false), None); + } + + #[test] + fn alt_drag_duplicate_measures_from_the_original_to_the_copy() { + let mut app = app_with_boxes(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let alt = egui::Modifiers { alt: true, ..Default::default() }; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, alt); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Move { x: 160.0, y: 60.0, pressure: 1.0 }, alt); + // The copy's left edge is 50 px right of the original's right edge (90 → 140); they line + // up vertically, so only the gap shows. A button is down and ⌘ isn't held. + let o = current(&mut app, None, false, true).unwrap(); + assert_eq!(o.measures, vec![m(true, 60.0, 90.0, 140.0)]); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Up { x: 160.0, y: 60.0 }, alt); + assert_eq!(current(&mut app, None, false, false), None); + } + + /// Through the real canvas: ⌘ held over the other layer measures to it (the overlay is drawn + /// from the cached boxes), and a ⌘-click there still Auto-Selects it as before. + #[test] + fn command_hover_on_the_canvas_draws_and_command_click_still_picks() { + use egui::{Event, Modifiers, PointerButton, vec2}; + let mut app = app_with_boxes(); + app.ui.tool_options.move_auto_select = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + let screen = |h: &egui_kittest::Harness<'_, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) + }; + let command = if cfg!(target_os = "macos") { + Modifiers { command: true, mac_cmd: true, ..Default::default() } + } else { + Modifiers { command: true, ctrl: true, ..Default::default() } + }; + let revision = h.state().session.active().unwrap().revision; + let p = screen(&h, 320.5, 230.5); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + // Not held: nothing measured. + assert!(h.state().move_distance.is_none()); + h.event(Event::ModifiersChanged(command)); + h.run_steps(2); + let boxes = h.state().move_distance.as_ref().and_then(|c| c.boxes); + assert_eq!(boxes, Some(([40.0, 40.0, 90.0, 80.0], [300.0, 200.0, 350.0, 260.0]))); + if let Ok(path) = std::env::var("PHOTOCRAFT_MOVE_DISTANCE_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + assert_eq!(h.state().session.active().unwrap().revision, revision, "hovering changed nothing"); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: command }); + h.run_steps(1); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: command }); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + let picked = st.active_layer.and_then(|id| st.doc.layer(id)).map(|l| l.name.clone()); + assert_eq!(picked.as_deref(), Some("target")); + } +} diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 64b3a65603..a5be52036f 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -152,6 +152,11 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; [start[0] + d[0], start[1] + d[1]] } +/// The Move drag in progress is an ⌥-drag that has duplicated the layers (it now moves the copies). +pub(crate) fn duplicated(app: &PhotocraftApp) -> bool { + moving(app) && app.move_mods.dup_from.is_some() +} + /// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step. pub fn finish(app: &mut PhotocraftApp) { let st = std::mem::take(&mut app.move_mods); diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..170e024b3a 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -499,10 +499,15 @@ fn hex_color(s: &str, fallback: Color32) -> Color32 { photocraft_engine::prefs::parse_hex(s).map_or(fallback, |c| Color32::from_rgb(c[0], c[1], c[2])) } +/// The Smart Guides colour from Preferences › Guides, Grid & Slices (magenta by default). +pub(crate) fn smart_color(app: &PhotocraftApp) -> Color32 { + hex_color(&app.session.prefs().guides_grid_and_slices.smart_guide_color, Color32::from_rgb(255, 0, 255)) +} + /// Draw smart guides (and the moving bounds) for the drag in progress. pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(snap) = &app.prefs_rt.snap else { return }; - let smart_color = hex_color(&app.session.prefs().guides_grid_and_slices.smart_guide_color, Color32::from_rgb(255, 0, 255)); + let smart_color = smart_color(app); // The moved layers' bounds follow the Move tool (Free Transform draws its own box). if let (Gesture::Move { rect }, Some(d)) = (&snap.gesture, app.drag.as_ref().and_then(|d| d.points.last()).map(|p| [p[0] - snap.start[0], p[1] - snap.start[1]])) From 79e827ce7a8397851caa6a7f7a0e640118768e98 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:47:09 +0800 Subject: [PATCH 0283/1452] test: optionally capture actual pen cursor bitmaps --- crates/ui-egui/src/tool_cursor/tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 91a9224772..a28d549405 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -400,6 +400,8 @@ fn brush_cursor_follows_pen_orientation_6350() { h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_y: -45.0, rotation: 90.0, ..Default::default() })); h.run_steps(3); let turned = h.output().platform_output.cursor_image.clone().expect("turned brush cursor"); + save_pen_cursor_image(&format!("{control}-initial"), &initial); + save_pen_cursor_image(&format!("{control}-turned"), &turned); let pen = h.state().stylus.sample().expect("simulated pen reaches the canvas"); assert_eq!((pen.tilt_y, pen.rotation), (-45.0, 90.0)); let point = photocraft_paint::StrokePoint { tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() }; @@ -432,12 +434,26 @@ fn round_cursor_shows_brush_projection_6350() { h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() })); h.run_steps(3); let tilted = h.output().platform_output.cursor_image.clone().unwrap(); + save_pen_cursor_image("projection-upright", &upright); + save_pen_cursor_image("projection-tilted", &tilted); assert_ne!(upright.rgba, tilted.rgba, "a projected round brush must not keep a circular cursor"); h.state_mut().stylus.feed.set(None); h.run_steps(3); assert_eq!(upright.rgba, h.output().platform_output.cursor_image.clone().unwrap().rgba, "mouse restores the unprojected tip"); } +#[cfg(not(target_arch = "wasm32"))] +fn save_pen_cursor_image(name: &str, cursor: &egui::CustomCursorImage) { + let Some(dir) = std::env::var_os("PHOTOCRAFT_CURSOR_OUTPUT") else { return }; + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + let image = + photocraft_codecs::Image::from_u8(u32::from(cursor.size[0]), u32::from(cursor.size[1]), photocraft_codecs::ChannelLayout::Rgba, cursor.rgba.to_vec()) + .unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(dir.join(format!("{name}.png")), png).unwrap(); +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes() { From 6b9fc995cea2f25646be655eac96f03e152305eb Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:47:44 +0800 Subject: [PATCH 0284/1452] test: add explicit release cursor generation cost probe --- crates/ui-egui/src/brush_cursor.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index fac5989c2a..22a489ba9a 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -185,6 +185,34 @@ mod tests { (a - b).length() < 1e-4 } + /// Component timing only: outline transformation and OS bitmap generation, not presentation + /// or tablet latency. Run explicitly in release mode on the same runner for both cases. + #[test] + #[ignore] + fn pen_cursor_cost_probe() { + let mut brush = BrushSettings { size: 60.0, roundness: 0.25, hardness: 1.0, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + let xf = view(0.0, false, 1.0); + for dynamic in [false, true] { + let ctx = egui::Context::default(); + for sample in 0..5 { + let start = std::time::Instant::now(); + let _ = ctx.run_ui(Default::default(), |ui| { + for i in 0..360 { + let axes = if dynamic { + pen_axes(&xf, &brush, Some(crate::stylus::PenSample { rotation: i as f32, ..Default::default() })) + } else { + tip_axes(&xf, brush.size) + }; + std::hint::black_box(cursor(ui.painter(), pos2(100.0, 100.0), &brush, 60.0, axes, false, false)); + } + }); + println!("PEN_CURSOR_COST dynamic={dynamic} sample={sample} us_per_update={:.3}", start.elapsed().as_secs_f64() * 1e6 / 360.0); + } + } + } + #[test] fn pen_orientation_preserves_mouse_and_disabled_dynamics() { let mut brush = BrushSettings::default(); From 8237d76170a15027e1795ba6b1c6d9de9fcdab9b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 19:28:58 -0400 Subject: [PATCH 0285/1452] Help search test: take the first Levels result and move the cursor to the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "levels" now finds two results (Image › Adjustments › Levels… and the Levels adjustment layer), and one → only reaches level|s, so the test needs a second → before typing the space. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_nav_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 659a794b3e..ebe89db424 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -284,10 +284,13 @@ fn help_search_keeps_the_arrows_and_space_that_edit_it() { key(&mut h, egui::Key::ArrowLeft); typing(&mut h, "e"); assert_eq!(text(&h), "levels", "← moved the cursor"); + // The cursor sits at `leve|ls`: two → take it to the end. key(&mut h, egui::Key::ArrowRight); assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open"); + key(&mut h, egui::Key::ArrowRight); // The pointer resting on a result lights it; a space typed then is text, not a click. - let levels = h.get_by_label_contains("› Levels").rect().center(); + // "levels" finds both Image › Adjustments › Levels… and the Levels adjustment layer. + let levels = h.get_all_by_label_contains("› Levels").next().expect("a Levels result").rect().center(); h.hover_at(levels); h.run_steps(3); assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit"); From dedf89feae2df0636a1cad6c06e6761ed46f3442 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:33:24 +0200 Subject: [PATCH 0286/1452] Type: move the caret test next to its helper, away from the file's end --- crates/ui-egui/src/type_tool_tests.rs | 86 +++++++++++++-------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index d27fdbcdac..1202ba28fa 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -115,6 +115,49 @@ fn new_app() -> PhotocraftApp { app } +/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white +/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. +#[test] +fn the_caret_reads_on_white_and_on_black() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = new_app(); + app.ui.tool = crate::state::Tool::Type; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). + let ed = app.ui.text_edit.as_mut().expect("editing"); + ed.anchor = ed.caret; + ed.dragging = true; + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 100.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut h = + Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + // Vertical line segments are the caret (the frame and underline run sideways). + let caret: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), + _ => None, + }) + .collect(); + assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); + let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); + assert_eq!(line, egui::Color32::BLACK); + assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); + assert_eq!(halo, egui::Color32::WHITE); + assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); +} + fn xf(app: &PhotocraftApp) -> ViewXform { let v = &app.ui.views[0]; ViewXform { @@ -2065,46 +2108,3 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } - -/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white -/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. -#[test] -fn the_caret_reads_on_white_and_on_black() { - use crate::canvas::{ToolEvent, tool_event}; - let mut app = new_app(); - app.ui.tool = crate::state::Tool::Type; - tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); - tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); - // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). - let ed = app.ui.text_edit.as_mut().expect("editing"); - ed.anchor = ed.caret; - ed.dragging = true; - let xf = ViewXform { - rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), - zoom: 1.0, - center: [100.0, 100.0], - flip: false, - rotation: 0.0, - aspect: 1.0, - }; - let mut h = - Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - h.run(); - // Vertical line segments are the caret (the frame and underline run sideways). - let caret: Vec<(f32, egui::Color32)> = h - .output() - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), - _ => None, - }) - .collect(); - assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); - let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); - assert_eq!(line, egui::Color32::BLACK); - assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); - assert_eq!(halo, egui::Color32::WHITE); - assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); -} From 6f2abe20fca3c35d39b81b3a4de2aba11c28803b Mon Sep 17 00:00:00 2001 From: cortezsilvano-bot <250676892+cortezsilvano-bot@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:14:47 -0500 Subject: [PATCH 0287/1452] =?UTF-8?q?Print:=20Functions=20=E2=80=BA=20Blee?= =?UTF-8?q?d=20(corner=20crop=20marks=20on=20the=20trim=20line)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's Print dialog has Functions › Bleed: crop marks print inside the image by the bleed width, so trimming on them cuts through the artwork and leaves no paper-white sliver. target-app-parity listed it as missing ("No Separations/bleed"). - file.print / file.printOneCopy take "bleed" in inches (default 0), report it back, and Print One Copy remembers it. - Corner crop marks move onto the trim line, still starting 6 pt outside the image. Centre and registration marks are unchanged (a symmetric bleed doesn't move the midlines). - One helper (corner_crop_segments) drives both the PDF writer and the native Windows primitives; the dialog preview draws the marks there too. - Negative, non-numeric or too-wide bleeds are BadParams, never a panic; bleed without corner crop marks changes nothing and reports 0. - With bleed absent or 0 the PDF is byte-identical (tested). The Print dialog field is a follow-up: every tl! label needs all translations. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds.rs | 50 ++++++++++--- crates/engine/src/print_cmds/tests.rs | 71 +++++++++++++++++++ crates/engine/src/print_cmds/windows_marks.rs | 24 +++++-- crates/ui-egui/src/file_ui.rs | 6 +- docs/target-app-parity.md | 5 +- 5 files changed, 138 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..29de3e57f0 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -61,6 +61,27 @@ pub struct Marks { pub corner_crop: bool, pub center_crop: bool, pub registration: bool, + /// Photoshop's Functions › Bleed, in points: corner crop marks sit this far inside the image + /// edge, so the trim cuts through the image and leaves no paper-white sliver. 0 = on the edge. + pub bleed: f64, +} + +/// Gap between the image edge and a crop mark, and the mark's length (pt). +const MARK_GAP: f64 = 6.0; +const MARK_LEN: f64 = 18.0; + +/// Corner crop marks around `(x, y, w, h)` as eight `(from, to)` segments, a horizontal then a +/// vertical one per corner. Each starts `MARK_GAP` outside the image (so it never prints on it) +/// and lies on the trim line, `bleed` inside the edge. The inset is symmetric, so this holds for +/// both bottom-up (PDF) and top-down (Windows) coordinates. +fn corner_crop_segments(bleed: f64, (x, y, w, h): (f64, f64, f64, f64)) -> [((f64, f64), (f64, f64)); 8] { + let mut out = [((0.0, 0.0), (0.0, 0.0)); 8]; + for (i, (cx, cy, dx, dy)) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)].into_iter().enumerate() { + let (tx, ty) = (cx - dx * bleed, cy - dy * bleed); + out[2 * i] = ((cx + dx * MARK_GAP, ty), (cx + dx * (MARK_GAP + MARK_LEN), ty)); + out[2 * i + 1] = ((tx, cy + dy * MARK_GAP), (tx, cy + dy * (MARK_GAP + MARK_LEN))); + } + out } /// One page: paper (pt), image box (pt, origin bottom-left), marks and texts. @@ -102,12 +123,11 @@ fn deflate(data: &[u8]) -> Vec { /// Vector marks around `(x, y, w, h)`. fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { let mut s = String::from("q 0 G 0.3 w\n"); - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); if m.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(m.bleed, (x, y, w, h)) { + line(&mut s, x0, y0, x1, y1); } } if m.center_crop { @@ -303,10 +323,16 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { let (pw, ph) = paper(p, cmd)?; let dpi = f64::from(doc.resolution_dpi.max(1.0)); let (iw, ih) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); - let marks = Marks { + // Bleed in inches, like Photoshop's Position fields; the marks need it in points. + let bleed = match p.get("bleed") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|b| b.is_finite() && *b >= 0.0).ok_or_else(|| bad(cmd, "bleed is a width in inches, 0 or more"))? * 72.0, + }; + let mut marks = Marks { corner_crop: p.get("cornerCropMarks").and_then(Value::as_bool).unwrap_or(false), center_crop: p.get("centerCropMarks").and_then(Value::as_bool).unwrap_or(false), registration: p.get("registrationMarks").and_then(Value::as_bool).unwrap_or(false), + bleed, }; let margin = if marks.corner_crop || marks.center_crop || marks.registration { 36.0 } else { 18.0 }; let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); @@ -316,6 +342,14 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; let (w, h) = (iw * scale, ih * scale); + // The trim box must keep some area, or the marks of opposite corners would cross. + if 2.0 * marks.bleed >= w.min(h) { + return Err(bad(cmd, format!("bleed {:.3} in leaves no image inside the trim ({:.2} × {:.2} in printed)", marks.bleed / 72.0, w / 72.0, h / 72.0))); + } + // Bleed only moves crop marks; without them it has nothing to change. + if !marks.corner_crop { + marks.bleed = 0.0; + } let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); let (x, y) = if center { ((pw - w) / 2.0, (ph - h) / 2.0) @@ -398,7 +432,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ - "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, + "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } @@ -431,7 +465,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), ) } @@ -750,7 +784,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"bleed":in=0 (Functions › Bleed: corner crop marks this far inside the image edge, on the trim line),"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..f3d1fc3fe3 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -434,3 +434,74 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// A dry-run print to `name`.pdf with `extra` params; returns the result and the PDF content stream. +fn print_with(s: &mut Session, dir: &str, name: &str, extra: Value) -> (Value, Vec) { + let mut p = json!({"output": format!("{dir}/{name}.pdf"), "dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute("file.print", p).unwrap(); + let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); + (r, pdf) +} + +#[test] +fn zero_bleed_prints_exactly_as_before() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-zero"); + let (plain, a) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + let (zero, b) = print_with(&mut s, &dir, "zero", json!({"cornerCropMarks": true, "bleed": 0.0})); + assert_eq!(a, b, "bleed 0 must not change a single byte"); + assert_eq!(plain["bleed"], 0.0); + assert_eq!(zero["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_puts_corner_crop_marks_on_the_trim_line_outside_the_image() { + let mut s = session("rgb", 8); + let dir = tmp("bleed"); + let (r, pdf) = print_with(&mut s, &dir, "bleed", json!({"cornerCropMarks": true, "centerCropMarks": true, "bleed": 0.125})); + assert_eq!(r["bleed"], 0.125); + let rect: Vec = r["imageRect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let (x, y, w, h) = (rect[0], rect[1], rect[2], rect[3]); + let c = pdf_content(&pdf); + // Bottom-left corner (PDF is bottom-up): the horizontal mark runs left from 6 pt outside the + // image, 9 pt (0.125 in) above the image's bottom edge; the vertical one 9 pt right of its left edge. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x - 6.0, y + 9.0, x - 24.0, y + 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + 9.0, y - 6.0, x + 9.0, y - 24.0)), "{c}"); + // Top-right corner, mirrored. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w + 6.0, y + h - 9.0, x + w + 24.0, y + h - 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w - 9.0, y + h + 6.0, x + w - 9.0, y + h + 24.0)), "{c}"); + // Centre crop marks are on the midlines, which a symmetric bleed does not move. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w / 2.0, y - 6.0, x + w / 2.0, y - 24.0)), "{c}"); + // Print One Copy repeats the last print, bleed included. + let again = s.execute("file.printOneCopy", json!({"output": format!("{dir}/again.pdf"), "dryRun": true})).unwrap(); + assert_eq!(again["bleed"], 0.125); + // The image box itself is unchanged: bleed moves marks, not pixels. + let (plain, _) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + assert_eq!(plain["imageRect"], r["imageRect"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_without_corner_crop_marks_changes_nothing() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-nomarks"); + let (_, a) = print_with(&mut s, &dir, "plain", json!({"registrationMarks": true})); + let (r, b) = print_with(&mut s, &dir, "bleed", json!({"registrationMarks": true, "bleed": 0.1})); + assert_eq!(a, b); + assert_eq!(r["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_bleed_is_an_error_not_a_panic() { + let mut s = session("rgb", 8); + // The test document prints 300 × 150 pt, so 1.05 in (75.6 pt) leaves no trim box. + for bleed in [json!(-0.1), json!("0.1"), json!(true), json!([0.1]), json!(1.05), json!(1e308)] { + let r = s.execute("file.print", json!({"dryRun": true, "send": false, "output": "", "cornerCropMarks": true, "bleed": bleed})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "bleed {bleed}: {r:?}"); + } +} diff --git a/crates/engine/src/print_cmds/windows_marks.rs b/crates/engine/src/print_cmds/windows_marks.rs index 857e6335a9..b78d4234b9 100644 --- a/crates/engine/src/print_cmds/windows_marks.rs +++ b/crates/engine/src/print_cmds/windows_marks.rs @@ -1,5 +1,5 @@ //! App-owned print-mark composition in points from the sheet's top-left. -use super::Marks; +use super::{MARK_GAP, MARK_LEN, Marks, corner_crop_segments}; use craft_print::rgb::{Primitive, Shape}; pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Vec { @@ -7,11 +7,10 @@ pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Ve let line = |out: &mut Vec, x0, y0, x1, y1| { out.push(Primitive { shape: Shape::Line { from: (x0, y0), to: (x1, y1) }, width: 0.3, color: [0; 3] }); }; - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); if marks.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut out, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut out, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(marks.bleed, (x, y, w, h)) { + line(&mut out, x0, y0, x1, y1); } } if marks.center_crop { @@ -37,11 +36,24 @@ mod tests { use super::*; #[test] fn marks_surround_the_final_image_box_in_physical_points() { - let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true }, (100.0, 200.0, 144.0, 72.0)); + let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true, bleed: 0.0 }, (100.0, 200.0, 144.0, 72.0)); assert_eq!(out.len(), 24); assert_eq!(out[0].shape, Shape::Line { from: (94.0, 200.0), to: (76.0, 200.0) }); assert_eq!(out[12].shape, Shape::Ellipse { bounds: (167.0, 180.0, 10.0, 10.0) }); assert!(out.iter().all(|p| p.width == 0.3 && p.color == [0; 3])); assert!(primitives(Marks::default(), (0.0, 0.0, 10.0, 10.0)).is_empty()); } + + #[test] + fn bleed_moves_corner_crop_marks_onto_the_trim_line_in_top_down_coordinates() { + let rect = (100.0, 200.0, 144.0, 72.0); + let out = primitives(Marks { corner_crop: true, bleed: 9.0, ..Marks::default() }, rect); + assert_eq!(out.len(), 8); + // Top-left corner: still outside the image, but on the trim line 9 pt in (y down here). + assert_eq!(out[0].shape, Shape::Line { from: (94.0, 209.0), to: (76.0, 209.0) }); + assert_eq!(out[1].shape, Shape::Line { from: (109.0, 194.0), to: (109.0, 176.0) }); + // Bottom-right corner, mirrored. + assert_eq!(out[6].shape, Shape::Line { from: (250.0, 263.0), to: (268.0, 263.0) }); + assert_eq!(out[7].shape, Shape::Line { from: (235.0, 278.0), to: (235.0, 296.0) }); + } } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..aebd7ca18a 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -921,11 +921,13 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (Print › Functions › Bleed through `file.print`; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -286,7 +286,7 @@ just presence. | Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) | | Automation: CLI, MCP, headless server, control channel | ~95% | Ahead of Photoshop here (see below) | | Image modes (RGB/Gray/CMYK/Lab/Indexed/Duotone/Multichannel) | ~85% | Multichannel display-only; no ink-plate model | -| Print / export / Save for Web / slices / Generator | ~85% | No Separations/bleed; droplets basic | +| Print / export / Save for Web / slices / Generator | ~85% | No Separations; Bleed only through `file.print` (no Print dialog field yet); droplets basic | | Computational photo (Photomerge, HDR, Lens, Camera Raw, Wide Angle) | ~70% | Classical implementations; generic lens profiles only | | Data-driven graphics (Variables, Data Sets) | ~0% | Not started (5 menu items) | | Video / Timeline / animation | ~0% | Not started (13 menu items) — a whole subsystem | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Print: Functions › Bleed (`file.print` / `file.printOneCopy` `bleed`, corner crop marks on the trim line, PDF and Windows paths); the Print dialog field is still missing; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | From cf3d26881d77d9e92e1def2e7d713da41095bed0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 20:41:08 -0400 Subject: [PATCH 0288/1452] Windows: start without Windows.Data.Pdf.dll; delay-load it for printing (#5884) winprint's PdfCreateRenderer made photocraft.exe and photocraft-cli.exe import Windows.Data.Pdf.dll at load time, so Windows images without the PDF component (NTLite-stripped, some N/LTSC builds) refused to start: "windows.data.pdf.dll was not found". Both apps now delay-load it, and PDF printing checks the DLL is installed first and reports an error instead of aborting. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/build.rs | 8 ++++++++ apps/photocraft/build.rs | 8 ++++++++ crates/engine/src/print_cmds.rs | 20 ++++++++++++++++++++ crates/engine/src/print_cmds/tests.rs | 10 ++++++++++ 4 files changed, 46 insertions(+) diff --git a/apps/photocraft-cli/build.rs b/apps/photocraft-cli/build.rs index c757a4a7c5..3951c46b81 100644 --- a/apps/photocraft-cli/build.rs +++ b/apps/photocraft-cli/build.rs @@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return Ok(()); } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); // FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left // to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index cbba622589..48abc9f1a5 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,6 +11,14 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/photocraft.ico") .set("ProductName", "PhotoCraft") diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..69f4505535 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -427,6 +427,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut spool = Value::Null; if send && !dry { require_printing_enabled()?; + require_windows_pdf()?; spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -435,6 +436,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { ) } +/// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their +/// `build.rs`) so a Windows image without it still starts. Calling into it when it is missing +/// would abort the process, so check that it is installed first (#5884). +fn require_windows_pdf() -> Result<()> { + if !cfg!(windows) { + return Ok(()); + } + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); + if windows_pdf_installed(std::path::Path::new(&root)) { + Ok(()) + } else { + Err(other("printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.")) + } +} + +fn windows_pdf_installed(system_root: &std::path::Path) -> bool { + system_root.join("System32").join("Windows.Data.Pdf.dll").is_file() +} + /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently /// draws RGB/gray and vector marks only; it must never silently omit requested page content. fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..677fdec6ba 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -434,3 +434,13 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +#[test] +fn windows_pdf_component_is_detected_by_its_dll() { + // #5884: a Windows image without Windows.Data.Pdf.dll reports an error instead of aborting. + let dir = tempfile::tempdir().unwrap(); + assert!(!windows_pdf_installed(dir.path())); + std::fs::create_dir(dir.path().join("System32")).unwrap(); + std::fs::write(dir.path().join("System32").join("Windows.Data.Pdf.dll"), b"MZ").unwrap(); + assert!(windows_pdf_installed(dir.path())); +} From 0ce7e00fee45c2db22ede12184bb6a1fd3203777 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 03:06:07 +0200 Subject: [PATCH 0289/1452] Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809) Photoshop scales the Dual Brush diameter by the main size ratio, unrounded, with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to 1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the diameter itself), keep the cap, and leave a non-positive or NaN result alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds/tests.rs | 51 +++++++++++++++++++++++++++ crates/paint/src/brush.rs | 12 ++++++- crates/paint/src/brush_tests.rs | 48 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..0e34997bb4 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -371,6 +371,57 @@ fn resizing_a_brush_resizes_its_enabled_secondary_tip() { assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); } +/// Every way of changing the main size scales the Dual Brush tip as Photoshop does (#4809): `[` / +/// `]`, the options bar / Brushes panel / Alt+right-drag (`tools.setBrush` with only a size), down +/// to 1 px and back without losing the proportion to a floor. +#[test] +fn every_size_change_scales_the_dual_tip_and_keeps_its_proportion() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + let sizes = |s: &Session| (s.tools.brush.size, s.tools.brush.dual_brush.size); + s.execute("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (80.0, 16.0)); + s.execute("tools.increaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (100.0, 20.0)); + // A coalesced drag (options bar slider, Alt+right-drag) is a run of size-only patches. + for size in [60, 10, 1] { + s.execute("tools.setBrush", json!({"brush": {"size": size}, "coalesce": "drag"})).unwrap(); + } + assert_eq!(s.tools.brush.size, 1.0); + assert!((s.tools.brush.dual_brush.size - 0.2).abs() < 1e-5, "no 0.5 px floor: {}", s.tools.brush.dual_brush.size); + s.execute("tools.setBrush", json!({"brush": {"size": 100}})).unwrap(); + assert_eq!(s.tools.brush.size, 100.0); + assert!((s.tools.brush.dual_brush.size - 20.0).abs() < 1e-3, "back to 20 px: {}", s.tools.brush.dual_brush.size); + // Painting with the sub-pixel dual tip works (the renderer bounds it). + s.execute("tools.setBrush", json!({"brush": {"size": 1}})).unwrap(); + s.execute("paint.stroke", json!({"points": [[4, 16], [28, 16]]})).unwrap(); +} + +/// Picking a preset that captured its size sets both diameters from the preset: the dual tip is +/// not scaled on top of that. +#[test] +fn a_captured_preset_size_is_not_scaled_twice() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual40"})).unwrap(); + s.execute("tools.setBrush", json!({"size": 200})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 50.0); + s.execute("tools.setBrush", json!({"preset": "Dual40"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 10.0)); +} + +#[test] +fn bad_sizes_fail_and_leave_the_dual_tip_alone() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + for bad in [json!(1e30), json!("big"), json!([1]), json!({"x": 1}), json!(null)] { + let _ = s.execute("tools.setBrush", json!({"brush": {"size": bad}})); + assert_eq!(s.tools.brush.dual_brush.size, 20.0, "{bad}"); + assert_eq!(s.tools.brush.size, 100.0, "{bad}"); + } + assert!(s.execute("tools.setBrush", json!({"brush": {"size": 1e30}})).is_err()); +} + #[test] fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { let mut s = session(32, 32); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index d60412a58d..3943073855 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -589,9 +589,19 @@ impl Default for BrushSettings { impl BrushSettings { /// Resize the primary tip and keep an enabled Dual Brush tip at the same relative size. /// An explicit Dual Brush size edit should bypass this and retain the requested diameter. + /// + /// As in Photoshop, the dual diameter is multiplied by the size ratio unrounded and only capped + /// at the top ([`MAX_BRUSH_SIZE`]): a 100 px brush with a 20 px dual tip shrunk to 1 px has a + /// 0.2 px dual tip and grows back to exactly 20 px. A sub-pixel floor here would lose the + /// proportion on the way back up (#4809); rendering bounds the diameter itself + /// ([`Self::bounded_for_render`]). pub fn resize_with_dual_brush(&mut self, new_size: f32) { if self.dual_brush.enabled && self.size.is_finite() && self.size > 0.0 && new_size.is_finite() && new_size > 0.0 { - self.dual_brush.size = (self.dual_brush.size * (new_size / self.size)).clamp(0.5, MAX_BRUSH_SIZE); + let scaled = self.dual_brush.size * (new_size / self.size); + // NaN fails the comparison; infinity is capped. + if scaled > 0.0 { + self.dual_brush.size = scaled.min(MAX_BRUSH_SIZE); + } } self.size = new_size; } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..051b3652f8 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1493,3 +1493,51 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() { assert_eq!(s.rgba(x, 5), first, "pixel {x} of the Pencil line"); } } + +/// Photoshop (measured): a 100 px brush with a 20 px Dual Brush tip resized to 50 / 2 / 1 px has a +/// 10 / 0.4 / 0.2 px dual tip, unrounded and with no floor, and back at 100 px it is 20 px again; past +/// 5000 px the dual tip is capped there and the proportion is lost (#4809). +#[test] +fn resizing_scales_the_dual_tip_like_photoshop() { + let mut b = BrushSettings { size: 100.0, ..brush() }; + b.dual_brush.enabled = true; + b.dual_brush.size = 20.0; + for (main, dual) in [(50.0, 10.0), (2.0, 0.4), (1.0, 0.2), (100.0, 20.0), (240.0, 48.0), (100.0, 20.0)] { + b.resize_with_dual_brush(main); + assert_eq!(b.size, main); + assert!((b.dual_brush.size - dual).abs() < 1e-4, "main {main}: dual {} != {dual}", b.dual_brush.size); + } + // The cap: 300 px at 100 px would be 15000 px at 5000 px; Photoshop stores 5000 and scales from it. + b.dual_brush.size = 300.0; + b.resize_with_dual_brush(MAX_BRUSH_SIZE); + assert_eq!(b.dual_brush.size, MAX_BRUSH_SIZE); + b.resize_with_dual_brush(100.0); + assert_eq!(b.dual_brush.size, 100.0); + // A sub-pixel dual tip renders at the renderer's smallest diameter. + b.dual_brush.size = 20.0; + b.resize_with_dual_brush(1.0); + assert_eq!(b.bounded_for_render().dual_brush.size, 0.5); +} + +#[test] +fn resizing_with_bad_sizes_leaves_the_dual_tip_alone() { + let mut b = BrushSettings { size: 100.0, ..brush() }; + b.dual_brush.enabled = true; + b.dual_brush.size = 20.0; + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] { + b.size = 100.0; + b.resize_with_dual_brush(bad); + assert_eq!(b.dual_brush.size, 20.0, "{bad}"); + } + for bad_dual in [f32::NAN, 0.0, -3.0] { + b.size = 100.0; + b.dual_brush.size = bad_dual; + b.resize_with_dual_brush(50.0); + assert_eq!(b.dual_brush.size.to_bits(), bad_dual.to_bits(), "a bad dual size {bad_dual} is left as it was"); + } + // A disabled dual tip keeps its size. + b.dual_brush = DualBrush { enabled: false, size: 20.0, ..Default::default() }; + b.size = 100.0; + b.resize_with_dual_brush(50.0); + assert_eq!(b.dual_brush.size, 20.0); +} From b59b0cd1d572a554aad29540e4027313006df7f2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 03:18:18 +0200 Subject: [PATCH 0290/1452] PSD: Perspective Warp smart filters open and save like Photoshop (#6268) A Perspective Warp smart filter (Fltr class perspectiveWarpTransform) fell through to psd.unsupportedFilter, so a re-rendered smart object (e.g. after Replace Contents) lost its perspective. Each quad now becomes a plane of layer.smartObjects.perspectiveWarp (src = vertices, dst = warpedVertices), and PSD save writes Photoshop's descriptor back: the original item with only the geometry rewritten, or a new item in the layout Photoshop writes. Inconsistent descriptors stay opaque, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/tests/smart_psd.rs | 61 ++++ crates/io/src/perspective_warp_map.rs | 446 ++++++++++++++++++++++++++ crates/io/src/smart_map.rs | 9 +- 3 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 crates/io/src/perspective_warp_map.rs diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 2586f24cc9..43eca52d4d 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -469,6 +469,67 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } +/// A Perspective Warp smart filter as Photoshop writes it (`perspectiveWarpTransform`: vertices, +/// warped vertices and quads of vertex indices, document pixels) opens as +/// `layer.smartObjects.perspectiveWarp` and re-renders warped instead of flat (#6268), and keeps +/// its planes through PSD export, `.pcraft` and a PSD re-import. +#[test] +fn perspective_warp_psd_filter_renders_warped() { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; + let pnts = |p: &[[f64; 2]]| { + let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; + Value::List(p.iter().map(|q| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(q[0])).with("Vrtc", px(q[1])))).collect()) + }; + let quad = Descriptor::new("perspectiveWarpQuad").with("indices", Value::List((0..4).map(Value::Integer).collect())); + let fltr = Descriptor::new("perspectiveWarpTransform") + .with("vertices", pnts(&[[8.0, 6.0], [80.0, 6.0], [80.0, 58.0], [8.0, 58.0]])) + // The right edge moves in and shrinks: the content no longer reaches x = 70. + .with("warpedVertices", pnts(&[[8.0, 6.0], [50.0, 20.0], [50.0, 44.0], [8.0, 58.0]])) + .with("quads", Value::List(vec![Value::Descriptor(quad)])); + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Perspective Warp..."))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(708)); + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = photocraft_doc::LayerId(s.active().unwrap().active_layer.unwrap().0); + let filter = photocraft_io::smart_map::filter_from_item(&item); + assert_eq!(filter.command, "layer.smartObjects.perspectiveWarp"); + s.edit("perspective warp", |doc, _| { + let mut l = doc.layer(id).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") }; + sm.smart_filters.push(filter.clone()); + assert!(refresh_layer(doc, &mut l).unwrap()); + *doc.layer_mut(id).unwrap() = l; + Ok(()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let alpha = |doc: &Document, x: i32, y: i32| smart(doc).1.cache.unwrap().pixel(x, y)[3]; + assert!(alpha(&doc, 20, 30) > 0.99 && alpha(&doc, 70, 30) < 0.01 && alpha(&doc, 45, 10) < 0.01, "depth {depth}: warped"); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + keep(&format!("perspective-warp-{depth}.psd"), &out.bytes); + let back = photocraft_io::import("perspective.psd", &out.bytes).unwrap().document; + let native = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&back, &Default::default()).unwrap()).unwrap(); + for doc in [&back, &native] { + let (bid, after) = smart(doc); + assert_eq!(after.smart_filters, vec![filter.clone()], "depth {depth}"); + let mut l = doc.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(doc, &mut l).unwrap()); + let LayerContent::Smart(sm) = &l.content else { panic!("smart") }; + let (a, b) = (sm.cache.as_ref().unwrap(), smart(&s.active().unwrap().doc).1.cache.unwrap()); + assert_eq!(a.pixel(20, 30), b.pixel(20, 30), "depth {depth}: re-render"); + assert_eq!(a.pixel(70, 30), b.pixel(70, 30), "depth {depth}: re-render"); + } + } +} + /// A version-2 `liFD` item, as in Photoshop CS2's `lnkD` block: the fields every version has, and /// none of the version 5–7 fields after the file data. fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { diff --git a/crates/io/src/perspective_warp_map.rs b/crates/io/src/perspective_warp_map.rs new file mode 100644 index 0000000000..8e1af9bc5b --- /dev/null +++ b/crates/io/src/perspective_warp_map.rs @@ -0,0 +1,446 @@ +//! Perspective Warp smart filters (`Fltr` class `perspectiveWarpTransform`). +//! +//! Layout as Photoshop writes it (checked by applying Edit › Perspective Warp to a smart object +//! through the Action Manager and saving the PSD): +//! +//! ```text +//! filterFX: Nm, blendOptions, enab, hasoptions (true), FrgC, BckC, filterID (1023 observed; +//! other versions write other ids), Fltr = perspectiveWarpTransform { +//! null = reference to the target layer, +//! referenceRect = Rctn { Top, Left, Btom, Rght } (UntF #Pxl, the layer bounds), +//! vertices = [ Pnt { Hrzn, Vrtc } (UntF #Pxl, document pixels) ], (Layout mode) +//! warpedVertices = [ Pnt { Hrzn, Vrtc } ] (same count), (Warp mode) +//! quads = [ perspectiveWarpQuad { indices = [4 × long] } ], +//! PuX0..PuX3, PuY0..PuY3 (doubles: the placement corners when the filter was applied) } +//! ``` +//! +//! Each quad is one plane of `layer.smartObjects.perspectiveWarp`: `src` = the quad's +//! `vertices`, `dst` = its `warpedVertices`, in the stored corner order. Quads that share a vertex +//! share the corner, which the engine links. The item is kept as a template ([`TEMPLATE_KEY`]) so +//! the keys PhotoCraft does not model go back unchanged, with only the vertices and quads +//! rewritten from the edited planes. Anything inconsistent (an index out of range, mismatched +//! vertex counts, a non-finite or degenerate quad) stays an opaque [`super::UNSUPPORTED_FILTER`]. + +use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, rgbc, set, to_hex, unit}; + +pub(super) const CLASS: &str = "perspectiveWarpTransform"; +/// The command this filter maps to (`photocraft_engine::distort_cmds::PERSPECTIVE_SMART`). +pub(super) const COMMAND: &str = "layer.smartObjects.perspectiveWarp"; +/// Edit › Perspective Warp on a smart object stores its own id; it renders the same. +const EDIT_COMMAND: &str = "edit.perspectiveWarp"; +/// The `filterID` Photoshop wrote for a new Perspective Warp smart filter. +const FILTER_ID: i32 = 1023; +const NAME: &str = "Perspective Warp..."; +pub(super) const TEMPLATE_KEY: &str = "__perspectiveWarpPsd"; +/// Far more planes than anyone lays out by hand; bounds the corner linking (quadratic). +const MAX_QUADS: usize = 256; +const MAX_VERTICES: usize = 4 * MAX_QUADS; +const MAX_TEMPLATE_BYTES: usize = 1 << 20; + +/// Whether `command` is a Perspective Warp smart filter this module writes. +pub(super) fn handles(command: &str) -> bool { + command == COMMAND || command == EDIT_COMMAND +} + +fn point(v: &Value) -> Option<[f64; 2]> { + let Value::Descriptor(p) = v else { return None }; + let coord = |k: &str| match p.get(k)? { + Value::Double(d) => Some(*d), + Value::UnitFloat { unit: [b'#', b'P', b'x', b'l'], value } => Some(*value), + _ => None, + }; + Some([coord("Hrzn")?, coord("Vrtc")?]).filter(|q| q.iter().all(|v| v.is_finite())) +} + +fn points(f: &Descriptor, key: &str) -> Option> { + let Some(Value::List(list)) = f.get(key) else { return None }; + if list.is_empty() || list.len() > MAX_VERTICES { + return None; + } + list.iter().map(point).collect() +} + +fn quads(f: &Descriptor) -> Option> { + let Some(Value::List(list)) = f.get("quads") else { return None }; + if list.is_empty() || list.len() > MAX_QUADS { + return None; + } + list.iter() + .map(|q| { + let Value::Descriptor(q) = q else { return None }; + let Some(Value::List(ix)) = q.get("indices") else { return None }; + let ix: Vec = ix + .iter() + .map(|i| match i { + Value::Integer(i) => usize::try_from(*i).ok(), + _ => None, + }) + .collect::>()?; + ix.try_into().ok() + }) + .collect() +} + +/// The planes a `perspectiveWarpTransform` descriptor describes; `None` when it is inconsistent. +fn planes_of(f: &Descriptor) -> Option> { + let (src, dst) = (points(f, "vertices")?, points(f, "warpedVertices")?); + if src.len() != dst.len() { + return None; + } + let corners = + |list: &[[f64; 2]], q: &[usize; 4]| -> Option<[[f64; 2]; 4]> { Some([*list.get(q[0])?, *list.get(q[1])?, *list.get(q[2])?, *list.get(q[3])?]) }; + let planes: Vec = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; + PerspectiveMap::new(&planes)?; + Some(planes) +} + +/// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let planes = planes_of(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + Some(json!({"planes": planes, TEMPLATE_KEY: to_hex(&template.to_bytes())})) +} + +/// The template item of `params` (`None` when there is none), checked to be a Perspective Warp. +fn template(params: &J) -> Result, String> { + let Some(raw) = params.get(TEMPLATE_KEY) else { return Ok(None) }; + let raw = raw.as_str().ok_or("the Perspective Warp PSD data must be hex text")?; + if raw.len() > MAX_TEMPLATE_BYTES * 2 { + return Err("the Perspective Warp PSD data is too large".into()); + } + let bytes = from_hex(raw).ok_or("the Perspective Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Perspective Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Perspective Warp PSD data is not a Perspective Warp filter".into()); + } + Ok(Some(item)) +} + +/// The planes to write: as rendered, so a `straighten` is baked in. +fn export_planes(params: &J) -> Result, String> { + let object = params.as_object().ok_or("Perspective Warp parameters must be an object")?; + if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["planes", "straighten", "interpolation"].contains(&k.as_str())) { + return Err(format!("Perspective Warp setting `{k}` has no PSD equivalent")); + } + let mut planes: Vec = + serde_json::from_value(params.get("planes").cloned().ok_or("it has no planes")?).map_err(|e| format!("its planes are unreadable ({e})"))?; + if planes.is_empty() || planes.len() > MAX_QUADS { + return Err(format!("it must have 1 to {MAX_QUADS} planes")); + } + if planes.iter().any(|q| q.src.iter().chain(&q.dst).flatten().any(|v| !v.is_finite())) { + return Err("a plane corner is not finite".into()); + } + if let Some(s) = params.get("straighten").and_then(J::as_str) { + straighten(&mut planes, Straighten::parse(s).ok_or_else(|| format!("unknown straighten `{s}`"))?); + } + PerspectiveMap::new(&planes).ok_or("a plane is degenerate")?; + Ok(planes) +} + +fn pnt(p: [f64; 2]) -> Value { + Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Pxl", p[0])).with("Vrtc", unit(b"#Pxl", p[1]))) +} + +/// `vertices`, `warpedVertices` and `quads` for `planes`; a corner shared by several planes (same +/// layout and warped position) is written once. +fn geometry(planes: &[Plane]) -> (Value, Value, Value) { + let mut corners: Vec<([f64; 2], [f64; 2])> = Vec::new(); + let mut quads = Vec::with_capacity(planes.len()); + for p in planes { + let mut ix = Vec::with_capacity(4); + for (s, d) in p.src.iter().zip(&p.dst) { + let i = corners.iter().position(|c| c == &(*s, *d)).unwrap_or_else(|| { + corners.push((*s, *d)); + corners.len() - 1 + }); + ix.push(Value::Integer(i32::try_from(i).unwrap_or(i32::MAX))); + } + quads.push(Value::Descriptor(Descriptor::new("perspectiveWarpQuad").with("indices", Value::List(ix)))); + } + let src = corners.iter().map(|c| pnt(c.0)).collect(); + let dst = corners.iter().map(|c| pnt(c.1)).collect(); + (Value::List(src), Value::List(dst), Value::List(quads)) +} + +/// A new `perspectiveWarpTransform` descriptor (no Photoshop template to start from). +fn fresh_fltr(planes: &[Plane]) -> Descriptor { + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for c in planes.iter().flat_map(|p| p.src) { + (x0, y0, x1, y1) = (x0.min(c[0]), y0.min(c[1]), x1.max(c[0]), y1.max(c[1])); + } + let target = Value::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::new_nul(""), class_id: Id::new("Lyr ") }, + type_id: Id::new("Ordn"), + value: Id::new("Trgt"), + }]); + let rect = + Descriptor::new("Rctn").with("Top ", unit(b"#Pxl", y0)).with("Left", unit(b"#Pxl", x0)).with("Btom", unit(b"#Pxl", y1)).with("Rght", unit(b"#Pxl", x1)); + Descriptor::new(CLASS).with("null", target).with("referenceRect", Value::Descriptor(rect)) +} + +/// The `filterFXList` item for a Perspective Warp smart filter. +pub(super) fn export_item(sf: &SmartFilter) -> Result { + let params = &sf.params; + let planes = export_planes(params)?; + let template = template(params)?; + let unchanged = template.as_ref().and_then(|item| get_desc(item, "Fltr")).and_then(planes_of).is_some_and(|p| p == planes); + let mut item = match template { + // Unchanged planes: the original item, vertex order and sharing included. + Some(item) if unchanged => item, + Some(mut item) => { + let mut f = get_desc(&item, "Fltr").cloned().ok_or("the Perspective Warp PSD data has no filter")?; + write_geometry(&mut f, &planes); + set(&mut item, "Fltr", Value::Descriptor(f)); + item + } + None => { + let mut f = fresh_fltr(&planes); + write_geometry(&mut f, &planes); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(NAME))) + .with("blendOptions", blend_options(sf)) + .with("enab", Value::Boolean(sf.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(FILTER_ID)) + } + }; + set(&mut item, "blendOptions", blend_options(sf)); + set(&mut item, "enab", Value::Boolean(sf.visible)); + Ok(item) +} + +fn write_geometry(f: &mut Descriptor, planes: &[Plane]) { + let (src, dst, quads) = geometry(planes); + set(f, "vertices", src); + set(f, "warpedVertices", dst); + set(f, "quads", quads); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, int, item_for_filter}; + + fn pts(list: &[[f64; 2]]) -> Value { + Value::List(list.iter().map(|&p| pnt(p)).collect()) + } + + fn quad_list>(q: &[Q]) -> Value { + Value::List( + q.iter() + .map(|q| { + Value::Descriptor( + Descriptor::new("perspectiveWarpQuad").with("indices", Value::List(q.as_ref().iter().map(|&i| Value::Integer(i)).collect())), + ) + }) + .collect(), + ) + } + + /// A `filterFXList` item as Photoshop wrote it for a one-plane Perspective Warp of a smart + /// object placed at (40, 30)–(160, 120). + fn item_with>(vertices: &[[f64; 2]], warped: &[[f64; 2]], q: &[Q], filter_id: i32) -> Descriptor { + let mut f = fresh_fltr(&[Plane::identity([[40.0, 30.0], [160.0, 30.0], [160.0, 120.0], [40.0, 120.0]])]) + .with("vertices", pts(vertices)) + .with("warpedVertices", pts(warped)) + .with("quads", quad_list(q)); + for (i, (x, y)) in [(40.0, 30.0), (160.0, 30.0), (160.0, 120.0), (40.0, 120.0)].into_iter().enumerate() { + f = f.with(&format!("PuX{i}"), Value::Double(x)); + f = f.with(&format!("PuY{i}"), Value::Double(y)); + } + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Perspektivische Verformung..."))) + .with( + "blendOptions", + blend_options(&SmartFilter { + command: String::new(), + params: J::Null, + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }), + ) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(filter_id)) + } + + const V: [[f64; 2]; 4] = [[40.0, 30.0], [160.0, 30.0], [160.0, 120.0], [40.0, 120.0]]; + const W: [[f64; 2]; 4] = [[60.0, 50.0], [140.0, 40.0], [180.0, 130.0], [20.0, 110.0]]; + + fn one_plane() -> Descriptor { + item_with(&V, &W, &[[0, 1, 2, 3]], FILTER_ID) + } + + /// Two planes sharing an edge (vertices 1 and 4). + fn two_planes(filter_id: i32) -> Descriptor { + let v = [[40.0, 30.0], [100.0, 30.0], [160.0, 30.0], [160.0, 120.0], [100.0, 120.0], [40.0, 120.0]]; + let w = [[40.0, 40.0], [100.0, 30.0], [170.0, 45.0], [150.0, 115.0], [100.0, 125.0], [30.0, 110.0]]; + item_with(&v, &w, &[[0, 1, 4, 5], [1, 2, 3, 4]], filter_id) + } + + #[test] + fn a_perspective_warp_opens_as_the_command_with_its_planes() { + let f = filter_from_item(&one_plane()); + assert_eq!(f.command, COMMAND); + let planes: Vec = serde_json::from_value(f.params["planes"].clone()).unwrap(); + assert_eq!(planes, vec![Plane { src: V, dst: W }]); + + // Several quads become several planes; any filterID (708 in other versions) is read. + let f = filter_from_item(&two_planes(708)); + assert_eq!(f.command, COMMAND); + let planes: Vec = serde_json::from_value(f.params["planes"].clone()).unwrap(); + assert_eq!(planes.len(), 2); + assert_eq!(planes[0].src[1], planes[1].src[0], "the shared vertex is the same corner in both planes"); + assert_eq!(planes[1].dst, [[100.0, 30.0], [170.0, 45.0], [150.0, 115.0], [100.0, 125.0]]); + } + + #[test] + fn unchanged_filters_write_the_original_item_back() { + for src in [one_plane(), two_planes(708)] { + let f = filter_from_item(&src); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn edited_planes_round_trip_and_keep_the_rest_of_the_template() { + let src = two_planes(708); + let mut f = filter_from_item(&src); + f.params["planes"][1]["dst"][2] = json!([155.5, 118.0]); + f.opacity = 0.5; + f.visible = false; + let item = item_for_filter(&f).unwrap(); + assert_eq!(int(&item, "filterID"), Some(708), "the original filter id stays"); + let fltr = get_desc(&item, "Fltr").unwrap(); + assert_eq!(fltr.get("PuX1"), Some(&Value::Double(160.0)), "unmodelled keys stay"); + let Some(Value::List(v)) = fltr.get("vertices") else { panic!() }; + assert_eq!(v.len(), 6, "shared corners stay shared"); + let back = filter_from_item(&item); + assert_eq!(back.command, COMMAND); + assert_eq!(back.params["planes"], f.params["planes"]); + assert_eq!((back.opacity, back.visible), (0.5, false)); + } + + #[test] + fn a_new_perspective_warp_writes_photoshops_layout() { + let planes = vec![Plane { src: V, dst: W }]; + let f = SmartFilter { + command: COMMAND.into(), + params: json!({"planes": planes}), + blend: photocraft_color::BlendMode::Multiply, + opacity: 0.25, + visible: true, + }; + let item = item_for_filter(&f).unwrap(); + assert_eq!(int(&item, "filterID"), Some(FILTER_ID)); + assert_eq!(item.get("hasoptions"), Some(&Value::Boolean(true))); + let fltr = get_desc(&item, "Fltr").unwrap(); + assert!(fltr.class_id.is(CLASS)); + assert_eq!(fltr.get("vertices"), Some(&pts(&V))); + assert_eq!(fltr.get("warpedVertices"), Some(&pts(&W))); + assert_eq!(fltr.get("quads"), Some(&quad_list(&[[0, 1, 2, 3]]))); + let rect = get_desc(fltr, "referenceRect").unwrap(); + assert_eq!(rect.get("Rght"), Some(&unit(b"#Pxl", 160.0))); + let back = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((back.command.as_str(), &back.params["planes"]), (COMMAND, &json!(planes))); + assert_eq!((back.blend, back.opacity), (photocraft_color::BlendMode::Multiply, 0.25)); + + // Edit › Perspective Warp on a smart object stores its own id; it is written too, with a + // straighten baked in. + let f = SmartFilter { command: EDIT_COMMAND.into(), params: json!({"planes": planes, "straighten": "auto", "interpolation": "bilinear"}), ..f }; + assert!(item_for_filter(&f).is_ok()); + } + + #[test] + fn inconsistent_perspective_warps_stay_opaque() { + let nan = [[f64::NAN, 30.0], V[1], V[2], V[3]]; + let flat = [[0.0, 0.0]; 4]; + let without = |key: &str| { + let mut i = one_plane(); + let mut f = get_desc(&i, "Fltr").unwrap().clone(); + f.items.retain(|(k, _)| !k.is(key)); + set(&mut i, "Fltr", Value::Descriptor(f)); + i + }; + let mut bad_unit = one_plane(); + let mut f = get_desc(&bad_unit, "Fltr").unwrap().clone(); + let Some(Value::List(l)) = f.get("vertices").cloned() else { panic!() }; + let mut l = l; + l[0] = Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", 40.0)).with("Vrtc", unit(b"#Prc", 30.0))); + set(&mut f, "vertices", Value::List(l)); + set(&mut bad_unit, "Fltr", Value::Descriptor(f)); + let too_many: Vec<[i32; 4]> = vec![[0, 1, 2, 3]; MAX_QUADS + 1]; + for (i, src) in [ + item_with(&V, &W, &[[0, 1, 2, 4]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2, -1]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2, 3, 0]], FILTER_ID), + item_with(&V, &W, &[] as &[[i32; 4]], FILTER_ID), + item_with(&V, &W, &too_many, FILTER_ID), + item_with(&V, &W[..3], &[[0, 1, 2, 3]], FILTER_ID), + item_with(&nan, &W, &[[0, 1, 2, 3]], FILTER_ID), + item_with(&flat, &W, &[[0, 1, 2, 3]], FILTER_ID), + item_with(&[], &[], &[[0, 1, 2, 3]], FILTER_ID), + without("vertices"), + without("warpedVertices"), + without("quads"), + bad_unit, + ] + .into_iter() + .enumerate() + { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER, "case {i}"); + // Bytes, as NaN is not equal to itself. + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), src.to_bytes()); + } + } + + #[test] + fn bad_exports_report_errors() { + let ok = filter_from_item(&one_plane()).params; + let with = |k: &str, v: J| { + let mut p = ok.clone(); + p[k] = v; + p + }; + let plane = json!([{"src": V, "dst": W}]); + for p in [ + J::Null, + json!({}), + json!({"planes": []}), + json!({"planes": "x"}), + json!({"planes": [{"src": [[0, 0], [0, 0], [0, 0], [0, 0]], "dst": W}]}), + json!({"planes": plane, "straighten": "sideways"}), + json!({"planes": plane, "pins": []}), + json!({"planes": vec![json!({"src": V, "dst": W}); MAX_QUADS + 1]}), + with(TEMPLATE_KEY, json!("zz")), + with(TEMPLATE_KEY, json!(7)), + with(TEMPLATE_KEY, json!(to_hex(&Descriptor::new("filterFX").to_bytes()))), + with(TEMPLATE_KEY, json!("0".repeat(MAX_TEMPLATE_BYTES * 2 + 2))), + ] { + let f = SmartFilter { command: COMMAND.into(), params: p.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + assert!(export_item(&f).is_err(), "{p}"); + } + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..1c9ed2bea1 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -20,7 +20,8 @@ //! listed, can be hidden, reordered or deleted, and is written back unchanged. //! //! Camera Raw and Displace keep their whole item as a template next to the editable settings -//! (Displace's carries the embedded displacement map, see `displace_map.rs`). +//! (Displace's carries the embedded displacement map, see `displace_map.rs`); so does Perspective +//! Warp (`perspective_warp_map.rs`), whose quads become the planes of its command. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{LayerMask, SmartFilter}; @@ -39,6 +40,8 @@ use crate::blocks::{enum_of, get_desc, num}; mod camera_raw; #[path = "displace_map.rs"] mod displace; +#[path = "perspective_warp_map.rs"] +mod perspective_warp; pub use displace::embedded_map as displace_embedded_map; @@ -469,6 +472,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), c => c.and_then(|c| known_params(c, fltr)), }; @@ -624,6 +628,9 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if f.command == GALLERY_COMMAND { return gallery_item(f); } + if perspective_warp::handles(&f.command) { + return perspective_warp::export_item(f); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; From a9ba86898e04c2016d1cf8e6ff75f3484d1b0d51 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 03:18:42 +0200 Subject: [PATCH 0291/1452] PSD: Hue/Saturation smart filters open and save like Photoshop (#6267) A Hue/Saturation smart filter (Fltr class HStr, filterID 'HStr') opened as psd.unsupportedFilter, so re-rendering the smart object (replace contents, relink) dropped it, and a Hue/Saturation smart filter made in PhotoCraft was left out of PSD saves. The new hue_sat_filter_map module maps Photoshop's layout (measured on a Photoshop-saved file: presetKind, Clrz, Adjs = master Hst2 {H, Strt, Lght} plus one Hst2 per edited range with LclR, BgnR, BgnS, EndS, EndR, all longs) to image.adjustments.hueSaturation with the command's params, and writes it back in that layout. Anything outside it (unknown keys, duplicate or unknown ranges, values the dialog can't hold) stays an opaque unsupported filter written back verbatim. smart_map.rs only gets the dispatch hooks. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/tests/smart_psd.rs | 43 ++++ crates/io/src/hue_sat_filter_map.rs | 382 ++++++++++++++++++++++++++++ crates/io/src/smart_map.rs | 9 +- 3 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 crates/io/src/hue_sat_filter_map.rs diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 2586f24cc9..87cdfc43fb 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -670,3 +670,46 @@ fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() { let d = max_diff(&photocraft_compose::flatten(&back).px, &photocraft_compose::flatten(&doc).px); assert!(d <= 2.0 / 255.0, "re-opened differs by {d}"); } + +/// Image › Adjustments › Hue/Saturation on a smart object is a smart filter that Photoshop saves +/// as an `HStr` filter (#6267): it is written in that layout, opens as the same command and +/// params, and re-renders adjusted instead of passing the source through. +#[test] +fn hue_saturation_smart_filter_survives_psd_and_re_renders() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let plain = smart(&s.active().unwrap().doc).1.cache.unwrap(); + let params = json!({"hue": 40, "saturation": -30, "lightness": 10, "colorize": false, + "greens": {"hue": -20, "saturation": 15, "lightness": -5, "range": [75, 105, 135, 165]}}); + s.execute("image.adjustments.hueSaturation", params.clone()).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + assert_eq!(before.smart_filters.len(), 1); + assert_eq!(before.smart_filters[0].command, "image.adjustments.hueSaturation"); + let adjusted = before.cache.clone().unwrap(); + assert!(plain.pixel(20, 20) != adjusted.pixel(20, 20), "depth {depth}: the filter changes pixels"); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + keep(&format!("hue-sat-{depth}.psd"), &out.bytes); + let back = photocraft_io::import("hue-sat.psd", &out.bytes).unwrap().document; + let (bid, after) = smart(&back); + assert_eq!(after.smart_filters.len(), 1); + let f = &after.smart_filters[0]; + assert_eq!(f.command, "image.adjustments.hueSaturation", "depth {depth}: {}", f.params); + for (k, v) in params.as_object().unwrap() { + assert_eq!(&f.params[k], v, "depth {depth}: {k}"); + } + // Re-rendered from the source (as replaceContents / relink do), the filter still applies. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let LayerContent::Smart(sm) = &l.content else { panic!("smart") }; + let cache = sm.cache.as_ref().unwrap(); + for (x, y) in [(20, 20), (40, 30), (70, 50)] { + let (a, b) = (cache.pixel(x, y), adjusted.pixel(x, y)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() <= 1.0 / 255.0 + 1e-6), "depth {depth} at {x},{y}: {a:?} vs {b:?}"); + } + } +} diff --git a/crates/io/src/hue_sat_filter_map.rs b/crates/io/src/hue_sat_filter_map.rs new file mode 100644 index 0000000000..d7ce04938d --- /dev/null +++ b/crates/io/src/hue_sat_filter_map.rs @@ -0,0 +1,382 @@ +//! Hue/Saturation smart filters (`Fltr` class `HStr`, filterID `HStr` as a long), as Photoshop +//! records Image › Adjustments › Hue/Saturation applied to a smart object: +//! +//! ```text +//! Fltr = HStr { presetKind = presetKindType.presetKindCustom, Clrz = bool, +//! Adjs = [ Hst2 { H , Strt, Lght } (master, first) +//! Hst2 { LclR (1 reds … 6 magentas), BgnR, BgnS, EndS, EndR, (one per edited range) +//! H , Strt, Lght } ] } +//! ``` +//! +//! Every number is a long: hue in degrees (a shift of -180..180, or 0..360 with Colorize), the +//! rest in percent; the four range sliders are hue degrees (reds: 315, 345, 15, 45). The filter +//! maps to `image.adjustments.hueSaturation` with the command's own params, so it re-renders +//! when the smart object does. Anything else (an unknown key, a duplicated or unknown range, a +//! value Photoshop's dialog can't hold) stays an opaque [`super::UNSUPPORTED_FILTER`]. + +use photocraft_doc::SmartFilter; +use photocraft_doc::adjust::HueRange; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; +use serde_json::{Map, Value as J, json}; + +use super::{blend_options, enumv, is_meta, rgbc}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "HStr"; +pub(super) const COMMAND: &str = "image.adjustments.hueSaturation"; +const FILTER_ID: i32 = super::code(b"HStr"); +/// `Nm ` (Photoshop writes the localized menu item). +const NAME: &str = "Hue/Saturation..."; +/// The `Fltr` descriptor's own name. +const FLTR_NAME: &str = "Hue/Saturation"; + +/// The colour ranges in `LclR` order (1 = reds), as the command's param keys. +const RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; +const BOUNDS: [&str; 4] = ["BgnR", "BgnS", "EndS", "EndR"]; +const SETTINGS: [(&str, &str); 3] = [("H ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")]; + +/// A whole, finite number within `lo..=hi`, as JSON. +fn whole(v: Option<&Value>, lo: f64, hi: f64) -> Option { + let v = num(v).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v))?; + Some(json!(v as i64)) +} + +/// `image.adjustments.hueSaturation` params for an `HStr` filter; `None` when any part of it is +/// not the observed layout. +pub(super) fn import_params(f: &Descriptor) -> Option { + if !f.class_id.is(CLASS) || f.items.iter().any(|(k, _)| !["presetKind", "Clrz", "Adjs"].iter().any(|n| k.is(n))) { + return None; + } + let colorize = match f.get("Clrz") { + Some(Value::Boolean(b)) => *b, + None => false, + Some(_) => return None, + }; + let Some(Value::List(entries)) = f.get("Adjs") else { return None }; + // A master and six ranges at most. + if entries.len() > 1 + RANGES.len() { + return None; + } + let mut out = Map::new(); + out.insert("colorize".into(), J::Bool(colorize)); + let mut seen_master = false; + for e in entries { + let Value::Descriptor(e) = e else { return None }; + if e.items.iter().any(|(k, _)| !k.is("LclR") && !BOUNDS.iter().any(|n| k.is(n)) && !SETTINGS.iter().any(|(n, _)| k.is(n))) { + return None; + } + let mut o = Map::new(); + match e.get("LclR") { + None => { + if std::mem::replace(&mut seen_master, true) { + return None; + } + let (hue, sat) = if colorize { ((0.0, 360.0), (0.0, 100.0)) } else { ((-180.0, 180.0), (-100.0, 100.0)) }; + o.insert("hue".into(), whole(e.get("H "), hue.0, hue.1)?); + o.insert("saturation".into(), whole(e.get("Strt"), sat.0, sat.1)?); + o.insert("lightness".into(), whole(e.get("Lght"), -100.0, 100.0)?); + out.extend(o); + } + r => { + let i = whole(r, 1.0, RANGES.len() as f64)?.as_u64()?; + let key = *RANGES.get(usize::try_from(i).ok()?.checked_sub(1)?)?; + if out.contains_key(key) { + return None; + } + o.insert("hue".into(), whole(e.get("H "), -180.0, 180.0)?); + o.insert("saturation".into(), whole(e.get("Strt"), -100.0, 100.0)?); + o.insert("lightness".into(), whole(e.get("Lght"), -100.0, 100.0)?); + let bounds: Option> = BOUNDS.iter().map(|k| whole(e.get(k), -720.0, 720.0)).collect(); + o.insert("range".into(), J::Array(bounds?)); + out.insert(key.into(), J::Object(o)); + } + } + } + Some(J::Object(out)) +} + +/// A number param of `o` (`default` when absent), finite. +fn number(o: &J, key: &str, default: f64) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or(format!("Hue/Saturation `{key}` must be a number")), + } +} + +/// A number param rounded and clamped like the dialog's field, as a long. +fn setting(o: &J, key: &str, default: f64, lo: f64, hi: f64) -> Result { + Ok(long(number(o, key, default)?, lo, hi)) +} + +fn long(v: f64, lo: f64, hi: f64) -> Value { + Value::Integer(v.round().clamp(lo, hi) as i32) +} + +/// The `Adjs` entry of colour range `i` (0 = reds), or `None` when it is neutral with its default +/// extent (Photoshop lists only the ranges that were edited). +fn range_entry(i: usize, o: &J) -> Result, String> { + let obj = o.as_object().ok_or(format!("Hue/Saturation `{}` must be an object", RANGES.get(i).unwrap_or(&"")))?; + if let Some(k) = obj.keys().find(|k| !["hue", "saturation", "lightness", "range"].contains(&k.as_str())) { + return Err(format!("Hue/Saturation range setting `{k}` has no PSD equivalent")); + } + let neutral = HueRange::neutral(i); + let bounds = match o.get("range") { + None => neutral.bounds, + Some(r) => { + let a = r.as_array().filter(|a| a.len() == 4).ok_or("a Hue/Saturation range must be four hue degrees")?; + let mut b = [0f32; 4]; + for (slot, v) in b.iter_mut().zip(a) { + *slot = v.as_f64().filter(|v| v.is_finite()).ok_or("a Hue/Saturation range must be four hue degrees")? as f32; + } + HueRange::canonical_bounds(b) + } + }; + let hue = setting(o, "hue", 0.0, -180.0, 180.0)?; + let sat = setting(o, "saturation", 0.0, -100.0, 100.0)?; + let light = setting(o, "lightness", 0.0, -100.0, 100.0)?; + let zero = Value::Integer(0); + if [&hue, &sat, &light].iter().all(|v| **v == zero) && bounds == neutral.bounds { + return Ok(None); + } + let lclr = i32::try_from(i + 1).map_err(|e| e.to_string())?; + let mut d = Descriptor::new("Hst2").with("LclR", Value::Integer(lclr)); + for (k, v) in BOUNDS.iter().zip(bounds) { + // Photoshop writes the sliders within one turn (reds: 315, 345, 15, 45). + d = d.with(k, Value::Integer(f64::from(v).round().rem_euclid(360.0) as i32)); + } + Ok(Some(Value::Descriptor(d.with("H ", hue).with("Strt", sat).with("Lght", light)))) +} + +/// The `filterFXList` item for an `image.adjustments.hueSaturation` smart filter. +pub(super) fn export_item(f: &SmartFilter) -> Result { + let p = &f.params; + let obj = match p { + J::Object(o) => o, + J::Null => &Map::new(), + _ => return Err("Hue/Saturation parameters must be an object".into()), + }; + let known = |k: &str| ["hue", "saturation", "lightness", "colorize"].contains(&k) || RANGES.contains(&k); + if let Some(k) = obj.keys().find(|k| !is_meta(k) && !known(k)) { + return Err(format!("Hue/Saturation setting `{k}` has no PSD equivalent")); + } + let colorize = match p.get("colorize") { + None => false, + Some(v) => v.as_bool().ok_or("Hue/Saturation `colorize` must be true or false")?, + }; + // As the command: Colorize hue is 0..360 and starts at 25 % saturation unless one is given. + let master = if colorize { + (long(number(p, "hue", 0.0)?.rem_euclid(360.0), 0.0, 360.0), setting(p, "saturation", 25.0, 0.0, 100.0)?) + } else { + (setting(p, "hue", 0.0, -180.0, 180.0)?, setting(p, "saturation", 0.0, -100.0, 100.0)?) + }; + let mut adjs = vec![Value::Descriptor( + Descriptor::new("Hst2").with("H ", master.0).with("Strt", master.1).with("Lght", setting(p, "lightness", 0.0, -100.0, 100.0)?), + )]; + for (i, key) in RANGES.iter().enumerate() { + if let Some(o) = p.get(*key) { + adjs.extend(range_entry(i, o)?); + } + } + let mut fltr = Descriptor::new(CLASS) + .with("presetKind", enumv("presetKindType", "presetKindCustom")) + .with("Clrz", Value::Boolean(colorize)) + .with("Adjs", Value::List(adjs)); + fltr.name = UnicodeString::new_nul(FLTR_NAME); + Ok(Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(NAME))) + .with("blendOptions", blend_options(f)) + .with("enab", Value::Boolean(f.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(FILTER_ID))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter}; + use photocraft_color::BlendMode; + use photocraft_psd::descriptor::Id; + + fn hst2(range: Option<(i32, [i32; 4])>, h: i32, s: i32, l: i32) -> Value { + let mut d = Descriptor::new("Hst2"); + if let Some((r, b)) = range { + d = d.with("LclR", Value::Integer(r)); + for (k, v) in BOUNDS.iter().zip(b) { + d = d.with(k, Value::Integer(v)); + } + } + Value::Descriptor(d.with("H ", Value::Integer(h)).with("Strt", Value::Integer(s)).with("Lght", Value::Integer(l))) + } + + fn entry(items: &[(&str, Value)]) -> Value { + Value::Descriptor(items.iter().fold(Descriptor::new("Hst2"), |d, (k, v)| d.with(k, v.clone()))) + } + + fn fltr(colorize: bool, adjs: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("presetKind", enumv("presetKindType", "presetKindCustom")) + .with("Clrz", Value::Boolean(colorize)) + .with("Adjs", Value::List(adjs)); + f.name = UnicodeString::new_nul("Farbton/Sättigung"); + f + } + + /// A `filterFXList` item exactly as Photoshop saved it (German UI: localized names, the + /// foreground colour of the moment in `FrgC`). + fn item(f: Descriptor) -> Descriptor { + let c = |r: f64, g: f64, b: f64| { + Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(r)).with("Grn ", Value::Double(g)).with("Bl ", Value::Double(b))) + }; + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Farbton/Sättigung..."))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", c(167.0, 157.0, 147.0)) + .with("BckC", c(255.0, 255.0, 255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(FILTER_ID)) + } + + /// Master 12 / -20 / 5 and Yellows -16 / 10 / -5 over 15, 45, 75, 105, Colorize off. + fn observed() -> Descriptor { + item(fltr(false, vec![hst2(None, 12, -20, 5), hst2(Some((2, [15, 45, 75, 105])), -16, 10, -5)])) + } + + fn fltr_of(d: &Descriptor) -> &Descriptor { + match d.get("Fltr") { + Some(Value::Descriptor(f)) => f, + _ => panic!("no Fltr"), + } + } + + fn filter(params: J) -> SmartFilter { + SmartFilter { command: COMMAND.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true } + } + + #[test] + fn filter_id_is_hstr() { + assert_eq!(FILTER_ID, 1213428850); + } + + #[test] + fn photoshop_items_map_to_the_command() { + let f = filter_from_item(&observed()); + assert_eq!(f.command, COMMAND); + assert_eq!( + f.params, + json!({"colorize": false, "hue": 12, "saturation": -20, "lightness": 5, + "yellows": {"hue": -16, "saturation": 10, "lightness": -5, "range": [15, 45, 75, 105]}}) + ); + let c = filter_from_item(&item(fltr(true, vec![hst2(None, 200, 40, -10)]))); + assert_eq!(c.command, COMMAND); + assert_eq!(c.params, json!({"colorize": true, "hue": 200, "saturation": 40, "lightness": -10})); + } + + #[test] + fn export_writes_photoshops_layout_and_round_trips() { + let reds = item(fltr(false, vec![hst2(None, 0, 0, 0), hst2(Some((1, [315, 345, 15, 45])), 30, 0, 0)])); + for src in [observed(), item(fltr(true, vec![hst2(None, 200, 40, -10)])), reds] { + let f = filter_from_item(&src); + let out = item_for_filter(&f).unwrap(); + let keys = |d: &Descriptor| d.items.iter().map(|(k, _)| k.clone()).collect::>(); + assert_eq!(keys(&out), keys(&src), "item key order"); + let (a, b) = (fltr_of(&out), fltr_of(&src)); + assert_eq!((&a.class_id, &a.items), (&b.class_id, &b.items), "Fltr as Photoshop writes it"); + assert_eq!(out.get("filterID"), Some(&Value::Integer(FILTER_ID))); + assert_eq!(filter_from_item(&out), f); + } + // Blending and visibility are the model's own. + let mut f = filter_from_item(&observed()); + (f.opacity, f.blend, f.visible) = (0.5, BlendMode::Screen, false); + assert_eq!(filter_from_item(&item_for_filter(&f).unwrap()), f); + } + + #[test] + fn command_params_are_written_like_the_dialog() { + let export = |p: J| filter_from_item(&item_for_filter(&filter(p)).unwrap()).params; + // Partial params: the command's defaults, numbers rounded and clamped. + assert_eq!(export(json!({"hue": 12.4})), json!({"colorize": false, "hue": 12, "saturation": 0, "lightness": 0})); + assert_eq!(export(json!({"hue": -400, "lightness": 101, "layer": 3})), json!({"colorize": false, "hue": -180, "saturation": 0, "lightness": 100})); + assert_eq!(export(J::Null), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0})); + // Colorize starts at 25 % saturation and wraps the hue into 0..360. + assert_eq!(export(json!({"colorize": true, "hue": -30})), json!({"colorize": true, "hue": 330, "saturation": 25, "lightness": 0})); + // The full set (`to_params`): neutral ranges at their default extent are left out, reds + // are written within one turn. + let mut full = json!({"hue": 0, "saturation": 0, "lightness": 0, "colorize": false}); + for (i, k) in RANGES.iter().enumerate() { + let r = HueRange::neutral(i); + full[*k] = json!({"hue": r.hue, "saturation": r.saturation, "lightness": r.lightness, "range": r.bounds}); + } + full["reds"]["saturation"] = json!(-50.0); + full["blues"]["range"] = json!([190, 220, 250, 280]); + let back = export(full); + assert_eq!(back["reds"], json!({"hue": 0, "saturation": -50, "lightness": 0, "range": [315, 345, 15, 45]})); + assert_eq!(back["blues"], json!({"hue": 0, "saturation": 0, "lightness": 0, "range": [190, 220, 250, 280]})); + assert_eq!(RANGES.iter().filter(|k| back.get(**k).is_some()).count(), 2, "{back}"); + } + + #[test] + fn unreadable_items_stay_opaque_and_are_written_back() { + let zero = Value::Integer(0); + let b4 = [0, 30, 60, 90]; + let cases = vec![ + fltr(false, vec![hst2(None, 1, 2, 3)]).with("Lab ", Value::Boolean(true)), + fltr(false, vec![hst2(None, 1, 2, 3), hst2(None, 1, 2, 3)]), + fltr(false, vec![hst2(None, 0, 0, 0), hst2(Some((2, b4)), 1, 0, 0), hst2(Some((2, b4)), 2, 0, 0)]), + fltr(false, vec![hst2(Some((0, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(Some((7, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(Some((-1, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(None, 181, 0, 0)]), + fltr(false, vec![hst2(None, 0, -101, 0)]), + fltr(true, vec![hst2(None, -1, 0, 0)]), + fltr(true, vec![hst2(None, 10, -5, 0)]), + fltr(false, vec![hst2(Some((3, [0, 1, 2, 9999])), 1, 0, 0)]), + fltr(false, vec![Value::Integer(4)]), + fltr(false, vec![entry(&[("H ", zero.clone()), ("Strt", zero.clone()), ("Lght", zero.clone()), ("Clr ", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Double(f64::NAN)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Double(1.5)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Boolean(true)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("LclR", Value::Integer(1)), ("H ", zero.clone()), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, (0..8).map(|i| hst2(Some((i % 6 + 1, b4)), 0, 0, 0)).collect()), + Descriptor::new(CLASS).with("Clrz", Value::Integer(1)).with("Adjs", Value::List(vec![hst2(None, 1, 2, 3)])), + Descriptor::new(CLASS).with("Clrz", Value::Boolean(false)), + Descriptor::new(CLASS).with("Adjs", Value::Integer(0)), + ]; + for (n, f) in cases.into_iter().enumerate() { + let src = item(f); + let sf = filter_from_item(&src); + assert_eq!(sf.command, UNSUPPORTED_FILTER, "case {n}"); + assert_eq!(item_for_filter(&sf).unwrap().to_bytes(), src.to_bytes(), "case {n}"); + } + // No `Fltr` at all. + let mut bare = observed(); + bare.items.retain(|(k, _)| !k.is("Fltr")); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn bad_exports_report_errors() { + for p in [ + json!(3), + json!({"hue": "red"}), + json!({"colorize": "yes"}), + json!({"preset": "sepia"}), + json!({"reds": 4}), + json!({"reds": {"hue": "x"}}), + json!({"reds": {"range": [1, 2, 3]}}), + json!({"reds": {"range": [1, 2, 3, "x"]}}), + json!({"reds": {"width": 2}}), + ] { + assert!(item_for_filter(&filter(p.clone())).is_err(), "{p}"); + } + // Huge numbers clamp rather than overflow. + let out = filter_from_item(&item_for_filter(&filter(json!({"hue": f64::MAX, "colorize": true, "saturation": -1e300}))).unwrap()); + assert_eq!(out.command, COMMAND); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..f45e9b486e 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -20,7 +20,8 @@ //! listed, can be hidden, reordered or deleted, and is written back unchanged. //! //! Camera Raw and Displace keep their whole item as a template next to the editable settings -//! (Displace's carries the embedded displacement map, see `displace_map.rs`). +//! (Displace's carries the embedded displacement map, see `displace_map.rs`). Hue/Saturation +//! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{LayerMask, SmartFilter}; @@ -39,6 +40,8 @@ use crate::blocks::{enum_of, get_desc, num}; mod camera_raw; #[path = "displace_map.rs"] mod displace; +#[path = "hue_sat_filter_map.rs"] +mod hue_sat; pub use displace::embedded_map as displace_embedded_map; @@ -469,6 +472,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), c => c.and_then(|c| known_params(c, fltr)), }; @@ -624,6 +628,9 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if f.command == GALLERY_COMMAND { return gallery_item(f); } + if f.command == hue_sat::COMMAND { + return hue_sat::export_item(f); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; From f22d08da8c55dee9885f83e6ace2756fd358045d Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:32:47 +0800 Subject: [PATCH 0292/1452] style: apply rustfmt to upstream smart-filter regression --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 73459a662c39f3730ac92ffaa6aa66346eb2a4de Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 21:44:21 -0400 Subject: [PATCH 0293/1452] Artboards: layers outside the boards stay visible over the pasteboard (#5712) Adding an artboard next to earlier work made that work vanish: the canvas painted the opaque pasteboard over the whole image outside the boards, hiding layers that are not in an artboard. Photoshop shows them, so the pasteboard now leaves out where visible loose layers draw (their composite bounds). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/artboard_ui.rs | 79 +++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 1ff452a829..ef4e080f6a 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -11,9 +11,20 @@ use crate::canvas::ViewXform; use crate::theme::Tokens; /// Screen rects covering the document image minus every artboard (vertical slabs between the -/// boards' x edges, split around the boards they cross). +/// boards' x edges, split around the boards they cross), and minus where visible layers outside +/// the boards draw: Photoshop shows those over the pasteboard, so a board added next to earlier +/// work must not paint over it (#5712). pub fn pasteboard_rects(xf: &ViewXform, doc: &Document) -> Vec { let canvas = doc.bounds(); + let mut loose = Vec::new(); + for l in doc.layers.iter().filter(|l| l.visible && !is_artboard(l)) { + match photocraft_compose::composite_bounds(l, canvas) { + // It may draw anywhere: no pasteboard shows. + None => return Vec::new(), + Some(b) if !b.is_empty() => loose.push(b), + Some(_) => {} + } + } let boards: Vec = doc.artboards().iter().map(|b| b.2.rect.intersect(&canvas)).filter(|r| !r.is_empty()).collect(); let mut xs: Vec = vec![canvas.x0, canvas.x1]; for b in &boards { @@ -29,17 +40,45 @@ pub fn pasteboard_rects(xf: &ViewXform, doc: &Document) -> Vec { let mut y = canvas.y0; for (a, b) in spans { if a > y { - out.push(xf.doc_rect(photocraft_geom::Rect::new(x0, y, x1, a))); + out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, a), &loose).into_iter().map(|r| xf.doc_rect(r))); } y = y.max(b); } if y < canvas.y1 { - out.push(xf.doc_rect(photocraft_geom::Rect::new(x0, y, x1, canvas.y1))); + out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, canvas.y1), &loose).into_iter().map(|r| xf.doc_rect(r))); } } out } +fn is_artboard(l: &Layer) -> bool { + matches!(&l.content, photocraft_doc::LayerContent::Group(g) if g.artboard.is_some()) +} + +/// `r` minus every hole, as disjoint rects (each hole splits a piece into at most four). +fn subtract(r: photocraft_geom::Rect, holes: &[photocraft_geom::Rect]) -> Vec { + let mut pieces = vec![r]; + for h in holes { + let mut next = Vec::with_capacity(pieces.len()); + for p in pieces { + let i = p.intersect(h); + if i.is_empty() { + next.push(p); + continue; + } + let parts = [ + photocraft_geom::Rect::new(p.x0, p.y0, p.x1, i.y0), + photocraft_geom::Rect::new(p.x0, i.y1, p.x1, p.y1), + photocraft_geom::Rect::new(p.x0, i.y0, i.x0, i.y1), + photocraft_geom::Rect::new(i.x1, i.y0, p.x1, i.y1), + ]; + next.extend(parts.into_iter().filter(|q| !q.is_empty())); + } + pieces = next; + } + pieces +} + /// Board outlines and names (the active board's name is highlighted, as in Photoshop). pub fn draw_frames(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(st) = app.session.active() else { return }; @@ -169,7 +208,7 @@ mod tests { fn app() -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 50, "background": "transparent"})).unwrap(); app.sync_views(); (app, egui::Context::default()) } @@ -195,6 +234,38 @@ mod tests { assert!(rects.iter().all(|r| !r.intersects(Rect::from_min_max(Pos2::new(0.5, 0.5), Pos2::new(39.5, 49.5))))); } + #[test] + fn layers_outside_the_boards_show_over_the_pasteboard() { + // #5712: a board drawn left of earlier work must not cover it with pasteboard. + let (mut app, _) = app(); + app.run("layer.new.artboard", json!({"rect": [0, 0, 40, 50]})).unwrap(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + let mut px = photocraft_doc::Layer::raster("Work", doc.pixel_format()); + px.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(60, 10, 80, 30), &[1.0, 0.0, 0.0, 1.0]); + doc.layers.insert(0, px); + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(100.0, 50.0)), + zoom: 1.0, + center: [50.0, 25.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let rects = pasteboard_rects(&xf, &doc); + let area: f32 = rects.iter().map(|r| r.area()).sum(); + // 100×50 canvas − the 40×50 board − the 20×20 loose pixels. + assert!((area - (5000.0 - 2000.0 - 400.0)).abs() < 0.5, "{rects:?}"); + assert!(rects.iter().all(|r| !r.intersects(Rect::from_min_max(Pos2::new(60.5, 10.5), Pos2::new(79.5, 29.5))))); + } + + #[test] + fn subtract_splits_around_holes() { + let r = photocraft_geom::Rect::new(0, 0, 10, 10); + let parts = subtract(r, &[photocraft_geom::Rect::new(2, 2, 4, 4), photocraft_geom::Rect::new(20, 20, 30, 30)]); + assert_eq!(parts.iter().map(|p| i64::from(p.width()) * i64::from(p.height())).sum::(), 96); + assert!(subtract(r, &[photocraft_geom::Rect::new(-5, -5, 50, 50)]).is_empty()); + } + #[test] fn fit_artboard_and_menu_items() { let (mut app, ctx) = app(); From 7bc556ec02aae434f0a83ff2efc16d48c3311e24 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 21:54:09 -0400 Subject: [PATCH 0294/1452] Web: a requested download no longer marks the document saved (#4962) The web writer only triggers an anchor download, which the user can cancel and which reports nothing back, yet Save treated it as a confirmed write: the document was marked clean and the leave-page guard disarmed. Services gains `write_unconfirmed` (set by the web shell); a save through such a writer names the document but keeps its changes unsaved. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 2 ++ crates/ui-egui/src/lib.rs | 14 +++++++++++++- crates/ui-egui/src/save_identity_tests.rs | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..7084396cbe 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -379,6 +379,8 @@ fn services(inbox: Inbox) -> Services { } })), write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), + // A download may be cancelled and never reports back: it doesn't save the document. + write_unconfirmed: true, encode_png: Some(Box::new(|w, h, rgba| { let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..4802fbe91a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -354,6 +354,13 @@ pub type WriteFn = Box Result<(), String>>; /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. pub type SaveFileFn = std::sync::Arc Result, String> + Send + Sync>; +/// The revision a document counts as saved at after `written` went to its writer: that one when +/// the writer confirms the bytes were stored, else still `previous`, so the document stays +/// unsaved. A browser download is only a request the user can cancel (#4962). +pub(crate) fn saved_revision_after_write(confirmed: bool, written: u64, previous: u64) -> u64 { + if confirmed { written } else { previous } +} + /// Who a finished save serves. #[derive(Clone, Copy)] pub(crate) enum SaveKind { @@ -446,6 +453,10 @@ pub struct Services { pub file_dialog: Option, /// Write bytes to a path (native) or trigger a download (web). pub write: Option, + /// `write` only asks for the bytes to be kept (a browser download, which the user can + /// cancel and which reports nothing back), so a save through it names the document but + /// leaves its changes unsaved and the leave-page guard armed (#4962). + pub write_unconfirmed: bool, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, @@ -1592,11 +1603,12 @@ impl PhotocraftApp { return jobs_ui::start_save(self, path, settings.clone(), copy, save, false); } let st = self.session.active().ok_or("no document")?; - let (doc, revision) = (st.doc.id, st.revision); + let (doc, written, previous) = (st.doc.id, st.revision, st.saved_revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; let write = self.services.write.as_mut().ok_or("no writer configured")?; write(&path, &bytes)?; + let revision = saved_revision_after_write(!self.services.write_unconfirmed, written, previous); self.saved(doc, revision, &path, &warnings, copy); Ok(Some((path, warnings))) } diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c947afc9b6..3396dfcdd5 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -254,3 +254,21 @@ fn save_settings_start_at_the_opened_files_encoding() { app.open_bytes("art.webp", &lossless).unwrap(); assert!(app.save_settings().webp_lossless); } + +/// A browser download is only requested: the document takes the name but keeps its unsaved +/// changes, so the leave-page guard stays armed (#4962). A confirmed write still saves it. +#[test] +fn unconfirmed_download_names_the_document_but_keeps_it_unsaved() { + assert_eq!(crate::saved_revision_after_write(true, 7, 3), 7); + assert_eq!(crate::saved_revision_after_write(false, 7, 3), 3); + let mut app = app(); + app.services.write_unconfirmed = true; + let (_, _, revision, saved) = identity(&app); + app.save_as(Some("download.psd".into())).unwrap(); + assert_eq!(identity(&app), ("download.psd".into(), Some("download.psd".into()), revision, saved)); + assert!(app.session.active().unwrap().is_dirty()); + assert!(app.has_unsaved_work()); + app.services.write_unconfirmed = false; + app.save_as(Some("download.psd".into())).unwrap(); + assert!(!app.has_unsaved_work()); +} From 76f29f67d55f3554c25d47125145cefeb0e9a030 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:16:03 -0400 Subject: [PATCH 0295/1452] rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From c972acf98fb455c28eac2971ec532a19fe7a1352 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:16:12 -0400 Subject: [PATCH 0296/1452] rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- crates/ui-egui/src/menu_nav.rs | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 879bd4397a..c9aedd77d7 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -339,9 +339,7 @@ impl Nav { // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its // cursor and Space types a space. let typing = ctx.text_edit_focused(); - let press = |k: Key| { - !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) - }; + let press = |k: Key| !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { let n = tops.len(); From 5f8d92f9f02ef896d9c49381eb368a7a435be5f0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:27:09 -0400 Subject: [PATCH 0297/1452] rustfmt; merge main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- crates/engine/src/print_cmds.rs | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 69f4505535..88ed6c7883 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -447,7 +447,9 @@ fn require_windows_pdf() -> Result<()> { if windows_pdf_installed(std::path::Path::new(&root)) { Ok(()) } else { - Err(other("printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.")) + Err(other( + "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.", + )) } } From b6be26a490ed9aa773e75ecca4aae621af4715bd Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:28:54 -0400 Subject: [PATCH 0298/1452] rustfmt; merge main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 5c0e40784a1056a6dfcda902e44c3e0242bf0464 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:29:42 -0400 Subject: [PATCH 0299/1452] rustfmt; merge main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 6822125f7916329f06adedcbc5c501056f21cb9b Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Thu, 15 Oct 2026 22:34:00 -0400 Subject: [PATCH 0300/1452] keep the rendering of smart filters that cannot be reproduced --- crates/engine/src/smart_cmds.rs | 22 ++++- .../engine/tests/unsupported_smart_filters.rs | 85 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/unsupported_smart_filters.rs diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..1c1f2a787e 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -453,11 +453,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)? { @@ -805,24 +816,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 { 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"); +} From 2b5e857d924ff992f57588d6d05775ea66be6e32 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Thu, 15 Oct 2026 22:46:05 -0400 Subject: [PATCH 0301/1452] convert the background and match mask depth where they were missed --- crates/engine/src/commands.rs | 2 +- crates/engine/src/cutout_cmds.rs | 3 +- crates/engine/src/extra_cmds.rs | 1 + crates/engine/src/frame_cmds.rs | 7 +- crates/engine/src/layer_style.rs | 4 + crates/engine/src/vector_cmds.rs | 3 +- .../engine/tests/background_and_mask_depth.rs | 73 +++++++++++++++++++ 7 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/background_and_mask_depth.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..0416913678 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -339,7 +339,7 @@ pub(crate) fn selection_mask(doc: &Document) -> Result> { /// New pixel masks use the document's depth, as PSD saves/imports do. Otherwise a later soft /// brush stroke quantizes differently depending on whether the document reopened as PSD or /// native. Converting a selection keeps its coverage and does not alter the selection itself. -fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { +pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { let format = mask.surface.format().with_sample(depth); if mask.surface.format() != format { mask.surface = mask.surface.try_convert(format)?; diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 2f6e2b70b5..e7368dcc9a 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -66,7 +66,8 @@ fn run(s: &mut Session, p: &Value) -> Result { } } crate::extra_cmds::background_to_layer_for_mask(doc, id); - doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface: matting::region_surface(®ion), ..LayerMask::reveal_all() }); + let mask = crate::commands::mask_at_depth(LayerMask { surface: matting::region_surface(®ion), ..LayerMask::reveal_all() }, doc.depth)?; + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); doc.selection = None; let b = region.bbox; Ok([b.x0, b.y0, b.width() as i32, b.height() as i32]) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index c291862167..2ff3b48ed9 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -928,6 +928,7 @@ pub fn specs() -> Vec { gray }; for id in ids { + background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); // Pass Through is a group's mode: a group's style pastes onto other layers as diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index 7a401594fd..2ea953609f 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -72,8 +72,8 @@ fn frame_group(s: &mut Session, grouped: &Value) -> Result { crate::layer_multi_cmds::layer_bounds(d.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?).filter(|r| !r.is_empty()).unwrap_or_else(|| d.doc.bounds()); s.edit("Frame from Layers", |doc, _| { - let g = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; - g.mask = Some(clip_mask(rect)); + let mask = crate::commands::mask_at_depth(clip_mask(rect), doc.depth)?; + doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?.mask = Some(mask); Ok(()) })?; Ok(json!({"layer": gid.0, "frame": [rect.x0, rect.y0, rect.width(), rect.height()]})) @@ -133,7 +133,8 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { }; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let gid = s.edit("New Frame", |doc, active| { - let mask = if ellipse { ellipse_mask(rect, doc.bounds()) } else { clip_mask(rect.intersect(&doc.bounds())) }; + let mask = + crate::commands::mask_at_depth(if ellipse { ellipse_mask(rect, doc.bounds()) } else { clip_mask(rect.intersect(&doc.bounds())) }, doc.depth)?; let name = name.unwrap_or_else(|| doc.next_layer_name("Frame")); let gid = match content { Some(id) => { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..6aea9d6345 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -894,6 +894,9 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { crate::pattern_cmds::ensure_in_doc(doc, pat); } let mode = doc.mode; + if !list.is_empty() { + crate::extra_cmds::background_to_layer(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; // The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye) // survives the edit: the dialog models per-effect state, not the switch (#3648). A layer @@ -916,6 +919,7 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { } // Colours in the document's mode, as Image › Mode keeps them. let fx = fx.in_mode(doc.mode); + crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; // Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh // effects list already has the default `enabled: true`, so a first effect still shows. diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..6b0bc6495d 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1405,7 +1405,8 @@ fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result if locked { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } - l.vector_mask = Some(VectorMask::new(path)); + crate::extra_cmds::background_to_layer(doc, id); + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.vector_mask = Some(VectorMask::new(path)); Ok(()) })?; vector_mask_info(s, id) diff --git a/crates/engine/tests/background_and_mask_depth.rs b/crates/engine/tests/background_and_mask_depth.rs new file mode 100644 index 0000000000..2011205e62 --- /dev/null +++ b/crates/engine/tests/background_and_mask_depth.rs @@ -0,0 +1,73 @@ +use photocraft_color::SampleType; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +const STYLES: [&str; 10] = + ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "bevelEmboss", "satin"]; +const VECTOR_MASKS: [&str; 3] = ["layer.vectorMask.add", "layer.vectorMask.revealAll", "layer.vectorMask.hideAll"]; + +fn background_only() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s +} + +fn has_background(s: &Session) -> bool { + s.active().unwrap().doc.background().is_some() +} + +fn converts_and_undoes(cmd: &str, params: Value) { + let mut s = background_only(); + s.execute(cmd, params).unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert!(!has_background(&s), "{cmd} left a Background carrying the change"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "{cmd}: one undo restores the Background"); +} + +#[test] +fn layer_styles_and_vector_masks_turn_the_background_into_a_layer() { + for kind in STYLES { + converts_and_undoes(&format!("layer.layerStyle.{kind}"), json!({})); + } + for cmd in VECTOR_MASKS { + converts_and_undoes(cmd, json!({})); + } + converts_and_undoes("layer.layerStyle.replace", json!({"effects": [{"kind": "stroke"}]})); +} + +#[test] +fn clearing_a_style_keeps_the_background() { + let mut s = background_only(); + s.execute("layer.layerStyle.replace", json!({"effects": []})).unwrap(); + assert!(has_background(&s)); +} + +fn mask_sample(s: &Session) -> SampleType { + let st = s.active().unwrap(); + st.doc.walk().into_iter().find_map(|(_, _, l)| l.mask.as_ref().map(|m| m.surface.format().sample)).expect("a layer mask") +} + +fn painted(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [30, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn frame_and_remove_background_masks_match_the_document_depth() { + for depth in [16, 32] { + let want = if depth == 16 { SampleType::U16 } else { SampleType::F32 }; + for (cmd, params) in [ + ("layer.new.frameFromLayers", json!({})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12, "shape": "ellipse"})), + ("layer.removeBackground", json!({})), + ] { + let mut s = painted(depth); + s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); + assert_eq!(mask_sample(&s), want, "{cmd} {params} in a {depth}-bit document"); + } + } +} From a80ece00aa778917ac422d5664301fdabe7a2665 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 23:01:51 -0400 Subject: [PATCH 0302/1452] Merge origin/main; cargo fmt (new_doc_ui.rs, smart_filter_authorization.rs) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- crates/ui-egui/src/new_doc_ui.rs | 10 ++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index fd43bc1535..761b1fd85c 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -830,10 +830,12 @@ mod tests { InstalledProfile { path: "press.icc".into(), description: "Press".into(), class: ProfileClass::Output, color_space: ColorSpace::Cmyk }, InstalledProfile { path: "panel.icc".into(), description: "Panel".into(), class: ProfileClass::Display, color_space: ColorSpace::Rgb }, ]; - let values = |mode: &str| profile_options(color_mode_of(mode).map(photocraft_engine::color_cmds::mode_space), "sRGB", &installed) - .into_iter() - .map(|(v, _)| v) - .collect::>(); + let values = |mode: &str| { + profile_options(color_mode_of(mode).map(photocraft_engine::color_cmds::mode_space), "sRGB", &installed) + .into_iter() + .map(|(v, _)| v) + .collect::>() + }; let rgb = values("rgb"); assert_eq!(rgb[0], "working"); assert!(rgb.contains(&Builtin::AdobeRgbCompat.id().to_string()) && rgb.contains(&"panel.icc".to_string())); From 708d393988f7108ea5d68b9c08e4c7be2b0e904d Mon Sep 17 00:00:00 2001 From: LochLogic <252105752+LochLogic@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:24:07 -0600 Subject: [PATCH 0303/1452] Tests: every GPU test holds gpu_lock (STATUS_ACCESS_VIOLATION on Windows) testkit::gpu_lock documents that WARP faults in d3d10warp.dll when two GPU harnesses render in parallel in one test process, and that every test that builds a GPU harness or device holds the lock. Fifteen tests didn't, so on Windows checker_canvas and marquee_autoscroll (and the lib test binary's GPU tests) could take their whole test binary down with STATUS_ACCESS_VIOLATION. They now take the lock on their first line, as the others do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/clipping_indicator_tests.rs | 1 + crates/ui-egui/src/gpu_canvas_mip_tests.rs | 1 + crates/ui-egui/src/navigator_gpu_tests.rs | 1 + crates/ui-egui/src/panel_docking_tests.rs | 3 +++ crates/ui-egui/src/type_tool_tests.rs | 1 + crates/ui-egui/tests/adjust_preview_gpu.rs | 2 ++ crates/ui-egui/tests/checker_canvas.rs | 1 + crates/ui-egui/tests/layout_perf.rs | 1 + crates/ui-egui/tests/marquee_autoscroll.rs | 3 +++ crates/ui-egui/tests/pattern_preview_canvas.rs | 1 + 10 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/clipping_indicator_tests.rs b/crates/ui-egui/src/clipping_indicator_tests.rs index 9bfb600b6f..7395a533de 100644 --- a/crates/ui-egui/src/clipping_indicator_tests.rs +++ b/crates/ui-egui/src/clipping_indicator_tests.rs @@ -83,6 +83,7 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() { #[test] #[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"] fn write_clipping_indicator_preview() { + let _gpu = photocraft_testkit::gpu_lock(); let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/clipping"); std::fs::create_dir_all(&output).unwrap(); for theme in crate::theme::ThemeKind::ALL { diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index a1c3e8ee14..82e911bae9 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec> { #[test] fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { + let _gpu = photocraft_testkit::gpu_lock(); let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default()))); let Ok(rs) = rs else { eprintln!("skipping mip readback: no GPU adapter"); diff --git a/crates/ui-egui/src/navigator_gpu_tests.rs b/crates/ui-egui/src/navigator_gpu_tests.rs index 51326597f4..d2342d17ac 100644 --- a/crates/ui-egui/src/navigator_gpu_tests.rs +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -47,6 +47,7 @@ fn near(a: [u8; 4], b: [u8; 3]) -> bool { #[test] fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(8); if !h.state().perf.gpu { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index c6feeff633..a4ae378eda 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -175,6 +175,7 @@ fn shared_layout_keeps_panel_identity_across_themes_and_respects_workspace_lock( /// Explicit offscreen evidence; this creates no native windows. #[test] fn capture_custom_panel_docking_visual_fixtures() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for theme in crate::theme::ThemeKind::ALL { @@ -211,6 +212,7 @@ fn capture_custom_panel_docking_visual_fixtures() { #[test] fn floating_group_titles_are_opaque_in_studio_themes() { + let _gpu = photocraft_testkit::gpu_lock(); // Studio's tab strip is transparent over the dock's card; a floating group must not show the canvas (#4509). let mut app = app(); app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60,"width":300,"height":300})).unwrap(); @@ -346,6 +348,7 @@ fn splitting_a_short_column_keeps_the_layers_footer_visible_and_usable() { #[test] fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() { + let _gpu = photocraft_testkit::gpu_lock(); let mut app = app(); app.ui.theme = crate::theme::ThemeKind::Studio; app.run("file.new", json!({"width":480,"height":360})).unwrap(); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c7b54c8871..7a23c9ccc3 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1130,6 +1130,7 @@ fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() { #[test] #[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"] fn temporary_type_transform_visual_evidence() { + let _gpu = photocraft_testkit::gpu_lock(); let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091"); std::fs::create_dir_all(&out).unwrap(); for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { diff --git a/crates/ui-egui/tests/adjust_preview_gpu.rs b/crates/ui-egui/tests/adjust_preview_gpu.rs index 82d47596fc..c7104f72ee 100644 --- a/crates/ui-egui/tests/adjust_preview_gpu.rs +++ b/crates/ui-egui/tests/adjust_preview_gpu.rs @@ -126,6 +126,7 @@ fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]], skip: usize) -> f32 { #[test] fn preview_layer_composites_on_the_gpu_like_the_command() { + let _gpu = photocraft_testkit::gpu_lock(); let Some((device, queue, mut comp)) = gpu() else { return }; // GPU vs CPU of the same document (as the compositor parity tests), and GPU preview vs the // command's CPU result (adds the command's rounding to the layer depth). @@ -170,6 +171,7 @@ fn preview_layer_composites_on_the_gpu_like_the_command() { /// The zoomed-out proxy preview (own document and layer ids) composites on the GPU like the CPU. #[test] fn proxy_preview_composites_on_the_gpu() { + let _gpu = photocraft_testkit::gpu_lock(); use photocraft_ui_egui::adjust_preview::{base_document, proxy_base, proxy_with_settings}; let Some((device, queue, mut comp)) = gpu() else { return }; for selection in [false, true] { diff --git a/crates/ui-egui/tests/checker_canvas.rs b/crates/ui-egui/tests/checker_canvas.rs index e626b42128..3c5e061afd 100644 --- a/crates/ui-egui/tests/checker_canvas.rs +++ b/crates/ui-egui/tests/checker_canvas.rs @@ -278,6 +278,7 @@ fn grid_line_contrast_over_grey(h: &mut Harness, grey: u8) -> i32 { #[test] fn pixel_grid_does_not_flip_colour_across_mid_tones() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(4); // 0x87 and 0x90 sit either side of luma 0.55, where the line used to switch from a full white diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/layout_perf.rs index fb17833e18..1afb0f36cf 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/layout_perf.rs @@ -220,6 +220,7 @@ fn the_move_preview_is_what_the_move_commits() { #[test] fn layout_document_composites_on_the_gpu_like_the_cpu() { + let _gpu = photocraft_testkit::gpu_lock(); use eframe::wgpu; fn block_on(f: F) -> F::Output { let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/marquee_autoscroll.rs index 2fc5772604..ab5b6f6bb5 100644 --- a/crates/ui-egui/tests/marquee_autoscroll.rs +++ b/crates/ui-egui/tests/marquee_autoscroll.rs @@ -82,6 +82,7 @@ fn selection(h: &Harness) -> Option<[i32; 4]> { #[test] fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; setup(&mut h, "rectMarquee"); let start = xf(&h); @@ -121,6 +122,7 @@ fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { #[test] fn a_drag_inside_the_canvas_does_not_scroll() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; setup(&mut h, "rectMarquee"); let start = xf(&h); @@ -139,6 +141,7 @@ fn a_drag_inside_the_canvas_does_not_scroll() { #[test] fn rotated_marquee_scrolls_on_the_axis_that_is_visibly_clipped() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness_with_size(egui::vec2(1200.0, 640.0)) else { return }; setup(&mut h, "rectMarquee"); let rect = xf(&h).rect; diff --git a/crates/ui-egui/tests/pattern_preview_canvas.rs b/crates/ui-egui/tests/pattern_preview_canvas.rs index 627bbddaa2..aef11c00dd 100644 --- a/crates/ui-egui/tests/pattern_preview_canvas.rs +++ b/crates/ui-egui/tests/pattern_preview_canvas.rs @@ -52,6 +52,7 @@ fn near(a: [u8; 3], b: [u8; 3]) -> bool { #[test] fn pattern_preview_repeats_the_document_on_gpu_and_cpu_canvases() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; let app = h.state_mut(); app.run("file.new", json!({"width": 200, "height": 100, "mode": "rgb", "depth": 8})).expect("new"); From 1d21c921f8bed94876f1e9289b0eb9566e5c5107 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:32:47 +0800 Subject: [PATCH 0304/1452] style: apply rustfmt to upstream smart-filter regression --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 801e229d762e2ae71c3760b999c3373492675798 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Fri, 16 Oct 2026 06:47:56 +0300 Subject: [PATCH 0305/1452] style(text): rustfmt antialias fringe_mass chain for #5902 (PR #5907) --- crates/text/src/tests.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c6cc6b2ce4..d9ad588d1a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1940,12 +1940,7 @@ fn antialias_modes_change_edge_fringe() { let layer = TextLayer { antialias: mode, ..point("O", 48.0) }; let (_, r) = e.render(&layer, 72.0, PixelFormat::RGBA8); let n = r.surface.channels(); - r.surface - .read_region(r.rect) - .chunks_exact(n) - .map(|p| f64::from(p[n - 1])) - .filter(|&a| a > 0.02 && a < 0.98) - .sum::() + r.surface.read_region(r.rect).chunks_exact(n).map(|p| f64::from(p[n - 1])).filter(|&a| a > 0.02 && a < 0.98).sum::() }; let none = fringe_mass(AntiAlias::None); From cddb6fa21cd12a322c4166c04c3c003aab5ce87d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:54:15 -0400 Subject: [PATCH 0306/1452] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a8..01cb9cd663 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 21b8d832afd5f1825189d06ba42c1c9600b985f5 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 09:39:31 +0530 Subject: [PATCH 0307/1452] Hand tool: hold Ctrl/Cmd to zoom in, Alt to zoom out (#6434) With the Hand tool selected, Cmd (Ctrl off the Mac) held is a temporary Zoom In and Alt (or Cmd+Alt) a temporary Zoom Out, as in Photoshop: a click steps the zoom, a drag scrubs (or draws a zoom box with Scrubby Zoom off), the magnifier cursor shows, and letting go gives the Hand back. Latched Window > Modifier Keys count too. Not while Free Transform is open (Cmd is its distort key), and a modifier pressed during a Hand press, release included, doesn't turn it into a zoom: only a press made with the modifier held zooms. docs/ui-parity.md: Navigation row added; Scroll All Windows and Bird's Eye View recorded as missing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/hold_keys.rs | 27 +++++- crates/ui-egui/src/hold_keys/tests.rs | 133 +++++++++++++++++++++++++- docs/ui-parity.md | 5 +- 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 3f2845cae8..e492515e62 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -16,6 +16,8 @@ //! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the //! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path //! Selection, shape, Pen and Type tools keep ⌘ for themselves. +//! - With the Hand tool selected, ⌘ (Ctrl off the Mac) is Zoom In while held and ⌥ (with or +//! without ⌘) Zoom Out ([`hand_zoom`], #6434). //! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases, //! with that tool's own brush ([`erase_held`], #3439). //! @@ -118,6 +120,27 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool { i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none() } +/// The Hand tool's own modifiers (#6434): ⌘ (Ctrl off the Mac) held is Zoom In, ⌥ (with or +/// without ⌘) Zoom Out, as in Photoshop; the Modifier Keys panel's latched keys count. Not while +/// Free Transform is open (its box keeps ⌘ for the distort handles), and a modifier pressed during +/// a Hand press doesn't turn it into a zoom, release included: only a press made with it held +/// zooms (that one is latched by [`for_frame`] until the button comes up). +fn hand_zoom(app: &PhotocraftApp, i: &InputState) -> Option { + let p = &i.pointer; + let hand_press = (p.primary_down() || p.primary_released()) && !p.primary_pressed(); + if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || hand_press { + return None; + } + let m = crate::workspace_ui::sticky_mods(app, i.modifiers); + if m.alt { + Some(Temporary::ZoomOut) + } else if m.command { + Some(Temporary::ZoomIn) + } else { + None + } +} + /// Is `id` a held temporary tool rather than a command? pub fn is_temporary(id: &str) -> bool { photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id) @@ -160,6 +183,7 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None; let mut cmd_move = false; let mut alt = false; + let mut hand = None; ctx.input(|i| { for t in Temporary::BOUND { if let Some(sc) = binding(app, t) @@ -171,12 +195,13 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option } cmd_move = cmd_move_held(app, i); alt = i.modifiers.alt; + hand = hand_zoom(app, i); }); // ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the // tie with ⌥Space), unless ⌥ is part of the Zoom In key itself. let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t }); // A bound key (⌘Space is Zoom In) wins over ⌘ alone. - held.or(cmd_move.then_some(Temporary::Move)) + held.or(hand).or(cmd_move.then_some(Temporary::Move)) } /// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`). diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index cfe89d4642..e88ec917a3 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -643,7 +643,9 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { let mut h = harness(tool); h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND))); h.run_steps(1); - assert_eq!(held_tool(h.state(), &h.ctx), None, "{tool:?}"); + // The Hand's own ⌘ is Zoom In (#6434). + let want = (tool == Tool::Hand).then_some(Temporary::ZoomIn); + assert_eq!(held_tool(h.state(), &h.ctx), want, "{tool:?}"); } for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::CloneStamp, Tool::Eyedropper, Tool::MagneticLasso, Tool::QuickSelection] { assert!(cmd_moves(tool), "{tool:?}"); @@ -796,3 +798,132 @@ fn a_held_key_keeps_a_pending_crop_and_another_tools_key_commits_it() { assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 150)); assert_eq!(h.state().ui.crop_rect, None); } + +/// #6434: with the Hand tool selected, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ (with or +/// without ⌘) Zoom Out, as in Photoshop; letting go gives the Hand back. +#[test] +fn cmd_and_alt_with_the_hand_tool_zoom_in_and_out() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + + hold_mods(&mut h, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + let z2 = h.state().ui.views[0].zoom; + assert!(z2 < z1, "{z1} -> {z2}"); + hold_mods(&mut h, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND | Modifiers::ALT); + assert!(h.state().ui.views[0].zoom < z2); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + assert_eq!(h.state().ui.tool, Tool::Hand, "the Hand is still the tool"); +} + +/// A ⌘-drag with the Hand is the Zoom tool's scrubby zoom, not a pan. +#[test] +fn cmd_drag_with_the_hand_tool_is_a_scrubby_zoom() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + drag_with(&mut h, Modifiers::COMMAND, 200.0, 150.0, 300.0, 150.0); + assert!(h.state().ui.views[0].zoom > z0 * 1.5, "{z0} -> {}", h.state().ui.views[0].zoom); + assert_eq!(h.state().ui.tool, Tool::Hand); +} + +/// ⌘ or ⌥ pressed during a Hand pan doesn't turn the drag into a zoom: the pan goes on. +#[test] +fn modifiers_pressed_during_a_hand_pan_keep_panning() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + press_at(&mut h, 200.0, 150.0); + hold_mods(&mut h, m); + assert_eq!(held_tool(h.state(), &h.ctx), None, "{m:?}"); + let p = screen(&h, 200.0, 150.0) + vec2(60.0, 40.0); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + button(&mut h, p, false, m); + hold_mods(&mut h, Modifiers::NONE); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, z0, "{m:?}: no zoom"); + assert!(v.center[0] < c0[0] - 30.0 && v.center[1] < c0[1] - 20.0, "{m:?}: panned {c0:?} -> {:?}", v.center); + } +} + +/// Free Transform keeps ⌘ (its distort handles) while the Hand is selected. +#[test] +fn cmd_with_the_hand_tool_is_not_a_zoom_during_free_transform() { + let mut h = harness(Tool::Hand); + with_layer(&mut h); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.transform.is_some()); + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), None); +} + +/// The Modifier Keys panel's latched ⌥ and ⌘ count, for desktops that take Alt+click. +#[test] +fn latched_modifier_keys_zoom_with_the_hand_tool() { + let mut h = harness(Tool::Hand); + h.state_mut().ui.shell.sticky_command = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + h.state_mut().ui.shell.sticky_alt = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + h.state_mut().ui.shell = Default::default(); + h.state_mut().ui.tool = Tool::Brush; + h.state_mut().ui.shell.sticky_alt = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), None, "only the Hand's ⌥ zooms"); +} + +/// A Hand press made without a modifier stays the Hand through its release: ⌘ or ⌥ pressed +/// before letting go (without moving past the drag threshold) doesn't zoom on the release. +#[test] +fn modifiers_pressed_before_a_hand_click_is_released_do_not_zoom() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + button(&mut h, p, true, Modifiers::NONE); + hold_mods(&mut h, m); + button(&mut h, p, false, m); + h.run_steps(1); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (z0, c0), "{m:?}: the release didn't zoom"); + // The modifier still zooms the next click. + click(&mut h, 200.0, 150.0, m); + assert_ne!(h.state().ui.views[0].zoom, z0, "{m:?}"); + hold_mods(&mut h, Modifiers::NONE); + } +} + +/// A click whose press and release arrive in one frame, with ⌘ held, still zooms. +#[test] +fn a_one_frame_cmd_click_with_the_hand_tool_zooms() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + hold_mods(&mut h, Modifiers::COMMAND); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let m = platform(Modifiers::COMMAND); + h.input_mut().events.extend([ + Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }, + Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: m }, + ]); + h.run_steps(2); + assert!(h.state().ui.views[0].zoom > z0); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..4988aace53 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; 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) · **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, @@ -167,6 +167,8 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | +| Hand tool: ⌘ held = Zoom In, ⌥ (or ⌘⌥) held = Zoom Out; click steps, drag scrubs | done | `hold_keys.rs` `hand_zoom` (#6434); not during Free Transform or once a pan has started | +| Hand tool: ⇧-drag / Scroll All Windows pans every open document; hold H for Bird's Eye View | missing | | | Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | @@ -227,6 +229,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From ef46343f6b8aab9fd023e97f2a19d38636e81d49 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 06:01:51 +0200 Subject: [PATCH 0308/1452] Type options: drag the "T" icon to change the font size, as in Photoshop (#6437) --- crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/type_tool.rs | 6 +- crates/ui-egui/src/type_tool_tests.rs | 81 +++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 449f741e21..9c7b28b6fa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1081,6 +1081,7 @@ # Paragraph and character panels Font Size: Кегль: + Font size Кегль Type: Текст: Style Стиль Style: Normal Стиль: Обычный diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..ed530fb2d1 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1742,8 +1742,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { apply(app, ui.ctx(), json!({"fontStyle": style})); } } - let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); + let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); + let icon = icon.on_hover_text(tl!("Font size")); + icon.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Font size"))); + // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). + crate::widgets::scrub_option_label(ui, &icon); let (unit, label) = type_unit(app); let mut display = size * unit; if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c7b54c8871..b14cd16cf7 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -410,6 +410,87 @@ fn size_drag_is_live_and_one_history_step_per_drag() { assert_eq!(size_at(h.state(), id, 0), 20.0); } +fn options_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Drags the options bar's "T" icon `dx` points to the right with `mods` held. +fn scrub_size_icon(h: &mut Harness<'static, PhotocraftApp>, dx: f32, mods: Modifiers) { + let icon = h.get_by_label("Font size").rect().center(); + h.hover_at(icon); + h.run_steps(1); + press(h, icon, true); + for k in 1..=5 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(icon + vec2(dx * k as f32 / 5.0, 0.0)); + h.run_steps(1); + } + press(h, icon + vec2(dx, 0.0), false); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn history_len(h: &Harness<'static, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.entries().len() +} + +/// Photoshop's options bar "T" icon is a scrubby slider for the size beside it, like the +/// Character panel's icons (#6437): a point per point dragged, the whole drag one undo step. +#[test] +fn options_bar_t_icon_scrubs_the_size_in_one_history_step() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + let steps = history_len(&h); + scrub_size_icon(&mut h, 30.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 50.0, "30 points to the right: 20 pt → 50 pt"); + assert_eq!(history_len(&h), steps + 1, "one history step for the whole drag"); + assert!(h.state_mut().session.undo()); + assert_eq!(size_at(h.state(), id, 0), 20.0); +} + +/// While editing with characters selected, scrubbing the "T" icon sizes only the selection. +#[test] +fn options_bar_t_icon_scrubs_the_selected_characters_only() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor: 6, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -8.0, Modifiers::NONE); + let sizes = [0, 5, 6, 10].map(|ci| size_at(h.state(), id, ci)); + assert_eq!(sizes, [20.0, 20.0, 12.0, 12.0], "\"world\" goes 20 pt → 12 pt, \"Hello \" stays"); +} + +/// Scrubbing the "T" icon stops at the size field's limits: 1 pt and 1296 pt. +#[test] +fn options_bar_t_icon_scrub_stops_at_the_size_limits() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -60.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 1.0, "60 points left of 20 pt stops at the minimum"); + scrub_size_icon(&mut h, 200.0, Modifiers::SHIFT); + assert_eq!(size_at(h.state(), id, 0), 1296.0, "Shift (10 pt per point) past the top stops at the maximum"); +} + /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the /// column's centre line. fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..95f2041f5a 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Type options bar "T" icon scrubs the font size, #6437; 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) · **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, @@ -161,6 +161,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Guides stored as f64 | missing | `Vec` (UI-217-20) | | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | +| Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) | ## Navigation @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From cac01f79b80d2a28bb75e03cdfd0757206846c9e Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Fri, 16 Oct 2026 00:27:10 -0400 Subject: [PATCH 0309/1452] match smart object re-renders to the document's mode and depth --- crates/engine/src/image_cmds.rs | 43 ++++++++++-------- crates/engine/src/smart_cmds.rs | 30 +++++++++++-- crates/engine/tests/smart_object_tones.rs | 54 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/smart_object_tones.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..6fc2700762 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -698,30 +698,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 6ffc89e67d..ec3e55f8ca 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; @@ -189,6 +189,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 { @@ -224,7 +248,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) } 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" + ); + } +} From 6cef19e38a13d70c31b4f75453f68e683fc02f71 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:28:03 -0400 Subject: [PATCH 0310/1452] =?UTF-8?q?perf(quantize):=20Indexed=20Color=20p?= =?UTF-8?q?alette=20build=208=C3=97=20faster,=20bit=20for=20bit=20the=20sa?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_palette (Adaptive, Perceptual, Selective; Exact shares the histogram) now gives exactly the same colours, faster. Release, 24 MP, 256 colours, median of 5: | case | before ms | after ms | speed-up | 1 thread before | 1 thread after | |-------------------------|----------:|---------:|---------:|----------------:|---------------:| | photo-like Perceptual | 5068 | 601 | 8.4x | 4841 | 1651 | | photo-like Adaptive | 4044 | 608 | 6.7x | 3859 | 1251 | | photo-like Selective | 4862 | 617 | 7.9x | 4720 | 1716 | | noisy Perceptual | 14124 | 1643 | 8.6x | 13726 | 4018 | | noisy Adaptive | 10891 | 1485 | 7.3x | 10600 | 2701 | Max RSS, photo-like Perceptual (whole probe, 384 MB image included): 802 MB before, 644 MB after. Noisy: 1667 MB before, 1151 MB after. What changed: - median_cut keeps each box's (axis, score) and rescans only the two boxes a split makes, instead of every box on every split. - median_cut sorts big boxes with a stable radix sort on the f32 total_cmp order (same order as sort_by, ties keep their input order). - histogram packs pixels to u32 and sorts and counts them, instead of a SipHash HashMap insert per pixel. - k-means finds the nearest centre for all points in parallel, then adds the f64 sums serially in the original point order. - The Lab conversion of the histogram runs in parallel (order kept). - to8 no longer calls libm roundf per channel; it is checked equal to f32::round for every one of the 2^32 floats. Every palette hash matched before and after for Adaptive/Perceptual/Selective/ Exact at 256/64/16 colours with no forced colours and Black and White, on a 24 MP photo-like image, a 24 MP noisy image, a small image with fewer colours than requested, a 300-colour image and a semi-transparent image. New tests pin a recorded palette from the old code and compare median_cut, k-means and the radix sort with the old code bit for bit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu --- crates/algo/src/quantize.rs | 427 +++++++++++++++++++++++++++++++++--- 1 file changed, 401 insertions(+), 26 deletions(-) diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index 589c9f196d..4c7d1d51c9 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -61,8 +61,15 @@ pub enum Dither { Noise, } +/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call +/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating +/// with an integer cast and adding one when the exact remainder is at least a half is +/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The +/// `to8_matches_round_for_every_float` test checks every input. fn to8(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 + let x = v.clamp(0.0, 1.0) * 255.0; + let t = x as u32; + (t + u32::from(x - t as f32 >= 0.5)) as u8 } fn rgb8(p: &[f32; 4]) -> Rgb8 { @@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec { } } -/// Unique 8-bit colours with counts (opaque pixels only). +/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour. fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> { - let mut m: std::collections::HashMap = std::collections::HashMap::new(); - for p in px.iter().filter(|p| p[3] >= 0.5) { - *m.entry(rgb8(p)).or_default() += 1; + // Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the + // `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash + // `HashMap` insert per pixel: counts are integers, so any order of work gives the same + // result, and equal colours sit next to each other after the sort. One 4-byte value per + // pixel is the only large allocation (no per-thread tables). + let pack = |p: &[f32; 4]| { + let [r, g, b] = rgb8(p); + u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b) + }; + #[cfg(not(target_arch = "wasm32"))] + let keys = { + use rayon::prelude::*; + let mut keys: Vec = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.par_sort_unstable(); + keys + }; + #[cfg(target_arch = "wasm32")] + let keys = { + let mut keys: Vec = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.sort_unstable(); + keys + }; + let mut out: Vec<(Rgb8, u32)> = Vec::new(); + for k in keys { + let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8]; + match out.last_mut() { + Some((last, n)) if *last == c => *n = n.saturating_add(1), + _ => out.push((c, 1)), + } + } + out +} + +/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot +/// be split. Every score is bit for bit what the old per-iteration scan gave. +fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> { + if b.len() <= 1 { + return None; + } + // One pass per axis, then the weight sum: the same folds the old per-iteration scan ran + // (measured faster than a single fused pass, which the compiler does not vectorise). + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + Some((best.0, best.1 * w.sqrt())) +} + +/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order. +fn total_order_key(v: f32) -> u32 { + let bits = v.to_bits(); + // Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit. + bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000) +} + +/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like +/// `sort_by`, so ties keep their original order and the result is element for element the same; +/// it is several times faster than a comparison sort on millions of points. +fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + let n = b.len(); + let Some(&fill) = b.first() else { return }; + if n < 2 { + return; + } + // Three digits of 11, 11 and 10 bits; count all of them in one read. + let mut counts = [[0u32; 2048]; 3]; + for e in b.iter() { + let k = total_order_key(e.0[axis]); + counts[0][(k & 0x7ff) as usize] += 1; + counts[1][((k >> 11) & 0x7ff) as usize] += 1; + counts[2][(k >> 22) as usize] += 1; + } + let mut src = std::mem::take(b); + let mut dst = vec![fill; n]; + for (pass, count) in counts.iter_mut().enumerate() { + // A digit that is the same for every point would not move anything. + if count.iter().any(|&c| c as usize == n) { + continue; + } + let mut sum = 0u32; + for c in count.iter_mut() { + let here = *c; + *c = sum; + sum += here; + } + let shift = 11 * pass as u32; + for e in &src { + let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize; + dst[count[d] as usize] = *e; + count[d] += 1; + } + std::mem::swap(&mut src, &mut dst); + } + *b = src; +} + +/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the +/// radix sort's fixed counting cost. +fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + if b.len() >= 256 { + radix_sort_along(b, axis); + } else { + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); } - let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect(); - v.sort_unstable(); - v } /// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means. @@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { return Vec::new(); } let mut boxes: Vec> = vec![pts.to_vec()]; + // Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes). + // Only the two boxes made by a split change, so only those are scanned again; rescanning + // every box on every iteration was most of the time here. + let mut scores: Vec> = vec![split_score(pts)]; while boxes.len() < n { - // Split the box with the largest weighted extent. - let (bi, axis, score) = boxes + // Split the box with the largest weighted extent (first one wins a tie, as before). + let (bi, axis, score) = scores .iter() .enumerate() - .filter(|(_, b)| b.len() > 1) - .map(|(i, b)| { - let mut best = (0usize, -1.0f32); - for a in 0..3 { - let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); - if hi - lo > best.1 { - best = (a, hi - lo); - } - } - let w: f32 = b.iter().map(|p| p.1 as f32).sum(); - (i, best.0, best.1 * w.sqrt()) - }) + .filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score))) .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); if bi == usize::MAX || score <= 0.0 { break; } let mut b = boxes.swap_remove(bi); - b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + scores.swap_remove(bi); + sort_along(&mut b, axis); let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); let mut acc = 0u64; let mut cut = 1; @@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { } } let rest = b.split_off(cut); + scores.push(split_score(&b)); + scores.push(split_score(&rest)); boxes.push(b); boxes.push(rest); } @@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { let lookup = Nearest::new(centers.iter().copied()); let mut sum = vec![[0.0f64; 3]; centers.len()]; let mut cnt = vec![0.0f64; centers.len()]; - for (p, w) in pts { - let i = lookup.find(*p); + // The nearest-centre search is the expensive part and has no cross-point state, so it + // runs in parallel. The f64 sums below stay sequential in the original point order: + // float addition is not associative, and this keeps every bit of the result unchanged. + #[cfg(not(target_arch = "wasm32"))] + let nearest: Vec = { + use rayon::prelude::*; + pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect() + }; + #[cfg(target_arch = "wasm32")] + let nearest: Vec = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect(); + for ((p, w), &i) in pts.iter().zip(&nearest) { + let i = i as usize; for a in 0..3 { sum[i][a] += f64::from(p[a]) * f64::from(*w); } @@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 { photocraft_color::convert::lab_to_srgb(lab).map(to8) } +/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own +/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow +/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours. +fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> { + let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + hist.par_iter().map(lab).collect() + } + #[cfg(target_arch = "wasm32")] + hist.iter().map(lab).collect() +} + /// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed /// kinds; forced colours are included first and count toward it. pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result, String> { @@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: if hist.len() <= room { hist.iter().map(|h| h.0).collect() } else { - let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect(); + let pts = lab_points(&hist); let mut centers = median_cut(&pts, room); kmeans(&pts, &mut centers, 4); let mut v: Vec = centers.iter().map(|c| lab_to_rgb8(*c)).collect(); @@ -570,6 +698,253 @@ mod tests { (0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect() } + fn to8_reference(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + + #[test] + fn to8_matches_round_on_samples_and_edges() { + // Every half-way point and its neighbours, plus a dense sweep of the unit range. + for k in 0..=256u32 { + let mid = (k as f32 + 0.5) / 255.0; + for d in -3i32..=3 { + let v = f32::from_bits((mid.to_bits() as i32 + d) as u32); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + for i in 0..200_000u32 { + let v = f32::from_bits(i * 20_000); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] { + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"] + fn to8_matches_round_for_every_float() { + use rayon::prelude::*; + let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count(); + assert_eq!(bad, 0); + } + + /// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a + /// few see-through pixels. + fn golden_image() -> Vec<[f32; 4]> { + let (w, h) = (96usize, 64usize); + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0; + let alpha = if i % 17 == 0 { 0.25 } else { 1.0 }; + [x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha] + }) + .collect() + } + + /// Palettes written down from the version before the speed-up (hash-map histogram, every box + /// rescanned each split, comparison sort, serial k-means). Any change to how the palettes + /// are built has to give these exact colours. + #[test] + fn palettes_match_the_recorded_originals() { + let px = golden_image(); + #[rustfmt::skip] + let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![ + ( + PaletteKind::Adaptive, + 16, + Forced::None, + &[ + [40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49], + [142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87], + [91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127], + ], + ), + ( + PaletteKind::Perceptual, + 16, + Forced::None, + &[ + [104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107], + [200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56], + [119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135], + ], + ), + ( + PaletteKind::Selective, + 24, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76], + [164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45], + [153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80], + [32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94], + ], + ), + ( + PaletteKind::Adaptive, + 8, + Forced::Primaries, + &[ + [0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], + [255, 0, 255], [255, 255, 0], + ], + ), + ( + PaletteKind::Perceptual, + 12, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132], + [66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105], + ], + ), + ]; + for (kind, colors, forced, expected) in cases { + assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}"); + } + } + + /// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones. + fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { + if pts.is_empty() || n == 0 { + return Vec::new(); + } + let mut boxes: Vec> = vec![pts.to_vec()]; + while boxes.len() < n { + let (bi, axis, score) = boxes + .iter() + .enumerate() + .filter(|(_, b)| b.len() > 1) + .map(|(i, b)| { + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + (i, best.0, best.1 * w.sqrt()) + }) + .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); + if bi == usize::MAX || score <= 0.0 { + break; + } + let mut b = boxes.swap_remove(bi); + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); + let mut acc = 0u64; + let mut cut = 1; + for (i, p) in b.iter().enumerate() { + acc += u64::from(p.1); + if acc * 2 >= total { + cut = (i + 1).clamp(1, b.len() - 1); + break; + } + } + let rest = b.split_off(cut); + boxes.push(b); + boxes.push(rest); + } + boxes + .iter() + .map(|b| { + let w: f64 = b.iter().map(|p| f64::from(p.1)).sum(); + std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::() / w) as f32) + }) + .collect() + } + + fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { + for _ in 0..iters { + let lookup = Nearest::new(centers.iter().copied()); + let mut sum = vec![[0.0f64; 3]; centers.len()]; + let mut cnt = vec![0.0f64; centers.len()]; + for (p, w) in pts { + let i = lookup.find(*p); + for a in 0..3 { + sum[i][a] += f64::from(p[a]) * f64::from(*w); + } + cnt[i] += f64::from(*w); + } + for (i, c) in centers.iter_mut().enumerate() { + if cnt[i] > 0.0 { + *c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32); + } + } + } + } + + /// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to + /// a few thousand, so the stable-sort and float-order rules are all exercised. + fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> { + let mut s = seed; + let mut next = move || { + s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (s >> 33) as u32 + }; + (0..n) + .map(|_| { + let mut c = |range: u32| match next() % 40 { + 0 => -0.0, + 1 => 0.0, + _ => (next() % range) as f32 * 0.5 - (range / 4) as f32, + }; + ([c(24), c(300), c(150)], 1 + next() % 3000) + }) + .collect() + } + + #[test] + fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() { + // 40 000 points cross the 256-point radix threshold on the first splits. + for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] { + let pts = tie_heavy_points(n, seed); + let new = median_cut(&pts, boxes); + let old = median_cut_reference(&pts, boxes); + assert_eq!(new.len(), old.len()); + for (a, b) in new.iter().zip(&old) { + assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}"); + } + let (mut a, mut b) = (new, old); + kmeans(&pts, &mut a, 3); + kmeans_reference(&pts, &mut b, 3); + for (x, y) in a.iter().zip(&b) { + assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}"); + } + } + } + + #[test] + fn radix_sort_is_the_stable_total_cmp_sort() { + for axis in 0..3 { + let mut pts = tie_heavy_points(5_000, 9 + axis as u64); + // The weight field doubles as an id: equal keys must keep their input order. + for (i, p) in pts.iter_mut().enumerate() { + p.1 = i as u32; + } + pts[10].0[axis] = f32::NAN; + pts[11].0[axis] = -f32::NAN; + pts[12].0[axis] = f32::INFINITY; + pts[13].0[axis] = f32::NEG_INFINITY; + let mut expected = pts.clone(); + expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + radix_sort_along(&mut pts, axis); + let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::>(); + assert_eq!(bits(&pts), bits(&expected), "axis {axis}"); + } + } + + #[test] + fn histogram_counts_opaque_pixels_sorted() { + let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]]; + assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]); + assert!(histogram(&[]).is_empty()); + } + #[test] fn fixed_palettes_have_expected_sizes() { assert_eq!(web_palette().len(), 216); From 8127c6aa1a6cf599c76411b2245f4eb6aab8dd30 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Fri, 16 Oct 2026 00:29:20 -0400 Subject: [PATCH 0311/1452] keep comps, slices and variables when converting a layer to a smart object --- crates/engine/src/smart_cmds.rs | 26 +++++++++- .../engine/tests/convert_keeps_references.rs | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/convert_keeps_references.rs diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..959bcf8fe9 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -752,8 +752,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) @@ -762,6 +765,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 { 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"); +} From eee8d8c4ea623de7920d5348aeb941e6cb34bab8 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 10:34:52 +0530 Subject: [PATCH 0312/1452] fix(photocraft): `--control 0` logs the port actually bound (#6427) With port 0 the OS picks a free port, but the control server printed the requested one (`127.0.0.1:0`), so a client had no way to find it. It now prints `listener.local_addr()`, like `photocraft-cli serve` does, falling back to the requested port if the OS can't report it. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/control_server.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 30ae610b70..d73984eb8a 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver Receiver String { + match listener.local_addr() { + Ok(addr) => addr.to_string(), + Err(_) => format!("127.0.0.1:{requested_port}"), + } +} + fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); } @@ -131,6 +141,17 @@ mod tests { use super::*; use std::io::BufRead; + /// #6427: with port 0 the log names the port the OS bound, which a client can connect to. + #[test] + fn port_zero_logs_the_bound_port() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let bound = listener.local_addr().unwrap(); + assert_ne!(bound.port(), 0); + let logged = listening_addr(&listener, 0); + assert_eq!(logged, format!("127.0.0.1:{}", bound.port())); + TcpStream::connect(&logged).unwrap(); + } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline /// (releasing its connection permit), and nothing it sent is dispatched. #[test] From ac8e60ff2ea440f7b1f1c877d9a8a5b7fe19838f Mon Sep 17 00:00:00 2001 From: denstu473 <63150090+denstu473@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:11:27 +0200 Subject: [PATCH 0313/1452] fix(ui): preserve shape stroke settings at zero width Keep the selected shape's stroke when width reaches zero so subsequent width edits retain its paint and geometry settings in both UI controls. Preserve explicit No Color removal and gesture-scoped undo behavior. Add real-widget regressions for style retention, native persistence, rendering, undo/redo, and deliberate stroke activation. Fixes #6191. --- crates/ui-egui/src/vector_paint_tests.rs | 95 ++++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 22 ++++-- docs/shape-strokes.md | 7 +- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index c62277fb1e..321a106cb0 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -62,6 +62,101 @@ fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) { h.step(); } +fn stroke_width(h: &mut Harness<'_, PhotocraftApp>, width: &str) { + h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the stroke width").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text(width.into())); + h.key_press(Key::Enter); + h.run_steps(3); +} + +/// #6191: passing through zero hides the stroke without discarding its paint or style. +#[test] +fn zero_width_preserves_selected_shape_stroke_and_restores_it() { + for depth in [8, 16, 32] { + for (properties, tool) in [(true, Tool::Rectangle), (false, Tool::Rectangle), (false, Tool::PathSelection), (false, Tool::DirectSelection)] { + let mut app = app("rgb", depth); + app.ui.tool = tool; + app.run( + "shape.create", + json!({"rect": [20, 20, 80, 60], "fill": null, + "stroke": {"width": 4, "color": [0.123456, 0.345678, 0.567891, 0.618237], + "align": "inside", "cap": "round", "join": "bevel", "miterLimit": 7, + "dashes": [3, 2], "dashOffset": 1.25, "opacity": 63}}), + ) + .unwrap(); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, properties); + stroke_width(&mut h, "0"); + let zero = shape(h.state()).clone(); + let mut expected = original.stroke.clone().unwrap(); + expected.width = 0.0; + assert_eq!(zero.stroke, Some(expected), "{tool:?}, Properties={properties}, depth={depth}: zero width retains the stroke"); + assert_eq!(zero.path, original.path); + assert!(zero.cache.as_ref().unwrap().content_bounds().is_empty(), "a zero-width stroke paints nothing"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + + // A save at zero must retain the dormant stroke too. + let st = h.state().session.active().unwrap(); + let bytes = photocraft_format::save_to_bytes(&st.doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + let LayerContent::Shape(saved) = &loaded.layer(st.active_layer.unwrap()).unwrap().content else { panic!("saved shape") }; + assert_eq!(saved.stroke, zero.stroke); + + stroke_width(&mut h, "4"); + assert_eq!(shape(h.state()), &original, "restoring width restores the complete style and rendered pixels"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } + } +} + +#[test] +fn zero_width_style_can_be_inspected_removed_or_reenabled_explicitly() { + for properties in [true, false] { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"rect": [20, 20, 80, 60], "stroke": {"width": 4, "color": "#e73581", "dashes": [3, 2]}})).unwrap(); + let paint = shape(&app).stroke.as_ref().unwrap().paint.clone(); + let mut h = harness(app, properties); + stroke_width(&mut h, "0"); + let zero = shape(h.state()).clone(); + h.get_by_label("Stroke Options").click(); + h.run_steps(3); + assert_eq!(h.state().ui.stroke_editor.as_ref().unwrap().draft.dashes, [3.0, 2.0], "zero width still exposes the saved options"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(shape(h.state()), &zero, "merely inspecting the style does not enable it"); + + h.get_by_label(if properties { "Set shape stroke type" } else { "Stroke color" }).click(); + h.run_steps(3); + h.get_by_label("No Color").click(); + h.run_steps(2); + assert!(shape(h.state()).stroke.is_none(), "No Color still removes the stroke"); + h.key_press(Key::Escape); + h.run_steps(2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.run_steps(2); + + h.get_by_label("Stroke Options").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Dotted").click(); + h.run_steps(3); + let stroke = shape(h.state()).stroke.as_ref().unwrap(); + assert_eq!(stroke.width, 3.0, "a deliberate style choice enables the dormant stroke"); + assert_eq!(stroke.paint, paint); + assert_eq!(stroke.dashes, [0.0, 2.0]); + } +} + #[test] fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_paint() { for label in ["Fill color", "Stroke color"] { diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..1375f63b1a 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1183,8 +1183,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo None => (o.shape_fill, o.shape_fill.then(|| solid_paint(fg)), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, solid_paint(bg)))), }; let mut width = stroke.as_ref().map_or(0.0, |s| s.width); + let stroke_enabled = stroke.as_ref().is_some_and(|s| s.width > 0.0); let style = stroke.as_ref().map_or_else(|| o.shape_stroke.clone(), crate::shape_stroke_ui::StrokeOptions::from); - let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}", s.doc, s.layer.0)); + let gesture_key = ui.id().with("shape-options-paint-gesture"); + let gesture = paint_gesture(ui, gesture_key); + let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}:{}:{gesture}", s.doc, s.layer.0, gesture_key.value())); let mut edit: Option = None; lbl(ui, tl!("Fill:")); if crate::widgets::checkbox(ui, &mut has_fill, "").changed() { @@ -1200,15 +1203,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if stroke.is_none() { p["color"] = json!(bg); } - edit = Some(if width <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": p, "coalesce": key("stroke-w")}) }); + edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); } let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); if let Some(change) = - crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke.is_some()) + crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) { let mut p = change.options.params(); - if stroke.is_none() { + if !stroke_enabled { p["width"] = json!(3.0); + } + if stroke.is_none() { p["color"] = json!(bg); } edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); @@ -1279,6 +1284,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo app.ui.status = e; app.ui.status_error = true; } + if !ui.input(|i| i.pointer.any_down()) { + ui.data_mut(|d| { + let gesture = d.get_temp_mut_or_default::<(u64, u64)>(gesture_key); + gesture.0 = gesture.0.wrapping_add(1); + }); + } } true } @@ -1519,8 +1530,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width); if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() { - let stroke = if w <= 0.0 { Value::Null } else { json!({"width": w}) }; - edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke-w")})); + edit = Some(json!({"stroke": {"width": w}, "coalesce": coalesce("stroke-w")})); } }); let style = sh.stroke.as_ref().map(crate::shape_stroke_ui::StrokeOptions::from).unwrap_or_default(); diff --git a/docs/shape-strokes.md b/docs/shape-strokes.md index 835eff76c2..61305c07b8 100644 --- a/docs/shape-strokes.md +++ b/docs/shape-strokes.md @@ -1,6 +1,6 @@ # Shape stroke options -Updated 2026-10-11: direct editing, compact layout, and clickable colour/style controls. +Updated 2026-10-15: retain stroke paint and options when width reaches zero (#6191). PhotoCraft's Shape tool options bar has a Stroke Options button beside the existing width and colour controls. It sets defaults for new Rectangle, Ellipse, Triangle, Polygon, Line @@ -15,6 +15,11 @@ colour or style deliberately enables a 3 px stroke when no stroke is present. Th remains available with no stroke, but opening or dismissing it alone does not enable one. Properties edits the selected shape; the options bar continues to set defaults for new shapes. +Setting an existing shape's stroke width to 0 px hides the stroke while retaining its paint, +opacity and geometry options. Raising the width restores the same style, including after a +native save and reopen. No Color still removes the stroke; deliberately choosing a colour or +style enables a zero-width stroke at 3 px. Width edits remain one undo step per numeric drag. + The editor offers Solid, Dashed (4 widths on, 2 off), Dotted (zero-length dashes with round caps, spaced every 2 widths), and custom repeating dash/gap sequences. Dash lengths and offsets are multiples of the stroke width; width itself is in document pixels. Odd-length From d8a23d3ec38aa6666c434265c7416aa71fcd3470 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 07:30:48 +0200 Subject: [PATCH 0314/1452] Automation: a control token authenticates in either letter case (#6425) validate_token accepts uppercase hex and server_token keeps the token lowercased, but authentication_reply passed the client's token to token_matches unchanged, which compared bytes exactly. A client sending the token exactly as configured (--control-token or a token file in uppercase) was refused; only the bundled bridge, which lowercases, got in. token_matches now folds ASCII letters on both sides, still without an early exit. Only A-Z fold, so a non-hex byte never matches a hex digit. --- crates/automation/src/security.rs | 45 ++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 7928420b12..3f62c191b5 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> { } /// Compare fixed-width tokens without an early exit on a mismatching byte. +/// +/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps +/// the token lowercased, so a client sending the token exactly as it was configured must still +/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit. pub fn token_matches(expected: &str, supplied: &str) -> bool { if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN { return false; } - expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0 + expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0 } /// Validate the first TCP frame without exposing any control method before authentication. @@ -348,6 +352,45 @@ mod tests { assert_eq!(reply["result"]["authenticated"], true); } + fn auth_line(token: &str) -> String { + json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string() + } + + /// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a + /// client sending the token exactly as it was configured was refused. + #[test] + fn a_token_authenticates_in_any_letter_case() { + let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef"; + let expected = server_token(Some(configured), None).unwrap(); + for supplied in [configured, &configured.to_ascii_lowercase(), mixed] { + let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected); + assert!(authenticated, "{supplied}: {reply}"); + assert_eq!(reply["result"]["authenticated"], true); + } + // The comparison is case-insensitive in both directions. + assert!(token_matches(configured, &configured.to_ascii_lowercase())); + + // A different token, or a non-hex character that only folds to a hex one, still fails. + let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let (_, authenticated) = authentication_reply(&auth_line(other), &expected); + assert!(!authenticated); + let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + assert!(!token_matches(&expected, not_hex)); + } + + /// #6425: the same for a token file whose contents are uppercase. + #[test] + fn an_uppercase_token_file_authenticates_its_exact_contents() { + let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); + let contents = generate_token().unwrap().to_ascii_uppercase(); + std::fs::write(&path, format!("{contents}\n")).unwrap(); + let expected = server_token(None, Some(&path)); + std::fs::remove_file(&path).unwrap(); + let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); + assert!(authenticated, "{reply}"); + } + #[test] fn repeat_auth_on_an_authenticated_connection_says_so() { let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string(); From 3c63b2007171bf3dda7e1d3f01b814ff2a6ca462 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 07:40:27 +0200 Subject: [PATCH 0315/1452] Ctrl+Space and Alt+Space zoom the image under an open dialog (fixes #6460) --- crates/ui-egui/src/canvas.rs | 18 ++++++++++- crates/ui-egui/src/dialogs.rs | 18 +++++++++++ crates/ui-egui/src/input_tests.rs | 50 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..4afedd01df 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3353,7 +3353,23 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let shadow_drag = !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; - if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { + // ⌘Space / ⌥Space zoom under the dialog as they do without one, before Space pans (#6460). + let held_zoom = !middle && matches!(temporary, Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut)); + if held_zoom { + let alt = ctx.input(|i| i.modifiers.alt); + if free_hover { + ctx.set_cursor_icon(crate::tool_cursor::zoom(&ctx, zoom_out(alt))); + } + let b = crate::dialogs::free_buttons(&ctx, rect); + let pointer = ctx.input(|i| i.pointer.latest_pos()); + crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &b, pointer); + if b.clicked + && let Some(p) = pointer + { + let nz = crate::zoom_levels::step(view.zoom, if zoom_out(alt) { -1 } else { 1 }, view.doc_size); + zoom_about(&mut view, &xf, p, nz, app.session.prefs().tools.zoom_clicked_point_to_center, ppp); + } + } else if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 013c79e825..7daef4ee28 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -43,6 +43,24 @@ pub fn pan_delta(ctx: &egui::Context, canvas: egui::Rect, hand: bool) -> Option< (panning && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta) } +/// The primary button's drag and click on the free canvas under an open dialog, as the canvas +/// response would report them if it weren't inert: for the held Zoom (⌘Space / ⌥Space, #6460). +/// A drag counts from egui's drag threshold, like `drag_started`. +pub fn free_buttons(ctx: &egui::Context, canvas: egui::Rect) -> crate::paint_mouse::Buttons { + let id = egui::Id::new("pc-dialog-free-drag"); + let was = ctx.data(|d| d.get_temp::(id)).unwrap_or(false); + let rects = rects(ctx); + let open_popup = egui::Popup::is_any_open(ctx); + let free = |p: egui::Pos2| !open_popup && canvas.contains(p) && !rects.iter().any(|r| r.contains(p)); + let (down, dragging, clicked) = ctx.input(|i| { + let p = &i.pointer; + let dragging = p.primary_down() && p.is_decidedly_dragging() && (was || p.press_origin().is_some_and(free)); + (p.primary_down(), dragging, p.primary_clicked() && p.interact_pos().is_some_and(free)) + }); + ctx.data_mut(|d| d.insert_temp(id, dragging)); + crate::paint_mouse::Buttons { started: dragging && !was, dragged: dragging, stopped: was && !down, clicked } +} + /// A click or drag with the primary button started on the free canvas under an open dialog, Space /// not held: where the pointer is this frame. The press itself counts even when it is released in /// the same frame (a quick click, `ui.click`); the drag only while it stays on the free canvas. diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..2266c6f3db 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -194,6 +194,56 @@ fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() { assert!(h.state().ui.dialogs.is_empty()); } +/// ⌘Space (Ctrl+Space off the Mac) and ⌥Space are the temporary Zoom under an open dialog too, as +/// in Photoshop: a drag on the free canvas is a scrubby zoom about the press point, not a pan, and +/// a click steps the zoom (#6460). +#[test] +fn temporary_zoom_works_under_a_dialog() { + let mut h = harness(); + h.state_mut().ui.extras.rulers = false; + open_dialog(&mut h); + let p = free_canvas(&h); + h.hover_at(p); + h.run_steps(1); + let (z0, doc0) = (zoom(&h), doc_at(&h, p)); + let held = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, m: Modifiers| { + h.event(egui::Event::ModifiersChanged(m)); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: m }); + h.run_steps(1); + }; + let button = |h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, m: Modifiers| { + h.event_modifiers(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m }, m); + h.run_steps(1); + }; + held(&mut h, true, Modifiers::COMMAND); + button(&mut h, p, true, Modifiers::COMMAND); + for i in 1..=6 { + h.hover_at(p + vec2(10.0 * i as f32, 0.0)); + h.run_steps(1); + } + button(&mut h, p + vec2(60.0, 0.0), false, Modifiers::COMMAND); + held(&mut h, false, Modifiers::COMMAND); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let z1 = zoom(&h); + let size = h.state().ui.views[0].doc_size; + assert!((z1 - crate::zoom_tool::scrub_zoom(z0, 60.0, size)).abs() < 1e-4, "⌘Space-drag zooms in: {z0} -> {z1}"); + let doc1 = doc_at(&h, p); + assert!((doc1[0] - doc0[0]).abs() < 0.5 && (doc1[1] - doc0[1]).abs() < 0.5, "the pressed point stays put: {doc0:?} -> {doc1:?}"); + + // ⌥Space-click steps out. + h.hover_at(p); + h.run_steps(1); + held(&mut h, true, Modifiers::ALT); + button(&mut h, p, true, Modifiers::ALT); + button(&mut h, p, false, Modifiers::ALT); + held(&mut h, false, Modifiers::ALT); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(zoom(&h), crate::zoom_levels::step(z1, -1, size)); + assert_eq!(h.state().ui.dialogs.len(), 1); +} + fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) { h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); h.run_steps(1); From 942a581c0ca833939fc651b04fae6204f5b3eb46 Mon Sep 17 00:00:00 2001 From: jackn Date: Thu, 15 Oct 2026 23:51:39 -0600 Subject: [PATCH 0316/1452] Shapes: the Color panel, Swatches and Gradients fill the selected shape (#5568) With a shape layer selected, picking a colour in the Color panel, clicking a swatch or confirming the foreground Color Picker only changed the foreground colour: the panels recoloured selected type and nothing else, so the shape kept its fill. A Gradients panel click recoloured a selected Gradient Fill layer but ignored shapes. As in Photoshop, these now fill the selected shape layer. A new foreground fills the shape with a solid colour, keeping the fill's own opacity; a Color panel drag is one undo step (edits coalesce on the press time), each click its own. A gradient preset fills the shape with the gradient, keeping an existing gradient fill's angle, style, scale and reverse. Locked shapes are left alone: the Color panel only changes the foreground, and gradient.presets.select refuses before it changes the Gradient tool's gradient. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/presets/gradients.rs | 40 +++++++++++++++++++++--- crates/engine/src/presets/tests.rs | 42 ++++++++++++++++++++++++++ crates/ui-egui/src/color_picker_ui.rs | 1 + crates/ui-egui/src/panels.rs | 30 ++++++++++++++++++ crates/ui-egui/src/shape_fill_ui.rs | 35 +++++++++++++++++++++ crates/ui-egui/src/swatches_ui.rs | 32 ++++++++++++++++++-- 6 files changed, 174 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..31e7561c35 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -441,15 +441,47 @@ fn active_gradient_fill(s: &Session) -> Option { matches!(d.doc.layer(id)?.content, LayerContent::Fill(Fill::Gradient { .. })).then_some(id) } +fn active_shape(s: &Session) -> Option { + let d = s.active()?; + let id = d.active_layer?; + matches!(d.doc.layer(id)?.content, LayerContent::Shape(_)).then_some(id) +} + fn select(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD_SELECT)?; + let apply = p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true); + let shape = active_shape(s).filter(|_| apply); + // A locked shape refuses before anything changes, the Gradient tool's gradient included. + if let (Some(id), Some(d)) = (shape, s.active()) { + let locks = d.doc.effective_locks(id); + if locks.all || locks.pixels { + return Err(bad(CMD_SELECT, "the selected shape layer is locked")); + } + } s.presets.gradient = g.clone(); s.presets_changed(); let mut out = json!({"current": g.to_json()}); + // Clicking a preset with a shape layer selected fills the shape with it (Photoshop, #5568); + // a gradient fill keeps its angle, style, scale and reverse. + if let Some(id) = shape { + let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); + crate::vector_cmds::with_shape(s, id, "Edit Shape", |sh, _| { + let (angle, scale, style, reverse) = match &sh.fill { + Some(Fill::Gradient { angle, scale, style, reverse, .. }) => (*angle, *scale, *style, *reverse), + _ => (90.0, 1.0, photocraft_doc::GradientStyle::Linear, false), + }; + let mut fill = Fill::gradient(stops_new, angle, scale, style, reverse); + if let Fill::Gradient { opacity_stops, .. } = &mut fill { + *opacity_stops = opacity_new; + } + sh.fill = Some(fill); + Ok(()) + })?; + out["layer"] = json!(id.0); + return Ok(out); + } // Clicking a preset with a Gradient Fill layer selected changes that layer (Photoshop). - if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true) - && let Some(id) = active_gradient_fill(s) - { + if apply && let Some(id) = active_gradient_fill(s) { let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); s.edit("Change Gradient Fill", |doc, _| { let mode = doc.mode; @@ -674,7 +706,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."## + r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer, or fills a selected shape layer with it)}} → {{current,layer?}}. The Gradient tool paints with it."## )), enabled: always, run: select, diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..0b714245dc 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -748,3 +748,45 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["width"], json!(1000.0)); assert_eq!(v["rotation"], json!(-90.0)); } + +fn shape_fill(s: &Session, id: LayerId) -> Option { + match &s.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + } +} + +/// #5568: clicking a gradient with a shape layer selected fills the shape with it in one undo +/// step; a gradient fill keeps its angle, style, scale and reverse. +#[test] +fn gradient_preset_click_fills_the_selected_shape_layer() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let before = shape_fill(&s, id); + let r = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap(); + assert_eq!(r["layer"], json!(id.0)); + let Some(Fill::Gradient { stops, angle, style, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + // Red 01 starts at #7f0000; a new gradient fill is linear at 90°. + assert_eq!(stops[0].1.to_rgba8(), [0x7f, 0, 0, 0xff]); + assert_eq!((angle, style), (90.0, photocraft_doc::GradientStyle::Linear)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(shape_fill(&s, id), before); + + s.execute("shape.edit", json!({"layer": id.0, "fill": {"gradient": {"angle": 30, "style": "radial", "scale": 80, "reverse": true}}})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap(); + let Some(Fill::Gradient { angle, style, scale, reverse, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + assert_eq!((angle, style, scale, reverse), (30.0, photocraft_doc::GradientStyle::Radial, 0.8, true)); +} + +/// A locked shape refuses the click before anything changes, the Gradient tool's gradient included. +#[test] +fn gradient_preset_click_on_a_locked_shape_changes_nothing() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let (before, gradient) = (shape_fill(&s, id), s.presets.gradient.name.clone()); + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + let err = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + assert_eq!(shape_fill(&s, id), before); + assert_eq!(s.presets.gradient.name, gradient); +} diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 09a3ce8d4c..9d61e3b984 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -825,6 +825,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(h.state()); + let min = h.ctx.input(|i| i.viewport_rect()).min; + // The field's top-left: no saturation, full brightness. + click(&mut h, min + vec2(60.0, 10.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 1); + drag(&mut h, min + vec2(120.0, 40.0), min + vec2(250.0, 120.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 2, "the whole drag is one undo step"); + } + /// Press at `from`, move to `to` in six steps and release, as egui sees a drag. fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); diff --git a/crates/ui-egui/src/shape_fill_ui.rs b/crates/ui-egui/src/shape_fill_ui.rs index 37731ce243..20d297d725 100644 --- a/crates/ui-egui/src/shape_fill_ui.rs +++ b/crates/ui-egui/src/shape_fill_ui.rs @@ -82,6 +82,41 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]})) } +/// A new foreground colour from the Color panel, a swatch or the Color Picker fills the selected +/// shape layer, keeping the fill's own opacity, as in Photoshop (#5568). Edits sharing `coalesce` +/// (one Color panel drag) are one undo step. Locked shapes and other layers are left alone. +pub(crate) fn foreground_changed(app: &mut PhotocraftApp, coalesce: Option) { + let Some(st) = app.session.active() else { return }; + let Some(layer) = st.active_layer else { return }; + let Some(LayerContent::Shape(sh)) = st.doc.layer(layer).map(|l| &l.content) else { return }; + let locks = st.doc.effective_locks(layer); + if locks.all || locks.pixels { + return; + } + let fg = app.session.tools.foreground; + let alpha = match &sh.fill { + Some(Fill::Solid(c)) => c.alpha, + _ => 1.0, + }; + if sh.fill == Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha))) { + return; + } + let mut p = json!({"layer": layer.0, "fill": [fg[0], fg[1], fg[2], alpha]}); + if let Some(key) = coalesce { + p["coalesce"] = json!(key); + } + if let Err(e) = app.run(COMMAND, p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// The coalesce key of the pointer gesture under way (its press time), so one Color panel drag +/// is one undo step; `None` for clicks and typed values. +pub(crate) fn gesture(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.press_start_time()).map(|t| format!("color-panel-fill:{}", t.to_bits())) +} + /// The canvas preview while the picker is open: the document with the shape re-rendered in the /// picked colour, cached per dialog, revision and colour. pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..d1fe7ef7c0 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -2,7 +2,8 @@ //! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context //! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete). //! -//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); +//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type or +//! fills the selected shape layer); //! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The //! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, //! `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a @@ -51,8 +52,10 @@ fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc>> { pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) { let target = if background { "background" } else { "foreground" }; if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background { - // Clicking a swatch while characters are selected recolours them (#364). + // Clicking a swatch while characters are selected recolours them (#364), and with a + // shape layer selected fills the shape (#5568). crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, None); } } @@ -273,6 +276,31 @@ mod tests { use egui::{Modifiers, vec2}; use egui_kittest::Harness; + /// #5568: clicking a swatch with a shape layer selected fills the shape with it, one undo + /// step each, a gradient fill included. + #[test] + fn swatch_click_fills_the_selected_shape() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": {"gradient": {}}})).unwrap()["layer"].as_u64().unwrap()); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(&app); + let names: Vec = app.session.presets.swatches[0].items.iter().take(2).map(|s| s.name.clone()).collect(); + use_swatch(&mut app, &names[0], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[0]); + assert_eq!(steps(&app), before + 1); + use_swatch(&mut app, &names[1], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[1]); + assert_eq!(steps(&app), before + 2, "each swatch click is its own undo step"); + } + fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) { h.hover_at(p); // Held modifiers, as when the user holds Alt while clicking. From 8ac3df9c3289d4168511c450b9d2505e601f8d1a Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 11:21:41 +0530 Subject: [PATCH 0317/1452] test(photocraft): mock portal subscribes before the client calls SaveFile The mock made its MessageIterator inside the spawned thread. A zbus stream only sees messages that arrive after it is created, so on a loaded runner the client's SaveFile could land first, go unanswered, and hang the test until nextest's 600 s timeout (a_typed_extension_wins_over_an_untouched_file_type on ubuntu-latest, PR #6468). Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/portal_save/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..3f200f3c97 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -44,9 +44,12 @@ fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking:: let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); let seen: Seen = Arc::default(); let log = seen.clone(); + // Subscribe before the client exists: a stream only sees messages that arrive after it is + // made, so one made in the thread could miss `SaveFile` and leave the test waiting forever. + let messages = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in messages { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { From a08992c9d93676affdabf97981547a4bce6974bc Mon Sep 17 00:00:00 2001 From: highperfocused Date: Fri, 16 Oct 2026 09:14:24 +0200 Subject: [PATCH 0318/1452] Desktop: a control/automation flag without its value is a usage error (#6429) --- apps/photocraft/src/main.rs | 288 +++++++++++++++++++++++++----------- 1 file changed, 203 insertions(+), 85 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c0f93bd319..337399d99a 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -149,12 +149,115 @@ fn parse_control_port(value: &str, source: &str) -> Result { value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) } -/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None` -/// when there are none, else 2 (a command-line usage error), never 0. +/// Process exit status for the collected control/automation argument errors: `None` when there +/// are none, else 2 (a command-line usage error), never 0. fn control_args_exit_code(errors: &[String]) -> Option { if errors.is_empty() { None } else { Some(2) } } +/// Everything the desktop launcher takes from the command line, on top of the environment +/// defaults handed in to `parse_launch_args`. +#[derive(Debug, Default, PartialEq, Eq)] +struct LaunchArgs { + control_port: Option, + control_token: Option, + control_token_file: Option, + automation_read_root: Option, + automation_write_root: Option, + files: Vec, + /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 + /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are + /// reported in a notice once the app is up; a control token is a usage error like a bad port. + unreadable_paths: Vec, + safe_gpu: bool, + new_instance: bool, + /// Options that apply only to the process they're given to; a launch with any keeps its own. + launch_options: Vec<&'static str>, + present_mode_arg: Option, + frame_latency_arg: Option, + stroke_trace_arg: Option, + in_window_menus: bool, + version: bool, + /// Usage errors in the control/automation flags; a non-empty list fails the launch (issue #701). + errors: Vec, +} + +/// Parse the desktop launch arguments over the environment defaults in `out`. A control or +/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent +/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. +fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { + let mut args = args; + while let Some(a) = args.next() { + match a.to_str() { + Some("--control") => match args.next() { + Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { + Ok(port) => out.control_port = Some(port), + Err(error) => out.errors.push(error), + }, + None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), + }, + Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(token)) => out.control_token = Some(token), + Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), + }, + Some("--control-token-file") => match args.next() { + Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), + }, + Some("--automation-read-root") => { + out.launch_options.push("--automation-read-root"); + match args.next() { + Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), + } + } + Some("--automation-write-root") => { + out.launch_options.push("--automation-write-root"); + match args.next() { + Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), + } + } + Some("--safe-gpu") => { + out.launch_options.push("--safe-gpu"); + out.safe_gpu = true; + } + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + out.launch_options.push(match flag { + "--present-mode" => "--present-mode", + "--frame-latency" => "--frame-latency", + _ => "--stroke-trace", + }); + let flag = flag.to_string(); + match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(v)) => match flag.as_str() { + "--present-mode" => out.present_mode_arg = Some(v), + "--frame-latency" => out.frame_latency_arg = Some(v), + _ => out.stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } + Some("--in-window-menus") => { + out.launch_options.push("--in-window-menus"); + out.in_window_menus = true; + } + Some("--new-instance") => out.new_instance = true, + Some("--version") => { + out.version = true; + break; + } + // Old macOS passes a process serial number when launched from Finder. + Some(s) if s.starts_with("-psn_") => {} + Some(s) => out.files.push(s.to_owned()), + None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), + } + } + out +} + #[cfg(test)] mod control_port_tests { use super::{control_args_exit_code, parse_control_port}; @@ -184,105 +287,120 @@ mod control_port_tests { } } +#[cfg(test)] +mod launch_args_tests { + use super::{control_args_exit_code, parse_launch_args, LaunchArgs}; + use std::ffi::OsString; + + fn parse(values: &[&str]) -> LaunchArgs { + parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // Issue #6429: a missing value must fail the launch, not silently fall back to a + // generated token or to no root. + for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { + let parsed = parse(&[flag]); + assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); + assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); + assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); + assert_eq!(control_args_exit_code(&parsed.errors), Some(2)); + } + } + + #[test] + fn missing_value_does_not_clear_an_environment_default() { + // `--automation-read-root` with no value must not drop a root set through the env var. + let defaults = LaunchArgs { + automation_read_root: Some("/env/read".into()), + automation_write_root: Some("/env/write".into()), + ..Default::default() + }; + let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + } + + #[test] + fn values_are_parsed_and_no_errors_remain() { + let parsed = parse(&["--control", "50494", "--control-token", "abcd", "--control-token-file", "/tmp/token", "--automation-read-root", "/r", "--automation-write-root", "/w", "--safe-gpu", "--in-window-menus", "--new-instance", "--present-mode", "mailbox", "photo.psd"]); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + assert_eq!(parsed.control_port, Some(50494)); + assert_eq!(parsed.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu); + assert!(parsed.in_window_menus); + assert!(parsed.new_instance); + assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); + assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); + assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + } + + #[test] + fn version_stops_parsing() { + let parsed = parse(&["--version", "--control"]); + assert!(parsed.version); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); - let mut control_port: Option = None; - let mut control_arg_errors: Vec = Vec::new(); + let mut launch = LaunchArgs { + automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), + automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), + in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), + ..Default::default() + }; if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Ok(port) => launch.control_port = Some(port), + Err(error) => launch.errors.push(error), } } - let mut control_token = None; - let mut control_token_file = None; - let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from); - let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from); - let mut files = Vec::new(); - // Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - // file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - // reported in a notice once the app is up; a control token is a usage error like a bad port. - let mut unreadable_paths: Vec = Vec::new(); - let mut safe_gpu = false; - let mut new_instance = false; - // Options that apply only to the process they're given to; a launch with any keeps its own. - let mut launch_options: Vec<&'static str> = Vec::new(); - let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); - let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut args = std::env::args_os().skip(1); - while let Some(a) = args.next() { - match a.to_str() { - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), - }, - None => control_arg_errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => control_token = Some(token), - Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => control_token = None, - }, - Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), - Some("--automation-read-root") => { - launch_options.push("--automation-read-root"); - automation_read_root = args.next().map(std::path::PathBuf::from); - } - Some("--automation-write-root") => { - launch_options.push("--automation-write-root"); - automation_write_root = args.next().map(std::path::PathBuf::from); - } - Some("--safe-gpu") => { - launch_options.push("--safe-gpu"); - safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => present_mode_arg = Some(v), - "--frame-latency" => frame_latency_arg = Some(v), - _ => stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - launch_options.push("--in-window-menus"); - in_window_menus = true; - } - Some("--new-instance") => new_instance = true, - Some("--version") => { - println!("photocraft {}", photocraft_engine::build_info::long_version()); - return Ok(()); - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - Some(s) => files.push(s.to_owned()), - None => unreadable_paths.push(a.to_string_lossy().into_owned()), - } + launch = parse_launch_args(std::env::args_os().skip(1), launch); + if launch.version { + println!("photocraft {}", photocraft_engine::build_info::long_version()); + return Ok(()); } - // A malformed control port must not silently drop the control server (issue #701): name the - // bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo. - // Exit status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = control_args_exit_code(&control_arg_errors) { - for error in &control_arg_errors { + // A malformed control port must not silently drop the control server (issue #701), and a + // control or automation flag without its value must not silently fall back to a generated + // token or to no root (issue #6429): name the flag and fail the launch, like + // `photocraft-cli serve --port` does for the same typo. Exit status 2 is the usual + // command-line usage error, so a launcher sees the failure. + if let Some(code) = control_args_exit_code(&launch.errors) { + for error in &launch.errors { eprintln!("photocraft: {error}"); } std::process::exit(code); } + let LaunchArgs { + control_port, + control_token, + control_token_file, + automation_read_root, + automation_write_root, + files, + unreadable_paths, + safe_gpu, + new_instance, + launch_options, + present_mode_arg, + frame_latency_arg, + stroke_trace_arg, + in_window_menus, + version: _, + errors: _, + } = launch; // The log file lives under the settings directory; opened after the arguments, so `--version` // and usage errors leave no file behind. Records logged until now are written to it first. From d27546970f5d1d1012c7ba71896ad491b28b4c83 Mon Sep 17 00:00:00 2001 From: ArchersIntel Date: Thu, 15 Oct 2026 13:37:57 -0500 Subject: [PATCH 0319/1452] fix(ui): panel footer buttons are no longer cut off at the bottom (part of #6238) panel_footer lays its 30 px bar over the body's bottom margin ("the bar runs on over the margin"), but the bar's Ui inherited the body's clip rect, which ends where the content does. The bottom of every footer button was clipped: a 26 px hover highlight was drawn about 21 px tall with a flat bottom edge. This is the "icon looks cut off when you hover over it" in the Paths panel, and it affects the Layers, Channels and History footers too, since they share the widget. The footer bar now extends its own clip to the whole bar. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/widgets.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2acbdf3c8b..2719b39076 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,9 @@ pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator)); let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR)); let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + // The bar runs on over the body's bottom margin, past where the body's own clip ends: without + // this its buttons were cut off there, a 26 px hover highlight drawn about 21 px tall. + bar_ui.set_clip_rect(bar_ui.clip_rect().union(bar)); bar_ui.spacing_mut().item_spacing.x = 2.0; add(&mut bar_ui) } @@ -1863,6 +1866,32 @@ mod tests { } use egui_kittest::{Harness, kittest::Queryable}; + /// A panel footer's buttons are drawn whole. The bar runs on over the body's bottom margin, so a + /// body that clips where its content ends (as the panels do) cut the bottom off every button: + /// the hover highlight of a 26 px button was about 21 px tall in the Paths, Layers and Channels + /// panels (#6238). + #[test] + fn panel_footer_buttons_are_not_clipped_by_the_body() { + let mut h = Harness::new_ui_state( + |ui, s: &mut Option<(egui::Rect, egui::Rect, egui::Rect)>| { + // A body exactly as tall as the footer takes, clipped there. + let body = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(220.0, super::footer_height(ui))); + let mut body_ui = ui.new_child(egui::UiBuilder::new().max_rect(body)); + body_ui.set_clip_rect(body); + super::panel_footer(&mut body_ui, |bar| { + let b = crate::icons::button(bar, "square", 26.0, false, "Button"); + *s = Some((b.rect, bar.clip_rect(), body)); + }); + }, + None, + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + let (button, clip, body) = h.state().expect("the footer drew its button"); + assert!(button.bottom() > body.bottom(), "the bar really overruns the body, or this test proves nothing: {button:?} vs {body:?}"); + assert!(clip.contains_rect(button), "the whole button is inside the clip: {button:?} not in {clip:?}"); + } + /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays /// local, so it isn't recoloured for the rest of the frame (selected rows, slider trails). #[test] From 8f5d53027c07a052d99ab57192bbd50f2424a9f7 Mon Sep 17 00:00:00 2001 From: ArchersIntel Date: Thu, 15 Oct 2026 16:18:44 -0500 Subject: [PATCH 0320/1452] fix(ui): scrolling the Paths or Channels list no longer moves the panel's footer (part of #6238) The dock lists the tabs that lay out their own scrolling list and footer so it does not wrap them in a scroll area of its own (Layers, History, Navigator). Channels and Paths do exactly the same, but were left off, so each sat inside the dock's scroll area as well as its own, and the two nested areas shared the mouse wheel. At 125 % display scaling, for some window heights, the outer content overflowed by one physical pixel and scrolled with the list: the footer's buttons moved about a pixel while scrolling down and back while scrolling up. Group::scrolls_itself now covers all three tabs of the Layers group. Measured: with 30 paths, the footer moved at 31 of 723 window-height and scale combinations (all at 125 %); with the change, none. The reporter's video agrees: the footer strip shifts 1 px and back repeatedly while the tab strip never moves. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dock.rs | 7 ++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/paths_scroll_tests.rs | 69 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/paths_scroll_tests.rs diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index deaea4e783..ad8a67c992 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -120,9 +120,12 @@ impl Group { } /// Tabs that lay out their own scrolling list and footer, or size themselves to the group - /// (the Navigator's preview grows with it, as in Photoshop: #2726). + /// (the Navigator's preview grows with it, as in Photoshop: #2726). Channels and Paths are + /// Layers' siblings and do the same: wrapped in the dock's scroll area as well, the two nested + /// areas shared the wheel, and where the outer content overflowed by one physical pixel (at + /// 125 % scaling for some window heights) the footer and list moved a pixel as you scrolled (#6238). pub fn scrolls_itself(self, tab: usize) -> bool { - matches!((self, tab), (Group::Layers, 0) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0)) + matches!((self, tab), (Group::Layers, 0..=2) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0)) } pub(crate) fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..d935b93355 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2732,6 +2732,8 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod paths_scroll_tests; #[cfg(test)] mod options_bar_mode_wheel_tests; diff --git a/crates/ui-egui/src/paths_scroll_tests.rs b/crates/ui-egui/src/paths_scroll_tests.rs new file mode 100644 index 0000000000..924e6e6290 --- /dev/null +++ b/crates/ui-egui/src/paths_scroll_tests.rs @@ -0,0 +1,69 @@ +//! Scrolling the Paths panel moves only its list (#6238). + +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn harness(paths: usize, size: egui::Vec2, ppp: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for i in 0..paths { + let x = (i % 8) as i32 * 40; + app.run("select.rect", json!({"x": x, "y": 20, "width": 30, "height": 30})).unwrap(); + app.run("select.toWorkPath", json!({"tolerance": 2})).unwrap(); + app.run("path.rename", json!({"name": "work", "to": format!("Path {}", i + 1)})).unwrap(); + } + // Layers | Channels | Paths: the Paths tab. + app.ui.dock_tabs.layers = 2; + app + }); + h.run_steps(8); + h +} + +/// Scrolling the path list moves the rows and nothing else: the footer's buttons stay where they +/// are. The Paths panel lays out its own list and footer, but the dock also wrapped it in a +/// scroll area; at 125 % scaling and some window heights that outer area overflowed by one +/// physical pixel, took part of the wheel and moved the footer about a pixel down and back up. +#[test] +fn scrolling_the_paths_list_does_not_move_the_footer() { + // Heights that moved it at 125 % (two bands of window heights), and ones that never did. + for (ppp, height) in [(1.25, 520.0), (1.25, 900.0), (1.25, 960.0), (1.0, 900.0), (1.5, 900.0)] { + let mut h = harness(30, vec2(1440.0, height), ppp); + let footer = |h: &Harness<'_, PhotocraftApp>| h.query_by_label("Delete path").map(|r| r.rect().min.y); + let rest = footer(&h).unwrap_or_else(|| panic!("the footer shows at {ppp}x, {height} high")); + let first_row = h.query_by_label("Path 1").unwrap().rect(); + h.hover_at(first_row.center()); + h.run_steps(2); + for i in 0..40 { + // Down to the end of the list and past it, then back up. + let dir = if i < 30 { -1.0 } else { 1.0 }; + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dir), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + assert_eq!(footer(&h), Some(rest), "{ppp}x, {height} high, notch {i}: the footer stayed put"); + } + // The list itself did scroll (the first row is out of view or has moved). + let moved = h.query_by_label("Path 1").map(|r| r.rect().min.y) != Some(first_row.min.y); + assert!(moved || h.query_by_label("Path 1").is_none(), "{ppp}x, {height} high: the list scrolled"); + } +} + +/// Channels, Paths and Layers are siblings with the same own-scroll layout, so none is wrapped in +/// the dock's scroll area. +#[test] +fn the_layers_group_tabs_all_scroll_themselves() { + use crate::dock::Group; + for tab in 0..=2 { + assert!(Group::Layers.scrolls_itself(tab), "Layers group tab {tab}"); + } + assert!(!Group::Layers.scrolls_itself(3), "a tab that does not exist is not special"); +} From 3f216a0cd1b9549a3ec81db75f5d160badf14d89 Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Fri, 16 Oct 2026 10:28:57 +0300 Subject: [PATCH 0321/1452] fix(engine): reject numeric overflow before document edits --- crates/engine/src/adjust_cmds.rs | 2 +- crates/engine/src/analysis_cmds.rs | 14 +- crates/engine/src/artboard_cmds.rs | 10 +- crates/engine/src/channel_cmds.rs | 16 +- crates/engine/src/color_to_alpha_cmds.rs | 9 +- crates/engine/src/commands.rs | 60 ++++++-- crates/engine/src/extra_cmds.rs | 2 +- crates/engine/src/fill_cmds.rs | 2 +- crates/engine/src/layer_style.rs | 31 +++- crates/engine/src/lens_cmds.rs | 2 +- crates/engine/src/notes_cmds.rs | 14 +- crates/engine/src/type_cmds.rs | 5 +- crates/engine/tests/finite_command_params.rs | 154 +++++++++++++++++++ 13 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 crates/engine/tests/finite_command_params.rs diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..e465b0ddc1 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -335,7 +335,7 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { fn replace_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.adjustments.replaceColor"; - let c = crate::commands::color_param(p, "color", s.tools.foreground); + let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?; let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..e6e285993e 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -722,10 +722,12 @@ fn group_index(d: &Document, p: &Value) -> Option { (i < d.measurement.count_groups.len()).then_some(i) } -fn color_of(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } /// Photoshop's colours for new count groups, in order. @@ -840,7 +842,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let color = color_of(p, "color"); + let color = color_of("count.newGroup", p, "color")?; s.edit("New Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); @@ -887,7 +889,7 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(n) = p.get("name").and_then(Value::as_str) { grp.name = n.to_string(); } - if let Some(c) = color_of(p, "color") { + if let Some(c) = color_of(CMD, p, "color")? { grp.color = c; } if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..4f551b2d77 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -92,10 +92,12 @@ fn preset_size(name: &str) -> Option<(u32, u32)> { } fn background_param(p: &Value, cmd: &str) -> Result> { - let custom = p.get("color").map(|_| { - let c = color_param(p, "color", [1.0; 4]); - ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3])) - }); + let custom = if p.get("color").is_some() { + let c = color_param(cmd, p, "color", [1.0; 4])?; + Some(ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3]))) + } else { + None + }; Ok(match p.get("background").and_then(Value::as_str) { None => custom, Some("white") => Some(ArtboardBackground::White), diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 468db2698b..991f40dc0b 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1086,17 +1086,19 @@ fn indicates_param(v: Option<&Value>) -> Option { } } -fn color_value(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_value(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn new_channel(s: &mut Session, p: &Value) -> Result { let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string(); let name = p.get("name").and_then(Value::as_str).map(str::to_string); let indicates = indicates_param(p.get("indicates")); - let color = color_value(p, "color"); + let color = color_value("channel.new", p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let i = s.edit("New Channel", |doc, _| { let area = doc.bounds(); @@ -1125,7 +1127,7 @@ fn new_channel(s: &mut Session, p: &Value) -> Result { } fn new_spot(s: &mut Session, p: &Value) -> Result { - let color = color_value(p, "color").unwrap_or(Color::rgb(1.0, 0.0, 0.0)); + let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0)); let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); @@ -1421,7 +1423,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?; let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - let color = color_value(p, "color"); + let color = color_value(cmd, p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string); diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index a6208e8246..81a60640fa 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -34,7 +34,7 @@ fn unit(v: f32, default: f32) -> f32 { /// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to /// the defaults). [`run`] validates strictly before it gets here. pub(crate) fn params(p: &Value) -> FilterParams { - let c = crate::commands::color_param(p, "color", WHITE); + let c = crate::commands::color_param(ID, p, "color", WHITE).unwrap_or(WHITE); let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d)); FilterParams::ColorToAlpha { color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0], @@ -65,9 +65,10 @@ fn validate(p: &Value) -> Result<()> { crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?; } Some(Value::Array(a)) if (3..=4).contains(&a.len()) => { - if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) { - return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1")); - } + a.iter() + .all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) + .then_some(()) + .ok_or_else(|| bad("`color` as an array must hold 3 or 4 numbers in 0..1"))?; } Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")), } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..83b0717528 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -232,15 +232,53 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result f32 { p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) } -pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { - match p.get(key) { +/// JSON numbers may be finite f64 values but overflow when stored in a document's f32 field. +pub(crate) fn finite_f32(cmd: &str, key: &str, value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { Ok(narrowed) } else { Err(bad(cmd, format!("`{key}` must fit a finite 32-bit number"))) } +} + +/// Effect/type parameters contain nested colour arrays and snapshots as well as scalars. +/// Validate before parsing or editing, including numbers hidden in an effect snapshot. +pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { + // Bound the call stack, without allocating a work list proportional to an input array. + fn check(cmd: &str, value: &Value, depth: usize) -> Result<()> { + if depth > 64 { + return Err(bad(cmd, "parameters nested deeper than 64 levels")); + } + match value { + Value::Number(n) => { + finite_f32(cmd, "number", n.as_f64().ok_or_else(|| bad(cmd, "invalid number"))?)?; + } + Value::Array(a) => { + for v in a { + check(cmd, v, depth + 1)?; + } + } + Value::Object(o) => { + for v in o.values() { + check(cmd, v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } + check(cmd, value, 0) +} + +pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { + Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)] + let mut out = [0.0, 0.0, 0.0, 1.0]; + for (slot, value) in out.iter_mut().zip(a) { + *slot = finite_f32(cmd, key, value.as_f64().unwrap_or(0.0))?; + } + out } Some(Value::String(s)) => parse_hex(s).unwrap_or(default), _ => default, - } + }) } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); @@ -968,7 +1006,7 @@ fn build() -> Vec { r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { - let c = color_param(p, "color", s.tools.foreground); + let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); @@ -988,8 +1026,8 @@ fn build() -> Vec { r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, has_doc, |s, p| { - let a = color_param(p, "from", s.tools.foreground); - let b = color_param(p, "to", s.tools.background); + let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; + let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?; let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); @@ -1120,8 +1158,10 @@ fn build() -> Vec { } }; let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); - s.tools.foreground = color_param(p, "foreground", s.tools.foreground); - s.tools.background = color_param(p, "background", s.tools.background); + let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; + let background = color_param("tools.setColors", p, "background", s.tools.background)?; + s.tools.foreground = foreground; + s.tools.background = background; if let Some(k) = fg_cmyk { let [r, g, b, _] = s.tools.foreground; s.tools.remember_cmyk([r, g, b], k); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index c291862167..abd739b842 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -135,7 +135,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; - let color = color_param(p, "color", s.tools.foreground); + let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 2aa0a02bdc..b8e6033cb3 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let source = match contents { "foreground" => color_source(s, s.tools.foreground), "background" => color_source(s, s.tools.background), - "color" => color_source(s, color_param(p, "color", s.tools.foreground)), + "color" => color_source(s, color_param(CMD, p, "color", s.tools.foreground)?), "black" => Source::Color([0.0, 0.0, 0.0, 1.0]), "gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]), "white" => Source::Color([1.0; 4]), diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..8933f838b9 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -594,6 +594,9 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { /// a dialog round trip never touches what it doesn't model: an imported /// gradient stroke keeps its gradient while its opacity is edited. pub fn overlay_effect(fx: &mut Effect, p: &Value) { + if crate::commands::check_f32_values("layer.layerStyle", p).is_err() { + return; + } fn set_color(p: &Value, k: &str, c: &mut Color) { if let Some(x) = color_of(p, k) { *c = x; @@ -821,6 +824,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { /// Builds an effect of `kind` from JSON params (Photoshop units: opacity, /// spread, range in percent; sizes in px; angles in degrees). pub fn effect_from_params(kind: &str, p: &Value) -> Option { + crate::commands::check_f32_values("layer.layerStyle", p).ok()?; let mut fx = fresh_effect(kind)?; overlay_effect(&mut fx, p); Some(fx) @@ -856,6 +860,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; + crate::commands::check_f32_values(CMD, p)?; let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { @@ -905,6 +910,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { } fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { + crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?; let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); @@ -936,6 +942,7 @@ fn make_default(s: &mut Session, p: &Value) -> Result { Some(v) if v.is_object() => v.clone(), _ => return Err(bad(CMD, "`params` must be an object")), }; + crate::commands::check_f32_values(CMD, ¶ms)?; s.presets.layer_defaults.insert(kind.to_string(), params); s.presets_changed(); Ok(json!({"kind": kind})) @@ -1152,17 +1159,25 @@ mod tests { /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width /// is capped at the effect reach, so the document still renders, quickly. #[test] - fn oversized_satin_and_bevel_sizes_render() { - for (cmd, p) in [ - ("satin", json!({"size": 1e308})), - ("satin", json!({"size": 1_000_000})), - ("bevelEmboss", json!({"size": 1e308})), - ("bevelEmboss", json!({"soften": 1e308})), - ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})), + fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() { + for (cmd, p, accepted) in [ + ("satin", json!({"size": 1e308}), false), + ("satin", json!({"size": 1e30}), true), + ("satin", json!({"size": 1_000_000}), true), + ("bevelEmboss", json!({"size": 1e308}), false), + ("bevelEmboss", json!({"soften": 1e308}), false), + ("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true), + ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true), ] { let mut s = session(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()); + if accepted { + result.unwrap(); + } else { + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + } + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); let start = std::time::Instant::now(); s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed()); diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..ff72fbb664 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -62,7 +62,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "edgeExtension" | "extension" => EdgeMode::Extension, "black" => EdgeMode::Color([0.0, 0.0, 0.0, 1.0]), "white" => EdgeMode::Color([1.0, 1.0, 1.0, 1.0]), - "background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])), + "background" | "color" => EdgeMode::Color(crate::commands::color_param(cmd, p, "edgeColor", [0.0, 0.0, 0.0, 1.0])?), other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))), }; let focal = num(p, "focalLength", 0.0); diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 54bbd60b7a..57116e4c8c 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -40,10 +40,12 @@ fn list_json(d: &Document) -> Value { json!({"notes": d.notes.iter().enumerate().map(|(i, n)| note_json(i, n)).collect::>()}) } -fn color_of(p: &Value) -> Option { - p.get("color")?; - let c = crate::commands::color_param(p, "color", [1.0, 1.0, 0.51, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value) -> Result> { + if p.get("color").is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn index_of(s: &Session, p: &Value, cmd: &str) -> Result { @@ -68,7 +70,7 @@ fn add(s: &mut Session, p: &Value) -> Result { modified: pdf_date(), ..Default::default() }; - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } let i = s.edit("New Note", |d, _| { @@ -90,7 +92,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("author").and_then(Value::as_str) { n.author = a.to_string(); } - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } if let Some(o) = p.get("open").and_then(Value::as_bool) { diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..8216b5d2c9 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -34,14 +34,14 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { } fn f32p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64).map(|v| v as f32) + p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } fn color(v: &Value) -> Option { match v { Value::Array(a) if a.len() >= 3 => { let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) + c.iter().take(4).all(|v| v.is_finite()).then(|| Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) } Value::String(s) => { let s = s.trim_start_matches('#'); @@ -743,6 +743,7 @@ fn info(s: &Session, p: &Value) -> Result { /// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type /// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`. pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result { + crate::commands::check_f32_values(cmd, p)?; let orientation = match p.get("orientation") { None => Orientation::Horizontal, Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, diff --git a/crates/engine/tests/finite_command_params.rs b/crates/engine/tests/finite_command_params.rs new file mode 100644 index 0000000000..0350d74493 --- /dev/null +++ b/crates/engine/tests/finite_command_params.rs @@ -0,0 +1,154 @@ +//! #6424: rejecting an overflowing f32 parameter must leave a saveable document and history. +use photocraft_engine::{EngineError, Session}; +use serde_json::{Value, json}; + +fn fresh(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +fn rejected(cases: Vec<(&str, Value)>) { + let mut failures = Vec::new(); + for (id, p) in cases { + let mut s = fresh("rgb", 8); + let before = saved(&s); + let undo = s.active().unwrap().history.undo_label().map(str::to_owned); + let result = s.execute(id, p.clone()); + let save = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()); + if !matches!(result, Err(EngineError::BadParams { .. })) { + failures.push(format!("{id} {p}: result={result:?}, save error={:?}", save.as_ref().err())); + continue; + } + assert_eq!(save.unwrap(), before, "rejected {id} must not change the document"); + assert_eq!(s.active().unwrap().history.undo_label(), undo.as_deref(), "rejected {id} must not add history"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn overflowing_layer_styles_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for value in [1e39, -1e39] { + for (kind, key) in [ + ("dropShadow", "angle"), + ("innerShadow", "angle"), + ("outerGlow", "size"), + ("innerGlow", "size"), + ("stroke", "size"), + ("satin", "angle"), + ("bevelEmboss", "depth"), + ("gradientOverlay", "angle"), + ("patternOverlay", "phaseX"), + ] { + let id = match kind { + "dropShadow" => "layer.layerStyle.dropShadow", + "innerShadow" => "layer.layerStyle.innerShadow", + "outerGlow" => "layer.layerStyle.outerGlow", + "innerGlow" => "layer.layerStyle.innerGlow", + "stroke" => "layer.layerStyle.stroke", + "satin" => "layer.layerStyle.satin", + "bevelEmboss" => "layer.layerStyle.bevelEmboss", + "gradientOverlay" => "layer.layerStyle.gradientOverlay", + _ => "layer.layerStyle.patternOverlay", + }; + let params = json!({key: value}); + cases.push((id, params.clone())); + cases.push(("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]}))); + } + cases.push(("layer.layerStyle.colorOverlay", json!({"color": [value, 0, 0]}))); + } + rejected(cases); +} + +#[test] +fn overflowing_document_colors_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for id in ["layer.newFillLayer.solidColor", "channel.new", "channel.newSpot", "layer.new.artboard", "count.newGroup"] { + for value in [1e39, -1e39] { + for component in 0..4 { + let mut color = [0.5; 4]; + color[component] = value; + cases.push((id, json!({"color": color}))); + } + } + } + rejected(cases); +} + +#[test] +fn overflowing_type_positions_and_colors_are_rejected_before_mutation() { + rejected(vec![ + ("type.create", json!({"text": "hello", "x": 1e39})), + ("type.create", json!({"text": "hello", "y": -1e39})), + ("type.create", json!({"text": "hello", "color": [1e39, 0, 0]})), + ]); +} + +#[test] +fn valid_color_and_style_edits_still_save_and_undo_at_each_depth() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = fresh(mode, depth); + let before = saved(&s); + s.execute("layer.layerStyle.dropShadow", json!({"angle": -45, "color": [0.1, 0.3, 0.8]})).unwrap(); + let edited = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), edited); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + let bytes = saved(&s); + photocraft_format::load_from_bytes(&bytes).unwrap(); + } + } +} + +#[test] +fn effect_snapshots_cannot_bypass_numeric_validation() { + let mut fx = serde_json::to_value(photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap()).unwrap(); + fx["DropShadow"]["angle"] = json!(1e39); + rejected(vec![( + "layer.layerStyle.replace", + json!({"effects": [ + {"kind": "colorOverlay", "params": {"color": "#00ff00"}}, + {"kind": "dropShadow", "fx": fx, "params": {}} + ]}), + )]); +} + +#[test] +fn direct_effect_helpers_do_not_introduce_infinity() { + let original = photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap(); + for p in [json!({"angle": 1e39}), json!({"color": [0, -1e39, 0]})] { + assert!(photocraft_engine::layer_style::effect_from_params("dropShadow", &p).is_none()); + let mut edited = original.clone(); + photocraft_engine::layer_style::overlay_effect(&mut edited, &p); + assert_eq!(edited, original); + } +} + +#[test] +fn bad_background_color_does_not_partially_change_tool_colors() { + let mut s = fresh("rgb", 32); + let before = (s.tools.foreground, s.tools.background); + let result = s.execute("tools.setColors", json!({"foreground": "#40a0c0", "background": [0, 1e39, 0]})); + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + assert_eq!((s.tools.foreground, s.tools.background), before); +} + +#[test] +fn finite_hdr_colors_are_preserved_without_unit_clamping() { + let mut s = fresh("rgb", 32); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": [4.0, -0.5, 0.25]})).unwrap()["layer"].as_u64().unwrap(); + let bytes = saved(&s); + let reopened = photocraft_format::load_from_bytes(&bytes).unwrap(); + let layer = reopened.layer(photocraft_doc::LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Solid(c)) = &layer.content else { panic!("expected a solid fill") }; + assert_eq!(c.to_rgb(), [4.0, -0.5, 0.25]); +} From f59b75b058b954e55d6df67b7d8521536e66ec7b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 09:35:06 +0200 Subject: [PATCH 0322/1452] Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference (fixes #6491) --- crates/ui-egui/src/type_tool.rs | 36 ++++++++++++++------------- crates/ui-egui/src/type_tool_tests.rs | 21 ++++++++++++++++ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..f95b1bdf1b 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2130,6 +2130,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section}; use crate::theme::ROW_GAP; let Some((c, para)) = styles_at(app) else { return }; + // Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points. + let (u, unit) = type_unit(app); let t = crate::theme::Tokens::get(ui.ctx()); // Row helper: `ui.horizontal` with the column gap, then the shared row gap. let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { @@ -2167,13 +2169,13 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let k = shown_scale(app); // Hover tips stay English until wired through `tl!` in a multi-locale change // (`every_tl_literal_is_translated` requires every complete catalog). - if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) { - app.ui.tool_options.type_size = v; - apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k * u, 0.1 * u..=1296.0 * u, unit, w) { + app.ui.tool_options.type_size = v / u; + apply(app, ui.ctx(), json!({"size": v / u, "metricsAsShown": true})); } let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k; - if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) { - apply(app, ui.ctx(), json!({"leading": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead * u, 0.1 * u..=5000.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"leading": v / u, "metricsAsShown": true})); } }); row(ui, &mut |ui| { @@ -2198,8 +2200,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"baselineShift": v})); + if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"baselineShift": v / u})); } // Colour: label + a swatch filling the rest of the second column. ui.horizontal(|ui| { @@ -2306,24 +2308,24 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa }); ui.add_space(ROW_GAP); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"startIndent": v})); + if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"startIndent": v / u})); } - if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"endIndent": v})); + if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"endIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"firstLineIndent": v})); + if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"firstLineIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceBefore": v})); + if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceBefore": v / u})); } - if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceAfter": v})); + if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); let mut hy = para.hyphenate; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 7a23c9ccc3..c2931fc463 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1549,6 +1549,27 @@ mod multi_layer_formatting { assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } + /// #6491: with Units & Rulers ▸ Type set to pixels, the Character and Paragraph fields show + /// and take pixels, like the options bar. 144 ppi so a pixel is not a point. + #[test] + fn character_and_paragraph_fields_follow_the_type_units_preference() { + use egui_kittest::kittest::NodeT; + let (mut app, ids) = fixture(); + app.run("prefs.set", json!({"path": "unitsAndRulers.typeUnits", "value": "pixels"})).unwrap(); + app.run("image.imageSize", json!({"resolution": 144, "resample": "none"})).unwrap(); + selected(&mut app, &[ids[0]]); + let mut h = controls(app, true); + let shown = h.get_by_role_and_label(Role::SpinButton, "Font size").accesskit_node().numeric_value(); + assert_eq!(shown, Some(24.0), "12 pt at 144 ppi"); + number(&mut h, "Font size", "60"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); + number(&mut h, "Baseline shift", "10"); + number(&mut h, "Indent left margin", "9"); + let c = style(h.state(), ids[0], 0); + assert_eq!((c.size_pt, c.leading_pt, c.baseline_shift_pt), (30.0, Some(42.0), 5.0)); + assert_eq!(text(h.state(), ids[0]).paragraph_runs()[0].style.start_indent_pt, 4.5); + } + /// #5717: a layer scaled so its shown size or leading is past a field's range (2400 pt, or /// 0.08 pt) keeps it while the Character panel and the options bar only show it. #[test] From 3c91200d88feef46129c90d60ca15b3f1e261c47 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 12:37:15 +0530 Subject: [PATCH 0323/1452] fix(type): commit a type edit when another layer or document becomes active (#6483) Clicking another layer while the caret was in a type layer left the edit open, so single-key tool shortcuts were typed instead. #5612 covered Layers-panel row clicks; every other route (layer menus, the canvas layer picker, Deselect Layers, automation, a document tab, New/Open) still left the session open. A frame guard (`type_tool::end_if_left`) now commits once the edit's layer isn't the active layer or its document isn't active. `TextEdit` records its document: layer ids repeat across documents (one .pcraft opened twice), and typing after a tab switch used to edit the other document's same-id layer. Commits run in the edit's own document; a closed document's edit is dropped without running anything. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/hold_keys/tests.rs | 1 + crates/ui-egui/src/input_tests.rs | 3 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/spring_tools/tests.rs | 1 + crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/swatches_ui.rs | 1 + crates/ui-egui/src/type_commit_tests.rs | 157 +++++++++++++++++++ crates/ui-egui/src/type_font_picker_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 114 +++++++++++++- crates/ui-egui/src/type_tool_tests.rs | 7 + 11 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/type_commit_tests.rs diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index cfe89d4642..8c1f753e77 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -186,6 +186,7 @@ fn space_while_editing_type_is_not_the_hand_tool() { let id = h.state_mut().run("type.create", json!({"text": "ئا", "size": 20, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 2, anchor: 2, session: "t".into(), diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..15bf88dc5c 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -474,6 +474,7 @@ fn type_tool_alt_arrows_kern_the_pair() { let metric = photocraft_text::shared().lock().unwrap().pair_kerning(&text(&h), 72.0, 0).unwrap().round(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 1, anchor: 1, session: "kern-test".into(), @@ -524,6 +525,7 @@ fn type_tool_alt_arrows_kern_the_pair() { fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer, + document: None, caret, anchor, session: "format-keys".into(), @@ -742,6 +744,7 @@ fn type_tool_format_keys_survive_hostile_state() { for ed in [None, Some(1)] { app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { layer, + document: None, caret: 4, anchor: 0, session: "x".into(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..16722fe47e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1836,6 +1836,8 @@ impl eframe::App for PhotocraftApp { if self.ui.text_edit.as_ref().is_some_and(|ed| !self.ui.tool.is_type() || ed.mask != self.ui.tool.is_type_mask()) { type_tool::commit(self); } + // So does selecting another layer or document by any route (#6483). + type_tool::end_if_left(self); if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { vector_ui::pen_commit(self, false); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4793554150..3468426182 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4383,6 +4383,7 @@ mod color_tests { let anchor = if selected { 6 } else { 11 }; app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor, session: "s".into(), @@ -5076,6 +5077,7 @@ mod swatch_type_tests { app.ui.tool = Tool::Type; app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 0, anchor: 5, session: "s".into(), diff --git a/crates/ui-egui/src/spring_tools/tests.rs b/crates/ui-egui/src/spring_tools/tests.rs index eef327c096..e3d13a043a 100644 --- a/crates/ui-egui/src/spring_tools/tests.rs +++ b/crates/ui-egui/src/spring_tools/tests.rs @@ -210,6 +210,7 @@ fn letters_type_while_editing_text() { let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "a", "size": 40})).unwrap()["layer"].as_u64().unwrap(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 1, anchor: 1, session: "spring-test".into(), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..a0db32fa4c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -971,6 +971,10 @@ pub enum MadeLayer { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { pub layer: u64, + /// Id of the document being edited (layer ids can repeat across documents, e.g. one .pcraft + /// opened twice). `None`: the active document. + #[serde(default)] + pub document: Option, pub caret: usize, pub anchor: usize, /// History coalescing key: the whole editing session is one "Edit Type Layer" step. diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..e3a4b4636d 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -327,6 +327,7 @@ mod tests { app.ui.tool = Tool::Type; app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 0, anchor: 5, session: "s".into(), diff --git a/crates/ui-egui/src/type_commit_tests.rs b/crates/ui-egui/src/type_commit_tests.rs new file mode 100644 index 0000000000..551706ec0f --- /dev/null +++ b/crates/ui-egui/src/type_commit_tests.rs @@ -0,0 +1,157 @@ +//! A type edit ends when its layer stops being the one worked on (#6483): another layer or +//! another document became active. Photoshop commits the text then; left open, the session kept +//! taking single-key shortcuts as typing and could type into a layer of the wrong document. + +use egui_kittest::Harness; +use photocraft_doc::{LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Type; + app +} + +fn text_of(app: &PhotocraftApp, doc: usize, id: LayerId) -> Option { + match &app.session.documents().get(doc)?.doc.layer(id)?.content { + LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + } +} + +/// Start a new type layer with a click and type `s` into it, as the canvas does. +fn type_new(app: &mut PhotocraftApp, s: &str) -> LayerId { + super::pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + super::insert(app, s); + LayerId(app.ui.text_edit.as_ref().unwrap().layer) +} + +/// Feed one frame of key handling with `events`. +fn keys(app: PhotocraftApp, events: Vec) -> PhotocraftApp { + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + super::handle_keys(app, ui.ctx()); + }, + app, + ); + h.run_steps(1); + for e in events { + h.event(e); + } + h.run_steps(1); + h.into_state() +} + +#[test] +fn typing_after_a_document_switch_never_edits_the_other_document() { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": "Bee", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + // A second document with the same layer ids, as when one .pcraft is opened twice (its saved + // ids are kept; only the document id is made unique). + let copy = (*app.session.active().unwrap().doc).clone(); + app.session.add_document(copy, None); + app.sync_views(); + // Edit the copy's type layer (index 1), then switch to the first document's tab and type. + crate::type_tool::edit_active(&mut app).unwrap(); + super::insert(&mut app, "Ay"); + assert!(app.session.set_active(0)); + super::end_if_left(&mut app); + let app = keys(app, vec![egui::Event::Text("Z".into())]); + assert_eq!(text_of(&app, 0, id).as_deref(), Some("Bee"), "the other document is untouched"); + assert_eq!(text_of(&app, 1, id).as_deref(), Some("Ay"), "the edited copy keeps its text"); + assert!(app.ui.text_edit.is_none(), "the copy's edit was committed"); + assert_eq!(app.session.active_index(), Some(0), "and the document switched to stays active"); +} + +#[test] +fn selecting_another_layer_commits_the_edit() { + let mut app = new_app(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + let text = type_new(&mut app, "Hello"); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_some(), "a session's own layer is active: it goes on"); + // The Layers panel menus, the canvas layer picker, ⌥[ / ⌥] and automation all select this way. + app.run("layer.select", json!({"layer": other.0})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer, Some(other), "the selection stands"); + assert_eq!(st.doc.layer(text).unwrap().name, "Hello", "committed: the new layer is named after its text"); + assert_eq!(text_of(&app, 0, text).as_deref(), Some("Hello")); +} + +#[test] +fn an_empty_new_layer_goes_when_another_layer_is_selected() { + let mut app = new_app(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + let text = type_new(&mut app, ""); + app.run("layer.select", json!({"layer": other.0})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + assert!(app.session.active().unwrap().doc.layer(text).is_none(), "an empty new type layer is deleted on commit"); +} + +#[test] +fn deselecting_all_layers_commits_the_edit() { + let mut app = new_app(); + type_new(&mut app, "Hello"); + app.run("select.deselectLayers", json!({})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); +} + +#[test] +fn a_type_mask_session_goes_on_while_its_layer_is_active() { + let mut app = new_app(); + app.ui.tool = crate::state::Tool::HorizontalTypeMask; + type_new(&mut app, "Mask"); + super::end_if_left(&mut app); + let ed = app.ui.text_edit.as_ref().expect("the Type Mask session is still open"); + assert!(ed.mask); +} + +#[test] +fn closing_the_edited_document_drops_the_edit_without_touching_another() { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": "Bee", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let copy = (*app.session.active().unwrap().doc).clone(); + app.session.add_document(copy, None); + app.sync_views(); + crate::type_tool::edit_active(&mut app).unwrap(); + super::insert(&mut app, ""); + assert!(app.session.close(1).is_some()); + let before = app.session.active().unwrap().revision; + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + assert_eq!(app.session.active().unwrap().revision, before, "nothing ran in the remaining document"); + assert_eq!(text_of(&app, 0, id).as_deref(), Some("Bee"), "its same-id type layer keeps its text"); +} + +/// The issue's second symptom: after a layer is selected, single-key tool shortcuts work again +/// instead of typing into the type layer. +#[test] +fn tool_shortcuts_work_after_another_layer_is_selected() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let app = h.state_mut(); + app.ui.tool = crate::state::Tool::Type; + let other = app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap(); + let text = type_new(app, "Hello"); + app.run("layer.select", json!({"layer": other})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "the frame commits the edit"); + h.key_press(egui::Key::V); + h.event(egui::Event::Text("v".into())); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move, "V picks the Move tool"); + assert_eq!(text_of(h.state(), 0, text).as_deref(), Some("Hello"), "and types nothing"); +} diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 176e683a9d..e542db2083 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -361,6 +361,7 @@ fn hovering_a_font_previews_only_the_selected_characters() { app.run("type.setStyle", json!({"layer": layer.0, "range": [0, 2], "font": "JetBrains Mono"})).unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: layer.0, + document: None, caret: 5, anchor: 3, session: "font-preview-test".into(), diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..1bed24c2c6 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -114,8 +114,18 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { commit(app); let key = session_key(app); begin_edit(app, id, &key)?; - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: n, anchor: 0, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: n, + anchor: 0, + session: key, + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); } else if let Some(ed) = app.ui.text_edit.as_mut() { ed.anchor = 0; ed.caret = n; @@ -143,8 +153,18 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { return false; } let off = hit_offset(app, id, x, y); - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: off, + anchor: off, + session: key, + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); if !previous_tool.is_type() { app.ui.text_edit_return_tool = Some(previous_tool); @@ -274,8 +294,18 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie return true; } let off = hit_offset(app, id, x, y); - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: off, + anchor: off, + session: key, + created: false, + mask: false, + dragging: true, + resize: None, + preedit: None, + }); app.ui.text_edit_return_tool = None; return true; } @@ -340,7 +370,18 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { } // Like Photoshop: the placeholder is selected, so typing replaces it. let n = text.chars().count(); - app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, mask, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id, + document: active_doc_id(app), + caret: n, + anchor: 0, + session: key, + created: true, + mask, + dragging: false, + resize: None, + preedit: None, + }); app.ui.text_edit_return_tool = None; } } @@ -349,6 +390,10 @@ fn session_key(app: &mut PhotocraftApp) -> String { format!("type-{}", app.ui.alloc_id()) } +fn active_doc_id(app: &PhotocraftApp) -> Option { + app.session.active().map(|st| st.doc.id.0) +} + fn current_text(app: &PhotocraftApp, id: LayerId) -> Option { Some(text_layer(&app.session.active()?.doc, id)?.text.clone()) } @@ -768,6 +813,7 @@ fn line_edge(app: &mut PhotocraftApp, id: LayerId, caret: usize, end: bool) -> u /// Keyboard input while editing. Returns true when a type edit session is active (single-key /// tool shortcuts must then be skipped). Handled events are removed from the frame's input. pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + end_if_left(app); crate::type_transform::cancel_stale(app); if !ctx.input(|i| i.focused) { crate::type_transform::cancel_drag(app); @@ -963,6 +1009,40 @@ pub fn commit(app: &mut PhotocraftApp) { /// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it /// replaces the selection. pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { + let current = app.session.active_index(); + let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { + return commit_here(app, mods); + }; + match home { + Some(home) if Some(home) != current => { + // An edit left in a background document commits there, in its own history (#6483). + app.session.set_active(home); + commit_here(app, mods); + if let Some(i) = current { + app.session.set_active(i); + } + } + Some(_) => commit_here(app, mods), + // Its document closed: there is nothing to commit into, and the layer id may name a + // layer of another document. + None => { + crate::type_transform::reset(app); + app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; + } + } +} + +/// Index of the document an edit belongs to; `None` once it is closed. +fn edit_home(app: &PhotocraftApp, ed: &TextEdit) -> Option { + match ed.document { + Some(d) => app.session.documents().iter().position(|st| st.doc.id.0 == d), + None => app.session.active_index(), + } +} + +/// [`commit_with`] in the active document, which is the edit's own. +fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { if app.ui.tool.is_type() { crate::type_transform::finish(app); } @@ -997,9 +1077,25 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { } } +/// Commit the edit once its layer is no longer the one worked on (#6483): another layer became +/// active (a Layers panel menu, the canvas layer picker, automation) or another document did (a +/// tab, File ▸ New/Open). Photoshop commits then; left open, the session took single-key +/// shortcuts as typing and typed into whichever document was active. +pub fn end_if_left(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.as_ref() else { return }; + let here = edit_home(app, ed).is_some_and(|h| Some(h) == app.session.active_index()); + if !here || app.session.active().is_some_and(|st| st.active_layer != Some(LayerId(ed.layer))) { + commit(app); + } +} + /// Selection highlight, caret and text frame over the canvas. pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(ed) = app.ui.text_edit.clone() else { return }; + // Until it commits, an edit in a document no longer active draws nothing over this one. + if edit_home(app, &ed) != app.session.active_index() { + return; + } let id = LayerId(ed.layer); let Some((l, aff, text)) = layout(app, id) else { return }; if ed.mask { @@ -2738,3 +2834,7 @@ mod tests { assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); } } + +#[cfg(test)] +#[path = "type_commit_tests.rs"] +mod commit_tests; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 7a23c9ccc3..90904b8a73 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -20,6 +20,7 @@ fn focused_font_search_does_not_type_into_the_canvas_layer() { let id = app.run("type.create", json!({"text": "Sample", "size": 24})).unwrap()["layer"].as_u64().unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 6, anchor: 6, session: "s".into(), @@ -357,6 +358,7 @@ fn size_applies_at_layer_and_selection_scope() { // Selection scope: "world" only. app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor: 6, session: "s".into(), @@ -504,6 +506,7 @@ fn foreground_colour_recolours_only_selected_type() { assert_eq!(rgb_at(&app, id, 0), [0, 0, 0, 255], "not editing: unchanged"); let edit = |caret, anchor| crate::state::TextEdit { layer: id.0, + document: None, caret, anchor, session: "s".into(), @@ -593,6 +596,7 @@ fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor: 6, session: "text-color-dialog-test".into(), @@ -650,6 +654,7 @@ fn empty_ime_events_never_delete_the_selection() { let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 40, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 5, anchor: 0, session: "s".into(), @@ -1427,6 +1432,7 @@ mod multi_layer_formatting { fn editing(app: &mut PhotocraftApp, id: LayerId) { app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 3, anchor: 1, session: "batch-type-edit".into(), @@ -2014,6 +2020,7 @@ fn multi_click_without_a_session_does_nothing() { assert!(app.ui.text_edit.is_none() && app.type_clicks.is_none()); app.ui.text_edit = Some(crate::state::TextEdit { layer: 999_999, + document: None, caret: 5, anchor: 5, session: "stale".into(), From 444ee06e1a841ff84b3255ca06b05268e74166a0 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 13:13:48 +0530 Subject: [PATCH 0324/1452] fix(type): keep the layer just selected when ending a Type Mask (#6483 review) Ending an empty Type Mask undoes its session, and undo restores the layer selection from before it, so selecting another layer (or Deselect Layers) during the session was reversed. `end_if_left` now restores the selection the user made when the commit moved it, through a new `Session::restore_layer_selection` (drops gone layers, refuses a gone active layer, keeps a clean document clean). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 25 ++++++++++++++++++++++ crates/engine/src/tests.rs | 22 +++++++++++++++++++ crates/ui-egui/src/type_commit_tests.rs | 28 +++++++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 18 +++++++++++++--- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8d6167c3d..d382310fed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -867,6 +867,31 @@ impl Session { Ok(()) } + /// Put back a layer selection (active layer, selected layers, anchor) that a step moved, such + /// as the undo ending an empty Type Mask, which restores the selection from before it. `None` + /// is no layer selected. Selected layers that are gone are dropped; an active layer that is + /// gone is an error and changes nothing. Like [`Self::select_layer`], not an edit. + pub fn restore_layer_selection(&mut self, active: Option, selected: &[LayerId], anchor: Option) -> Result<()> { + let st = self.active_mut().ok_or(EngineError::NoDocument)?; + if let Some(id) = active + && st.doc.layer(id).is_none() + { + return Err(EngineError::NoLayer(id)); + } + st.active_layer = active; + st.selected_layers = selected.to_vec(); + st.layer_anchor = anchor.filter(|_| active.is_some()); + fix_selection(st); + channel_cmds::fix_view(st); + let clean = st.saved_revision == st.revision; + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + Ok(()) + } + /// Re-renders the pixels of type layers whose font arrived after they were drawn (the web /// build fetches served fonts on demand, `photocraft_text::served`). The layers' model is /// unchanged, so this is no history step and leaves a clean document clean. Unknown documents diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..934471164e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1580,3 +1580,25 @@ fn merge_down_on_a_group_merges_the_group() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some()); } + +/// `restore_layer_selection` puts a multi-layer selection back, drops gone layers, keeps a clean +/// document clean, and refuses a gone active layer without changing anything. +#[test] +fn restore_layer_selection_puts_back_what_still_exists() { + let mut s = session_with_doc(); + let id = |v: Value| LayerId(v["layer"].as_u64().unwrap()); + let a = id(s.execute("layer.new.layer", json!({})).unwrap()); + let b = id(s.execute("layer.new.layer", json!({})).unwrap()); + let gone = LayerId(u64::MAX); + s.active_mut().unwrap().saved_revision = s.active().unwrap().revision; + s.restore_layer_selection(Some(b), &[a, gone, b], Some(a)).unwrap(); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (Some(b), vec![a, b], Some(a))); + assert_eq!(st.saved_revision, st.revision, "selection is not an edit"); + assert!(matches!(s.restore_layer_selection(Some(gone), &[gone], None), Err(EngineError::NoLayer(_)))); + assert_eq!(s.active().unwrap().active_layer, Some(b), "an error changes nothing"); + s.restore_layer_selection(None, &[a], Some(a)).unwrap(); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected"); + assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document"); +} diff --git a/crates/ui-egui/src/type_commit_tests.rs b/crates/ui-egui/src/type_commit_tests.rs index 551706ec0f..e25fc33977 100644 --- a/crates/ui-egui/src/type_commit_tests.rs +++ b/crates/ui-egui/src/type_commit_tests.rs @@ -155,3 +155,31 @@ fn tool_shortcuts_work_after_another_layer_is_selected() { assert_eq!(h.state().ui.tool, crate::state::Tool::Move, "V picks the Move tool"); assert_eq!(text_of(h.state(), 0, text).as_deref(), Some("Hello"), "and types nothing"); } + +/// Ending an empty Type Mask undoes its session, and undo restores the layer selection from +/// before it: the layer just selected (or Deselect Layers) must stand. So must it when a Type +/// Mask with text becomes the selection and its working layer goes. +#[test] +fn ending_a_type_mask_keeps_the_new_layer_selection() { + for (deselect, text) in [(false, ""), (true, ""), (false, "Mask"), (true, "Mask")] { + let mut app = new_app(); + let first = LayerId(app.run("layer.new.layer", json!({"name": "First"})).unwrap()["layer"].as_u64().unwrap()); + let second = LayerId(app.run("layer.new.layer", json!({"name": "Second"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + app.ui.tool = crate::state::Tool::HorizontalTypeMask; + let mask = type_new(&mut app, text); + if deselect { + app.run("select.deselectLayers", json!({})).unwrap(); + } else { + app.run("layer.select", json!({"layer": second.0})).unwrap(); + } + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(mask).is_none(), "{text:?}: the mask's working layer is gone"); + assert_eq!(st.doc.selection.is_some(), !text.is_empty(), "{text:?}: text becomes the selection"); + let want = if deselect { None } else { Some(second) }; + assert_eq!(st.active_layer, want, "{text:?}, deselect={deselect}: the new selection stands"); + assert_eq!(st.selected_layers(), want.into_iter().collect::>(), "{text:?}, deselect={deselect}"); + } +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 1bed24c2c6..2fe19a1ec6 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1083,9 +1083,21 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { /// shortcuts as typing and typed into whichever document was active. pub fn end_if_left(app: &mut PhotocraftApp) { let Some(ed) = app.ui.text_edit.as_ref() else { return }; - let here = edit_home(app, ed).is_some_and(|h| Some(h) == app.session.active_index()); - if !here || app.session.active().is_some_and(|st| st.active_layer != Some(LayerId(ed.layer))) { - commit(app); + let layer = LayerId(ed.layer); + if edit_home(app, ed).is_none_or(|h| Some(h) != app.session.active_index()) { + return commit(app); + } + let Some(st) = app.session.active() else { return }; + if st.active_layer == Some(layer) { + return; + } + // The selection just made stands: ending an empty Type Mask undoes its session, and undo + // restores the selection from before it. + let chosen = (st.active_layer, st.selected_layers.clone(), st.layer_anchor); + commit(app); + let moved = app.session.active().is_some_and(|st| (st.active_layer, &st.selected_layers, st.layer_anchor) != (chosen.0, &chosen.1, chosen.2)); + if moved { + let _ = app.session.restore_layer_selection(chosen.0, &chosen.1, chosen.2); } } From 9c914a9fd29bf27d8e1ecd72203578cc52412c8e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 15:58:41 +0800 Subject: [PATCH 0325/1452] Refuse Affinity spot colours nested more than 4 levels deep paint::color followed a spot colour's RGB equivalent and base colour with no depth limit. Colours are shared objects, so a spot colour that links to itself overflowed the stack and aborted the import, and a chain whose levels link to the next through both took exponential time. Stop after 4 levels of spot colours defined through spot colours, with a warning. Co-Authored-By: Claude Opus 5.5 --- crates/affinity/README.md | 2 +- crates/affinity/src/paint.rs | 17 +++- crates/affinity/src/tests.rs | 171 +++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 3e4e066156..e881352c8b 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -139,7 +139,7 @@ zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve -nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient +nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff8446..343c276b60 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option> { } } +/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB +/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared +/// objects, so one can link to itself, and with both links followed at every level a colour read +/// costs up to 2^(depth+1) lookups. +const MAX_SPOT_DEPTH: usize = 4; + /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_at(r, id, 0) +} + +/// [`color`] of an object `depth` spot colours down. +fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } } b"Pant" => { + if depth >= MAX_SPOT_DEPTH { + r.warn("spot colours nested deeper than 4 levels (left empty)"); + return None; + } r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 59ae99044a..2c68a064a3 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -538,3 +538,174 @@ mod pixels { ); } } + +/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB +/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process +/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded +/// recursion fails the one test that caused it instead of taking the other tests in the binary +/// down with it. +mod spot_colours { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::paint::{Color, Paint}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + /// A one-page document whose page holds `top`, read in a child process first and then here. + fn read(top: Vec) -> Document { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let test = std::thread::current().name().unwrap_or("reader").to_owned(); + read_in_child(&bytes, &test); + read_on_small_stack(bytes, &test).unwrap() + } + + /// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test. + fn read_on_small_stack(bytes: Vec, test: &str) -> Result { + let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap(); + reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + } + + /// Set in the child process to the name of the test it reads for. + const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD"; + + /// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if + /// that process crashes, panics or takes over 20 s, or never read the document. + fn read_in_child(bytes: &[u8], test: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .env(CHILD, test) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let drain = |mut pipe: Box| { + std::thread::spawn(move || { + let mut text = String::new(); + let _ = pipe.read_to_string(&mut text); + text + }) + }; + let stdout = drain(Box::new(child.stdout.take().unwrap())); + let stderr = drain(Box::new(child.stderr.take().unwrap())); + // Fed from a thread, so a child that never reads cannot block the deadline below; a child + // that dies first breaks the pipe and is reported by its exit status. + let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec()); + let feed = std::thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + feed.join().unwrap(); + let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap()); + match status { + None => panic!("reading the document did not finish within 20 s:\n{output}"), + Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"), + // A filter that matched no test would also exit successfully, having read nothing. + Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"), + } + } + + /// Not a test of its own: `read_in_child` runs it in a child process to read the document on + /// its stdin. Without the variable, as when run with `--ignored`, it does nothing. + #[test] + #[ignore = "run by read_in_child in a child process"] + fn read_child() { + let Some(test) = std::env::var_os(CHILD) else { return }; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + let len = bytes.len(); + let _ = read_on_small_stack(bytes, &test.to_string_lossy()); + println!("read {len} bytes"); + } + + /// An ellipse filled with `colour`. + fn filled(id: u32, colour: F) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])), + (tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])), + (tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])), + ], + ) + } + + fn spot(id: u32, fields: Vec<(Tag, F)>) -> F { + F::Def(id, vec![tag(b"Pant")], fields) + } + + fn fills(doc: &Document) -> &[Paint] { + match &doc.spreads[0].nodes[0].kind { + Kind::Shape { fills, .. } => fills, + other => panic!("expected a shape, got {other:?}"), + } + } + + fn warned(doc: &Document, what: &str) -> bool { + doc.warnings.iter().any(|w| w.contains(what)) + } + + #[test] + fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_of_spot_colours_do_not_take_exponential_time() { + // Each level's RGB equivalent and base are the next level, and the last is in an unknown + // model, so both are followed everywhere: 2^41 colour reads before the fix. + fn level(id: u32, left: u32) -> F { + if left == 0 { + return F::Def(id, vec![tag(b"XXXX")], vec![]); + } + spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))]) + } + let doc = read(vec![filled(10, level(100, 40))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + } + + /// `spots` spot colours, each defined through the next, around an opaque RGB colour. + fn spots(id: u32, spots: u32) -> F { + if spots == 0 { + let rgba: Vec = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect(); + return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]); + } + spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))]) + } + + #[test] + fn spot_colours_nest_four_levels_and_no_deeper() { + let doc = read(vec![filled(10, spots(100, 4))]); + assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]); + let doc = read(vec![filled(10, spots(100, 5))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } +} From 4d6f3f4d1e2d87056898d1ae589d0f298afb03d4 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Fri, 16 Oct 2026 11:31:52 +0200 Subject: [PATCH 0326/1452] style: cargo fmt (review on #6499) --- apps/photocraft/src/main.rs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 337399d99a..085adff597 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -289,7 +289,7 @@ mod control_port_tests { #[cfg(test)] mod launch_args_tests { - use super::{control_args_exit_code, parse_launch_args, LaunchArgs}; + use super::{LaunchArgs, control_args_exit_code, parse_launch_args}; use std::ffi::OsString; fn parse(values: &[&str]) -> LaunchArgs { @@ -312,11 +312,7 @@ mod launch_args_tests { #[test] fn missing_value_does_not_clear_an_environment_default() { // `--automation-read-root` with no value must not drop a root set through the env var. - let defaults = LaunchArgs { - automation_read_root: Some("/env/read".into()), - automation_write_root: Some("/env/write".into()), - ..Default::default() - }; + let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); @@ -325,7 +321,24 @@ mod launch_args_tests { #[test] fn values_are_parsed_and_no_errors_remain() { - let parsed = parse(&["--control", "50494", "--control-token", "abcd", "--control-token-file", "/tmp/token", "--automation-read-root", "/r", "--automation-write-root", "/w", "--safe-gpu", "--in-window-menus", "--new-instance", "--present-mode", "mailbox", "photo.psd"]); + let parsed = parse(&[ + "--control", + "50494", + "--control-token", + "abcd", + "--control-token-file", + "/tmp/token", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "mailbox", + "photo.psd", + ]); assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); assert_eq!(parsed.control_port, Some(50494)); assert_eq!(parsed.control_token.as_deref(), Some("abcd")); From 1302e08791731d88506f64634308d3f9035eeb8e Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:04:34 +0200 Subject: [PATCH 0327/1452] Arrange: the shown tiles stay put when a visible document is activated (#6017) With more documents than tiles, the run of shown documents was worked out from the active index alone, so after activating document 2 in Two-up, a click into the visible document 1 moved the tiles back to [0, 1]. The run is now kept from frame to frame and slides only when a hidden document becomes active. --- crates/ui-egui/src/canvas.rs | 8 +++++--- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/view_cmds/tests.rs | 8 +++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 05ee6ba855..cd6d3739c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2058,9 +2058,10 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option>() + let shown: Vec = crate::canvas::arranged_cells(app, rect).unwrap().iter().map(|(d, _)| *d).collect(); + app.arrange_start = shown[0]; + shown }; assert_eq!(docs(&mut app, 0), vec![0, 1]); assert_eq!(docs(&mut app, 1), vec![0, 1], "the second tile's document stays on the right"); assert_eq!(docs(&mut app, 2), vec![1, 2], "the third document slides in next to the second"); + assert_eq!(docs(&mut app, 1), vec![1, 2], "a click into the visible document 1 leaves the tiles as they are"); + assert_eq!(docs(&mut app, 0), vec![0, 1], "a hidden document slides in from the left"); + assert_eq!(docs(&mut app, 1), vec![0, 1]); } /// #6017: rulers showed in the active tile only; the other tile had none and no room for them. From c467592211fe49c5a947cecf0b29b793076b805e Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 13:12:19 +0200 Subject: [PATCH 0328/1452] Docs: FILE-215-2 lists Perspective Warp smart filters as mapped (#6268) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..e2c1926336 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-16 · **Change:** minor (PSD smart filters: Perspective Warp mapped both ways, #6268, FILE-215-2; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..2da48695b1 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -101,7 +101,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters, Perspective Warp and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..c1a2b268f6 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 14 filters, Perspective Warp and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From 1e3971413fb51f7796d37191c9f4438ba7637b79 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 13:12:19 +0200 Subject: [PATCH 0329/1452] Docs: FILE-215-2 lists Hue/Saturation smart filters as mapped (#6267) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..29693d2c66 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-16 · **Change:** minor (PSD smart filters: Hue/Saturation mapped both ways, #6267, FILE-215-2; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | PSD smart filters: Hue/Saturation (`HStr`: `Clrz`, `Adjs` = master `Hst2` plus one per edited range with `LclR` and its four range sliders, all longs, measured on a Photoshop-saved file) opens as `image.adjustments.hueSaturation`, re-renders with the smart object and saves in Photoshop's layout; layouts outside it stay verbatim (FILE-215-2, #6267) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..947e107ce0 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -101,7 +101,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters, Hue/Saturation and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..64ec6dd000 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 14 filters, Hue/Saturation and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" From 9b5cb2939551f8234378a78668af8cb3d9f4f703 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 17:46:16 +0530 Subject: [PATCH 0330/1452] Canvas overlays stay visible while panning (#6592) Every canvas overlay (the Pen path being drawn, paths, guides, grid, transform box, crop/slice/gradient overlays) was skipped for as long as a Hand drag lasted (the tool, Space or the middle button), so the Pen path vanished while panning. Overlays now always draw; only the tool cursors yield to the fist (#2196). The transform-only workaround from #3610 is folded into the general fix. Test: the_pen_path_being_drawn_stays_visible_while_space_pans (fails without the fix); transform_preview_stays_visible_while_panning stays green. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 14 +++++---- crates/ui-egui/src/hold_keys/tests.rs | 44 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..440d355218 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3794,7 +3794,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); - if primary && !(hand_pan && response.dragged()) { + // A Hand drag (the tool, Space or the middle button) keeps every overlay on screen, as in + // Photoshop: the Pen path being drawn, paths, guides, grid, transform box (#6592). Only the + // tool cursors below give way to the fist. + let panning = hand_pan && response.dragged(); + if primary { // The transform preview is image content: it goes under the grid and guides, which // stay visible across a layer being transformed (#5086); the box is drawn later. crate::transform_tool::draw_content(app, &painter, &xf); @@ -3857,7 +3861,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } command_guide_at(app, tool, d, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers))) }); - if let Some((vertical, _)) = guide_hover { + if panning { + // The fist set with the pan above stays (#2196). + } else if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(crate::rulers::guide_cursor(&xf, vertical)); } else if let Some(p) = response.hover_pos() && let Some(c) = crate::transform_tool::cursor(app, &xf, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) @@ -4088,10 +4094,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift); } } - if primary && hand_pan && response.dragged() { - crate::transform_tool::draw_content(app, &painter, &xf); - crate::transform_tool::draw_overlay(app, &painter, &xf); - } app.tool_override = None; if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index cfe89d4642..f4e0064412 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -180,6 +180,50 @@ fn space_is_a_temporary_hand_and_gives_the_tool_back() { assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); } +/// The open paths the last frame painted, as their screen points. +fn painted_paths(h: &Harness<'static, PhotocraftApp>) -> Vec> { + fn walk(s: &egui::Shape, out: &mut Vec>) { + match s { + egui::Shape::Path(p) if !p.closed => out.push(p.points.clone()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, &mut out); + } + out +} + +#[test] +fn the_pen_path_being_drawn_stays_visible_while_space_pans() { + // #6592: the path vanished for as long as the Hand dragged. + let mut h = harness(Tool::Pen); + click(&mut h, 100.0, 100.0, Modifiers::NONE); + click(&mut h, 200.0, 100.0, Modifiers::NONE); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + key(&mut h, Key::Space, true, Modifiers::NONE); + let before = h.state().ui.views[0].center; + press_at(&mut h, 150.0, 200.0); + move_to(&mut h, 190.0, 230.0); + assert_ne!(h.state().ui.views[0].center, before, "the view is panning"); + // Mid-drag: the segment is painted where its anchors now are on screen. + let (a, b) = (screen(&h, 100.0, 100.0), screen(&h, 200.0, 100.0)); + let near = |p: &Pos2, q: Pos2| p.distance(q) < 1.0; + assert!( + painted_paths(&h).iter().any(|pts| pts.first().is_some_and(|p| near(p, a)) && pts.last().is_some_and(|p| near(p, b))), + "the Pen path from {a:?} to {b:?} is drawn while panning" + ); + assert_eq!(h.output().platform_output.cursor_icon, crate::tool_cursor::hand(&h.ctx, true), "the fist while panning (#2196)"); + release(&mut h, 190.0, 230.0, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + let st = h.state(); + assert_eq!(st.ui.pen.as_ref().map(|p| p.knots.len()), Some(2), "the path is still being drawn"); + assert_eq!(st.ui.tool, Tool::Pen); +} + #[test] fn space_while_editing_type_is_not_the_hand_tool() { let mut h = harness(Tool::Type); From ac8d0af24c159185774ae141fb024098135867ab Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 15:00:11 +0200 Subject: [PATCH 0331/1452] Hue/Saturation: the Lightness track runs black, grey, white (#6539) The Lightness slider's track in the Hue/Saturation editor (Properties panel and dialog, which share it) ran black -> the current hue -> white. Photoshop's Lightness track is a fixed black -> mid grey -> white ramp whatever the Hue. lightness_track() now returns that ramp and no longer takes the hue; the Saturation track still follows the hue. The UI-217-16 scorecard row describes the new track. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/adjust_editors.rs | 25 +++++++++++++++++-------- docs/scorecard.md | 2 +- scorecard/ui.toml | 4 ++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 87a81b8c35..eb1f08464e 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -858,9 +858,10 @@ fn saturation_track(hue_deg: f32) -> [Color32; 2] { [Color32::from_gray(128), hue_color(hue_deg)] } -/// The Lightness slider's track: black, the hue, white (UI-217-16). -fn lightness_track(hue_deg: f32) -> [Color32; 3] { - [Color32::BLACK, hue_color(hue_deg), Color32::WHITE] +/// The Lightness slider's track: black, mid grey, white, whatever the hue, as in Photoshop +/// (UI-217-16, #6539). +fn lightness_track() -> [Color32; 3] { + [Color32::BLACK, Color32::from_gray(128), Color32::WHITE] } fn move_hue_range_handle(bounds: [f32; 4], k: usize, deg: f32) -> [f32; 4] { @@ -926,7 +927,8 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { v["hue"] = json!(h.round()); } e.add(Edit::of(&r)); - // The Saturation and Lightness tracks follow the current hue, as in Photoshop (UI-217-16). + // The Saturation track follows the current hue; the Lightness track is a fixed + // black-grey-white ramp, as in Photoshop (UI-217-16). let sat_track = saturation_track(h); let mut s = num(v, "saturation", 0.0).clamp(slo, 100.0); let r = widgets::slider_row(ui, tl!("Saturation"), &mut s, slo..=100.0, "%", track_stops(cx, &sat_track)); @@ -934,7 +936,7 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { v["saturation"] = json!(s.round()); } e.add(Edit::of(&r)); - let light_track = lightness_track(h); + let light_track = lightness_track(); let mut l = num(v, "lightness", 0.0).clamp(-100.0, 100.0); let r = widgets::slider_row(ui, tl!("Lightness"), &mut l, -100.0..=100.0, "%", track_stops(cx, &light_track)); if r.changed() { @@ -1681,12 +1683,19 @@ mod tests { use super::*; #[test] - fn saturation_and_lightness_tracks_follow_the_hue() { - // The S/L slider tracks recolour with the hue (UI-217-16). + fn saturation_track_follows_the_hue() { + // The Saturation slider track recolours with the hue (UI-217-16). assert_eq!(saturation_track(0.0), [Color32::from_gray(128), hue_color(0.0)]); assert_ne!(saturation_track(0.0)[1], saturation_track(120.0)[1]); assert_eq!(saturation_track(720.0)[1], saturation_track(0.0)[1], "hue wraps"); - assert_eq!(lightness_track(200.0), [Color32::BLACK, hue_color(200.0), Color32::WHITE]); + } + + #[test] + fn lightness_track_is_black_grey_white_whatever_the_hue() { + // Photoshop's Lightness track does not take the hue's colour (#6539). + assert_eq!(lightness_track(), [Color32::BLACK, Color32::from_gray(128), Color32::WHITE]); + let [_, mid, _] = lightness_track(); + assert!(mid.r() == mid.g() && mid.g() == mid.b(), "the middle stop is neutral grey"); } /// Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the colour sliders get plain tracks. diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..8059611b84 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -200,7 +200,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | -| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | +| UI-217-16 | Hue/Saturation: Saturation slider gets a hue-following colour track, Lightness a black-grey-white track | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue, recolouring as the Hue slider moves, and a fixed black -> grey -> white (#6539); saturation_track_follows_the_hue, lightness_track_is_black_grey_white_whatever_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | | UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | | UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..46730c3eab 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -112,10 +112,10 @@ note = "Curves editor (adjust_editors.rs, dialog and Properties): Edit points / [[item]] id = "UI-217-16" -target = "Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks" +target = "Hue/Saturation: Saturation slider gets a hue-following colour track, Lightness a black-grey-white track" status = "done" issue = 217 -note = "saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue" +note = "saturation_track/lightness_track (adjust_editors.rs): grey -> the hue, recolouring as the Hue slider moves, and a fixed black -> grey -> white (#6539); saturation_track_follows_the_hue, lightness_track_is_black_grey_white_whatever_the_hue" [[item]] id = "UI-217-17" From 145acbda8bc5c6793ccf02a4c8c77371b3abd42f Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 15:04:09 +0200 Subject: [PATCH 0332/1452] Layers: toggling a clipped adjustment damages only its base (#6535) `photocraft_compose::change_bounds` returned "anywhere" for every adjustment layer before looking at `clipped`, although a clipped adjustment only changes its clip base's pixels. Hiding or showing one (or undoing that) therefore recomposited the whole canvas, which on a heavy document fails the GPU memory fit check and falls back to a full CPU redraw taking seconds to minutes. `change_bounds` sees one layer and cannot find the base, so a new `change_bounds_in(layers, k, canvas)` looks at the sibling list: a clipped adjustment's change bounds are its base's change bounds. It stays `None` (anywhere) when no unclipped base lies beneath it (the compositor then draws it as an ordinary adjustment), when the base is an adjustment (layers clipped to it composite atop the adjusted backdrop), and, through `change_bounds`, when the base is a pass-through group holding an adjustment (visible clipped layers make that group isolated, so they decide whether its adjustment reaches the layers beneath) or draws outside its bounds. The engine's `layers_damage`, `step_damage` and `one_sided_bounds` use it. Tests: a compose unit test that the clipped adjustment's bounds equal the base's, that every pixel the toggle changes lies inside them, and the cases that stay unbounded; an engine test that hide, undo, redo, show and `layer.setProps` visible report the base's rect (top level and inside a group), and that recompositing only that rect over the previous canvas equals a full recomposite. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 17 +++++ crates/compose/src/tests.rs | 52 +++++++++++++++ crates/engine/src/layer_multi_cmds.rs | 92 +++++++++++++++++++++++++-- 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..6ee2b6a2ce 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1168,6 +1168,23 @@ pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option { composite_bounds(layer, canvas) } +/// [`change_bounds`] of `layers[k]` in its sibling list (bottom→top), which also bounds a clipped +/// adjustment layer: it changes only its clip base's pixels, so it reaches as far as showing, +/// hiding or restyling that base does (#6535). Still `None` when there is no base below it (the +/// compositor then draws it as an ordinary adjustment) or the base is an adjustment itself +/// (layers clipped to one composite atop the adjusted backdrop, everywhere). +pub fn change_bounds_in(layers: &[Layer], k: usize, canvas: Rect) -> Option { + let layer = layers.get(k)?; + if layer.clipped && matches!(layer.content, LayerContent::Adjustment(_)) { + let base = layers.get(..k)?.iter().rev().find(|l| !l.clipped)?; + if matches!(base.content, LayerContent::Adjustment(_)) { + return None; + } + return change_bounds(base, canvas); + } + change_bounds(layer, canvas) +} + /// Whether the layer's content is transparent outside its bounds (every surface it draws from /// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown /// by their reach. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..42bbea272e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -448,6 +448,58 @@ fn clipped_adjustment_only_affects_base() { assert!(close4(px(&d, 3, 0), [1.0; 4]), "background untouched (white)"); } +/// #6535: showing or hiding a clipped adjustment changes only its base's pixels, so its change +/// bounds are the base's; it still reaches everywhere without a base, or clipped to an +/// adjustment, or to a pass-through group whose adjustment it isolates. +#[test] +fn clipped_adjustment_changes_only_its_base() { + let canvas = Rect::new(0, 0, 8, 6); + let base_rect = Rect::new(2, 1, 5, 4); + let mut d = doc_white(8, 6); + d.layers.push(solid_layer("base", base_rect, [0.2, 0.4, 0.6, 1.0])); + let mut over = solid_layer("over", Rect::new(3, 2, 4, 3), [0.9, 0.1, 0.1, 1.0]); + over.clipped = true; + d.layers.push(over); + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.clipped = true; + d.layers.push(adj); + assert_eq!(change_bounds(&d.layers[3], canvas), None); + assert_eq!(change_bounds_in(&d.layers, 3, canvas), Some(base_rect)); + // Every pixel the toggle changes lies inside those bounds. + let shown = render(&d, canvas); + d.layers[3].visible = false; + let hidden = render(&d, canvas); + let mut changed = Rect::EMPTY; + for (i, (a, b)) in shown.px.iter().zip(&hidden.px).enumerate() { + if !close4(*a, *b) { + let (x, y) = ((i % 8) as i32, (i / 8) as i32); + assert!(base_rect.contains(x, y), "pixel {x},{y} changed outside the base"); + let p = Rect::from_xywh(x, y, 1, 1); + changed = if changed.is_empty() { p } else { changed.union(&p) }; + } + } + assert_eq!(changed, base_rect); + // Other layers keep their own bounds. + assert_eq!(change_bounds_in(&d.layers, 2, canvas), Some(Rect::new(3, 2, 4, 3))); + assert_eq!(change_bounds_in(&d.layers, 9, canvas), None); + + // Unclipped, or with no base beneath it, it reaches everything. + let mut free = d.layers.clone(); + free[3].clipped = false; + assert_eq!(change_bounds_in(&free, 3, canvas), None); + let orphan = vec![d.layers[3].clone()]; + assert_eq!(change_bounds_in(&orphan, 0, canvas), None); + // Clipped layers on an adjustment composite atop the adjusted backdrop. + let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()]; + assert_eq!(change_bounds_in(&on_adj, 2, canvas), None); + // A pass-through group holding an adjustment is isolated only while it has visible clipped + // layers, so they change whether its adjustment reaches the layers beneath. + let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); + g.blend = BlendMode::PassThrough; + let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()]; + assert_eq!(change_bounds_in(&on_group, 2, canvas), None); +} + #[test] fn threshold_posterize_levels_curves() { let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..3a67f8278b 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -502,7 +502,8 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op let canvas = d.bounds(); for id in ids { // A layer gone from one side (never for a move) changes who knows what. - let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?; + let (siblings, k) = siblings_of(d, *id)?; + let b = photocraft_compose::change_bounds_in(siblings, k, canvas)?; if !b.is_empty() { out = if out.is_empty() { b } else { out.union(&b) }; } @@ -511,6 +512,14 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op Some(out) } +/// The sibling list holding layer `id` in `doc` (bottom→top) and its index there. +fn siblings_of(doc: &Document, id: LayerId) -> Option<(&[Layer], usize)> { + let path = doc.path_of(id)?; + let (&k, parent) = path.split_last()?; + let siblings = if parent.is_empty() { doc.layers.as_slice() } else { doc.layer_at(parent)?.children()? }; + Some((siblings, k)) +} + /// Report [`layers_damage`] as the active document's last change, so the canvas recomposites /// only that (grown by the effect reach around it) instead of everything. pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) { @@ -801,6 +810,7 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { // Both sides have the layer, in another order. _ => return false, }; + let (xk, yk) = (i, j); i += 1; j += 1; if x.video.is_some() || y.video.is_some() { @@ -813,7 +823,9 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } if !layer_pixels_equal(x, y) { // Both sides' extents: a move changes pixels where it left and where it landed. - let (Some(bx), Some(by)) = (photocraft_compose::change_bounds(x, a.bounds()), photocraft_compose::change_bounds(y, b.bounds())) else { + // A clipped adjustment reaches as far as its base (#6535). + let (Some(bx), Some(by)) = (photocraft_compose::change_bounds_in(xs, xk, a.bounds()), photocraft_compose::change_bounds_in(ys, yk, b.bounds())) + else { return false; }; *out = if out.is_empty() { bx.union(&by) } else { out.union(&bx).union(&by) }; @@ -834,10 +846,11 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } /// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds -/// (`photocraft_compose::change_bounds`: not for an adjustment, or a pass-through group holding -/// one), provided it isn't the clipping base of clipped layers above it (they would change base), -/// and neither it nor anything inside it is a video layer or excludes channels (both reach -/// outside the layer's bounds, see [`step_damage`]). +/// (`photocraft_compose::change_bounds_in`: not for an unclipped adjustment, or a pass-through +/// group holding one; a clipped adjustment's are its base's), provided it isn't the clipping base +/// of clipped layers above it (they would change base), and neither it nor anything inside it is +/// a video layer or excludes channels (both reach outside the layer's bounds, see +/// [`step_damage`]). fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { fn contained(l: &Layer, depth: usize) -> bool { depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1))) @@ -847,7 +860,7 @@ fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { if new_base || !contained(l, 0) { return None; } - photocraft_compose::change_bounds(l, canvas) + photocraft_compose::change_bounds_in(layers, k, canvas) } /// Whether `layers[k]` draws nothing wherever it sits, so adding or removing it leaves the @@ -1780,6 +1793,71 @@ mod tests { assert_eq!(step_damage(&plain, &before), Some(Rect::EMPTY)); } + /// #6535: hiding or showing an adjustment clipped to a layer recomposites only that base's + /// area, also on undo and redo and inside a group, and recompositing just that area gives + /// the same canvas as a full recomposite. + #[test] + fn toggling_a_clipped_adjustment_damages_only_its_base() { + let all = |s: &Session| photocraft_compose::render(doc(s), doc(s).bounds()); + // Recomposite only the reported damage over the previous canvas, as the view does. + let patched = |s: &Session, prev: &photocraft_compose::Buffer| { + let r = damage(s).expect("bounded damage"); + let mut out = prev.clone(); + if r.is_empty() { + return out; + } + let part = photocraft_compose::render(doc(s), r); + let w = out.rect.width() as usize; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let i = (y - out.rect.y0) as usize * w + (x - out.rect.x0) as usize; + let k = (y - r.y0) as usize * r.width() as usize + (x - r.x0) as usize; + out.px[i] = part.px[k]; + } + } + out + }; + for grouped in [false, true] { + let mut s = session(16); + let base_rect = Rect::new(10, 10, 30, 25); + let base = rect_layer(&mut s, base_rect); + let adj = LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.createClippingMask", json!({"layer": adj.0})).unwrap(); + assert!(doc(&s).layer(adj).unwrap().clipped); + if grouped { + s.execute("layer.select", json!({"layer": base.0})).unwrap(); + s.execute("layer.select", json!({"layer": adj.0, "mode": "toggle"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + assert!(doc(&s).path_of(adj).unwrap().len() == 2, "the pair sits in a group"); + } + let shown = all(&s); + s.execute("layer.hideLayers", json!({"layer": adj.0})).unwrap(); + assert!(!doc(&s).layer(adj).unwrap().visible); + assert_eq!(damage(&s), Some(base_rect), "grouped: {grouped}"); + let hidden = all(&s); + assert_ne!(hidden, shown); + assert_eq!(patched(&s, &shown), hidden); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &hidden), shown); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &shown), hidden); + s.execute("layer.showLayers", json!({"layer": adj.0})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &hidden), shown); + // layer.setProps reports its own damage. + s.execute("layer.setProps", json!({"layer": adj.0, "visible": false})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &shown), hidden); + // Unclipped, the adjustment still reaches the whole canvas. + s.execute("layer.setProps", json!({"layer": adj.0, "visible": true})).unwrap(); + s.execute("layer.releaseClippingMask", json!({"layer": adj.0})).unwrap(); + s.execute("layer.hideLayers", json!({"layer": adj.0})).unwrap(); + assert_eq!(damage(&s), None); + } + } + /// #2883 (P8/P9): a duplicate shares the source's tiles (no pixel copy) and recomposites only /// where it draws; undo brings back exactly the old pixels. A copy that becomes the clipping /// base of the layers above still recomposites everything. From b0350c64c1d3e3466aee456c979f19ff8fd1edc9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:41:04 +0300 Subject: [PATCH 0333/1452] Session::start: drop the checks main moved into admit The merge with main kept the old precondition, job conflict and hidden layer checks next to main's `self.admit(...)` (#5684), which runs the same checks, so every started command was checked twice. The New Effects disclosure block now follows admit, as it followed those checks before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9f71e6a3ff..6d325ccd5e 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -713,17 +713,7 @@ impl Session { return Ok(Started::Done(v)); } // Pixel commands follow the Channels panel target unless the caller names one. - let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); - if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - if let Some(why) = self.job_conflict(id, spec.journal) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - // Painting on or moving a hidden layer is refused, as in Photoshop (#571). - if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { - return Err(EngineError::Other(why.into())); - } + let run_params = self.admit(id, spec, ¶ms)?; // This UI-only disclosure preference applies to both `execute` and `start`: // desktop UI normally calls `start`, even for synchronous Layer Style commands. let new_fx_target = if id.starts_with("layer.layerStyle.") { @@ -735,7 +725,6 @@ impl Session { None }; let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true); - let run_params = self.admit(id, spec, ¶ms)?; self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); self.jobs.spawn = background; From 2a9973f9a52631bf8917cc608b0a493434b32a7e Mon Sep 17 00:00:00 2001 From: aqibmohd271 <188757755+aqibmohd271@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:49:07 +0400 Subject: [PATCH 0334/1452] fix(print): preserve WinAnsi characters in PDF labels --- contributors/people.toml | 3 ++ crates/engine/src/print_cmds.rs | 45 ++++++++++++++++++++++++--- crates/engine/src/print_cmds/tests.rs | 27 ++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..3ae5dc9208 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,6 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.aqibmohd271] +sync_github_name = true diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..2681854b41 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -76,16 +76,51 @@ pub struct PrintPage { pub emulsion_down: bool, } +// ISO 32000-1, Annex D.2: the standard Helvetica font below uses WinAnsiEncoding, +// not UTF-8. Escape non-ASCII bytes as three octal digits so the content stays ASCII +// and a following digit cannot become part of the escape. Other scripts still need +// an embedded font; retain the existing '?' fallback for unsupported characters. fn pdf_string(s: &str) -> String { let mut o = String::from("("); for c in s.chars() { - match c { - '(' | ')' | '\\' => { + let byte = match c { + '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => c as u8, + '€' => 0x80, + '‚' => 0x82, + 'ƒ' => 0x83, + '„' => 0x84, + '…' => 0x85, + '†' => 0x86, + '‡' => 0x87, + 'ˆ' => 0x88, + '‰' => 0x89, + 'Š' => 0x8a, + '‹' => 0x8b, + 'Œ' => 0x8c, + 'Ž' => 0x8e, + '‘' => 0x91, + '’' => 0x92, + '“' => 0x93, + '”' => 0x94, + '•' => 0x95, + '–' => 0x96, + '—' => 0x97, + '˜' => 0x98, + '™' => 0x99, + 'š' => 0x9a, + '›' => 0x9b, + 'œ' => 0x9c, + 'ž' => 0x9e, + 'Ÿ' => 0x9f, + _ => b'?', + }; + match byte { + b'(' | b')' | b'\\' => { o.push('\\'); - o.push(c); + o.push(char::from(byte)); } - c if c.is_ascii() && !c.is_ascii_control() => o.push(c), - _ => o.push('?'), + 0x80..=0xff => o.push_str(&format!("\\{byte:03o}")), + _ => o.push(char::from(byte)), } } o.push(')'); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..8a9480422c 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -434,3 +434,30 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +#[test] +fn print_strings_keep_winansi_accents_and_punctuation() { + assert_eq!(pdf_string("Café – résumé"), r"(Caf\351 \226 r\351sum\351)"); + assert_eq!(pdf_string("€ ‘quote’ “double” • Œœ Šš Žž Ÿ"), r"(\200 \221quote\222 \223double\224 \225 \214\234 \212\232 \216\236 \237)"); + assert_eq!(pdf_string("é7"), r"(\3517)", "octal escapes must have three digits before a digit"); +} + +#[test] +fn print_strings_keep_pdf_delimiters_safe_and_fallback_bounded() { + assert_eq!(pdf_string(r"a(b)\c"), r"(a\(b\)\\c)"); + assert_eq!(pdf_string("\n\r\t\0\u{7f}\u{81}中😀"), "(????????)"); + assert_eq!(pdf_string(""), "()"); +} + +#[test] +fn printed_pdf_uses_winansi_for_labels_and_descriptions() { + let mut page = page_of(3, vec![255; 6], false); + page.label = Some("Café – résumé.psd".into()); + page.description = Some("Price: €20 (proof)".into()); + let pdf = print_pdf(&page); + assert!(String::from_utf8_lossy(&pdf).contains("/Encoding /WinAnsiEncoding")); + let content = pdf_content(&pdf); + assert!(content.contains(r"(Caf\351 \226 r\351sum\351.psd) Tj"), "{content}"); + assert!(content.contains(r"(Price: \20020 \(proof\)) Tj"), "{content}"); + assert!(content.is_ascii(), "encoded bytes are escaped, never UTF-8 text operands"); +} From ff411fa9e14cdf976fb3b971ebf90a7c725b5446 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 19:20:47 +0530 Subject: [PATCH 0335/1452] test(ui-egui): the Pen-while-panning test expects Windows' fist bitmap On Windows the fist is a cursor bitmap with a `Crosshair` fallback icon, not the `None` that `tool_cursor::hand` returns, so comparing the frame's icon to that return value failed there. Check the icon and bitmap each platform gets. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/hold_keys/tests.rs | 10 +++++++++- crates/ui-egui/src/tool_cursor.rs | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index f4e0064412..7f90eb3874 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -215,7 +215,15 @@ fn the_pen_path_being_drawn_stays_visible_while_space_pans() { painted_paths(&h).iter().any(|pts| pts.first().is_some_and(|p| near(p, a)) && pts.last().is_some_and(|p| near(p, b))), "the Pen path from {a:?} to {b:?} is drawn while panning" ); - assert_eq!(h.output().platform_output.cursor_icon, crate::tool_cursor::hand(&h.ctx, true), "the fist while panning (#2196)"); + // The fist (#2196): the OS's own on macOS and Linux; on Windows our bitmap, over the + // `Crosshair` fallback `CursorLifecycle` leaves under it. + let out = &h.output().platform_output; + let fist = if cfg!(target_os = "windows") { + (egui::CursorIcon::Crosshair, crate::tool_cursor::hand_bitmap(&h.ctx, true).map(|i| i.rgba)) + } else { + (egui::CursorIcon::Grabbing, None) + }; + assert_eq!((out.cursor_icon, out.cursor_image.as_ref().map(|i| i.rgba.clone())), fist, "the fist while panning (#2196)"); release(&mut h, 190.0, 230.0, Modifiers::NONE); key(&mut h, Key::Space, false, Modifiers::NONE); h.run_steps(2); diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 8b67b96d60..bc4757a3ab 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -152,6 +152,12 @@ fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon { if closed { CursorIcon::Grabbing } else { CursorIcon::Grab } } +/// The bitmap [`hand`] hands Windows, for tests that check the Hand's pointer on every platform. +#[cfg(test)] +pub(crate) fn hand_bitmap(ctx: &egui::Context, closed: bool) -> Option { + rasterize(&Shape::Hand { closed }, native_scale(ctx)) +} + /// The Zoom tool's pointer: a magnifier with a plus, or a minus when it zooms `out`. winit maps /// `ZoomIn` and `ZoomOut` to the plain arrow on Windows (#3735), so there it is a bitmap; macOS and /// the Linux themes already draw magnifiers. From 8c9c5fccf88040f56186c4459a2a0ad40916df2a Mon Sep 17 00:00:00 2001 From: Trade-Mottoes <278793924+Trade-Mottoes@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:51:56 +0000 Subject: [PATCH 0336/1452] Layer Mask Display Options: set the layer mask rubylith's colour and opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rubylith (`\`, ⇧⌥-click on a mask thumbnail) was always red at 50 %. Its colour and opacity are now Photoshop's Layer Mask Display Options: one setting for every mask, kept across restarts and not an undo step, as Photoshop does it. They live in the preferences as `layerMaskOverlay` (`#rrggbb`, 0..100 %, red at 50 by default) and are set through `channel.options {"channel":"mask","color","opacity"}`, which with no fields reads them back (prefs.set and prefs.reset work too). In the UI, as in Photoshop: double-click the layer mask's row in the Channels panel or pick Layer Mask Display Options… from its menu, Mask Options… on the Layers panel mask thumbnail, or Channel Options… with the mask targeted (`\` counts). Each opens the Channel Options dialog with just Color and Opacity. The rubylith is still drawn over the picture as it looks, mask applied, as Photoshop draws it. The channel view's texture key carries the options, so changing them redraws it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/channel_cmds.rs | 29 +++- crates/engine/src/channel_cmds/tests.rs | 35 +++++ crates/engine/src/mask_view_cmds.rs | 2 +- crates/engine/src/prefs.rs | 37 +++++ crates/ui-egui/src/canvas.rs | 33 +++- crates/ui-egui/src/channel_options_ui.rs | 44 +++++- .../ui-egui/src/channel_options_ui_tests.rs | 31 +++- crates/ui-egui/src/channel_view.rs | 144 ++++++++++++------ crates/ui-egui/src/channels_panel.rs | 35 ++++- crates/ui-egui/src/filter_preview_ui.rs | 6 +- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/layer_menu_ui.rs | 19 ++- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 6 +- crates/ui-egui/src/menus.rs | 7 +- docs/context-menu-inventory.md | 2 +- docs/control-protocol.md | 1 + docs/scorecard.md | 4 +- 36 files changed, 446 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 468db2698b..f54ae8175a 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1425,6 +1425,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string); + let display_only = name.is_none() && kind.is_none() && solidity.is_none(); let apply = move |ch: &mut AlphaChannel| -> Result<()> { if let Some(n) = &name { ch.name = n.clone(); @@ -1493,7 +1494,31 @@ fn options(s: &mut Session, p: &Value) -> Result { })?; } } - _ => return Err(bad(cmd, "`channel` must be an alpha/spot channel or \"quickMask\"")), + ChanRef::LayerMask => { + // Layer Mask Display Options: only the rubylith's colour and opacity, for every mask. + if !display_only { + return Err(bad(cmd, "a layer mask's options are only `color` and `opacity`")); + } + // A preference, kept across restarts; not an undo step. + if color.is_some() || opacity.is_some() { + s.edit_prefs(|prefs| { + let o = &mut prefs.layer_mask_overlay; + if let Some(c) = color { + o.color = crate::adjust_params::hex(c.to_rgb()); + } + if let Some(a) = opacity { + o.opacity = (a * 100.0).round(); + } + }); + } + let o = serde_json::to_value(&s.prefs().layer_mask_overlay).unwrap_or(Value::Null); + let mut out = list(s); + if let Some(m) = out.as_object_mut() { + m.insert("layerMaskOverlay".into(), o); + } + return Ok(out); + } + _ => return Err(bad(cmd, "`channel` must be an alpha/spot channel, \"quickMask\" or \"mask\"")), } Ok(list(s)) } @@ -1890,7 +1915,7 @@ pub fn specs() -> Vec { "Channel Options…", [], None, - r##"{"channel":index|name|"quickMask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?}"##, + r##"{"channel":index|name|"quickMask"|"mask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?} ("mask": Layer Mask Display Options, the rubylith's color and opacity for every layer mask, kept in the preferences as layerMaskOverlay; not an undo step; returns them as layerMaskOverlay)"##, has_doc, options ), diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index d63dc5fd0d..9cb541656d 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -401,6 +401,41 @@ fn quick_mask_without_selection_and_options() { assert!(sel_at(&s, 10, 10) < 0.05 && sel_at(&s, 30, 10) > 0.99, "{} {}", sel_at(&s, 10, 10), sel_at(&s, 30, 10)); } +/// Layer Mask Display Options: `channel.options` on `"mask"` sets the rubylith's colour and +/// opacity for every mask. They're a preference (kept across restarts, as in Photoshop), not an +/// undo step, and come back in the result. +#[test] +fn layer_mask_display_options() { + let mut s = session(); + let o = || crate::prefs::LayerMaskOverlay::default(); + assert_eq!(s.prefs().layer_mask_overlay, o()); + assert_eq!((o().rgb(), o().alpha()), ([1.0, 0.0, 0.0], 0.5)); + let hist = s.active().unwrap().history.entries().len(); + let r = s.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 30})).unwrap(); + assert_eq!(r["layerMaskOverlay"], json!({"color": "#00ff00", "opacity": 30.0})); + assert_eq!(s.execute("prefs.get", json!({"path": "layerMaskOverlay"})).unwrap(), json!({"color": "#00ff00", "opacity": 30.0})); + assert_eq!(s.active().unwrap().history.entries().len(), hist, "not an undo step"); + // Just one of them leaves the other; no fields reads them back; out of range clamps. + s.execute("channel.options", json!({"channel": "mask", "opacity": 250})).unwrap(); + let r = s.execute("channel.options", json!({"channel": "layerMask"})).unwrap(); + assert_eq!(r["layerMaskOverlay"], json!({"color": "#00ff00", "opacity": 100.0})); + // A layer mask has no name, Color Indicates or solidity here. + for bad in [json!({"name": "x"}), json!({"indicates": "selected"}), json!({"indicates": "spot"}), json!({"solidity": 20})] { + let mut p = bad.clone(); + p["channel"] = json!("mask"); + assert!(s.execute("channel.options", p).is_err(), "{bad}"); + } + assert_eq!(s.prefs().layer_mask_overlay.opacity, 100.0, "a refused call changes nothing"); + // prefs.set validates them like other preferences; prefs.reset restores red at 50 %. + assert!(s.execute("prefs.set", json!({"path": "layerMaskOverlay.color", "value": "green"})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "layerMaskOverlay.opacity", "value": 101})).is_err()); + s.execute("prefs.reset", json!({"path": "layerMaskOverlay"})).unwrap(); + assert_eq!(s.prefs().layer_mask_overlay, o()); + // A hand-edited settings file can't break the view. + let odd = crate::prefs::LayerMaskOverlay { color: "nope".into(), opacity: f32::NAN }; + assert_eq!((odd.rgb(), odd.alpha()), ([1.0, 0.0, 0.0], 0.5)); +} + /// Overlay coverage of the live Quick Mask at (x, y): how much colour the canvas shows there. fn qm_overlay(s: &Session, x: i32, y: i32) -> f32 { let q = doc(s).quick_mask.as_ref().unwrap(); diff --git a/crates/engine/src/mask_view_cmds.rs b/crates/engine/src/mask_view_cmds.rs index d7cfc338c0..403a6a0a47 100644 --- a/crates/engine/src/mask_view_cmds.rs +++ b/crates/engine/src/mask_view_cmds.rs @@ -160,7 +160,7 @@ pub fn specs() -> Vec { label: "Toggle Layer Mask Overlay", menu: &[], shortcut: Some("\\"), - params: r##"{"layer":id?} (shows or hides the active layer's mask as a 50% red rubylith over the composite, the `\` key; view state, not an undo step; painting while shown paints the mask)"##, + params: r##"{"layer":id?} (shows or hides the active layer's mask as a rubylith over the composite, the `\` key; red at 50% unless Layer Mask Display Options, channel.options {"channel":"mask"}, set it; view state, not an undo step; painting while shown paints the mask)"##, enabled: active_has_mask, run: toggle_overlay, journal: false, diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2c2507226d..da3dcd6a8c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -920,6 +920,37 @@ pub struct ToolbarCustomization { pub order: Vec, } +/// Layer Mask Display Options (double-click the layer mask's row in the Channels panel, or Mask +/// Options… on its Layers thumbnail): the colour and opacity of the layer mask rubylith (`\`, +/// ⇧⌥-click the thumbnail). As in Photoshop, one setting for every mask, kept across restarts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayerMaskOverlay { + /// `#rrggbb`. + pub color: String, + /// Percent, 0..=100. + pub opacity: f32, +} + +impl Default for LayerMaskOverlay { + /// Photoshop's: red at 50 %. + fn default() -> Self { + Self { color: "#ff0000".into(), opacity: 50.0 } + } +} + +impl LayerMaskOverlay { + /// The colour as 0..1 RGB; red if the stored value isn't a colour. + pub fn rgb(&self) -> [f32; 3] { + parse_hex(&self.color).unwrap_or([255, 0, 0]).map(|v| f32::from(v) / 255.0) + } + + /// The opacity as a fraction (0..1); 50 % if the stored value isn't a number. + pub fn alpha(&self) -> f32 { + if self.opacity.is_finite() { self.opacity.clamp(0.0, 100.0) / 100.0 } else { 0.5 } + } +} + /// All preferences. JSON keys are the `edit.preferences.
` ids. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -991,6 +1022,8 @@ pub struct Preferences { pub view_extras: Value, /// File › Scripts › Script Events Manager: event → script bindings. pub script_events: crate::automate_cmds::ScriptEvents, + /// Layer Mask Display Options: the layer mask rubylith's colour and opacity. + pub layer_mask_overlay: LayerMaskOverlay, } /// Preferences dialog sections in Photoshop's order: (id, title). @@ -1122,6 +1155,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "unitsAndRulers.printResolution" | "unitsAndRulers.screenResolution" | "rawDefaults.resolution" => (1.0, 10_000.0), "guidesGridAndSlices.gridlineEvery" => (0.001, 10_000.0), "guidesGridAndSlices.subdivisions" => (1.0, 100.0), + "layerMaskOverlay.opacity" => (0.0, 100.0), "type.recentFonts" => (0.0, 50.0), "integrations.controlPort" => (0.0, 65535.0), _ => return None, @@ -1133,6 +1167,9 @@ pub fn is_color(path: &str) -> bool { if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") { return false; } + if path == "layerMaskOverlay.color" { + return true; + } let leaf = path.rsplit('.').next().unwrap_or(path); matches!( leaf, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..43dabb9607 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -7369,7 +7369,7 @@ mod tests { &app.session.documents()[0].channel_view, r, factor, - false, + crate::channel_view::Look::default(), app.session.documents()[0].active_layer, ) .unwrap(); @@ -7400,7 +7400,8 @@ mod tests { let st = &app.session.documents()[0]; assert_eq!( image.pixels, - crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), factor, false, st.active_layer).unwrap(), + crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), factor, crate::channel_view::Look::default(), st.active_layer) + .unwrap(), "undo restores the shown mask" ); delta.clear(); @@ -7445,12 +7446,28 @@ mod tests { let r = DRect::new(pos[0] as i32, pos[1] as i32, (pos[0] + image.size[0]) as i32, (pos[1] + image.size[1]) as i32); assert_eq!( image.pixels, - crate::channel_view::render(&shown, &app.session.documents()[0].channel_view, r, 1, false, app.session.documents()[0].active_layer).unwrap(), + crate::channel_view::render( + &shown, + &app.session.documents()[0].channel_view, + r, + 1, + crate::channel_view::Look::default(), + app.session.documents()[0].active_layer + ) + .unwrap(), "{target}" ); assert_ne!( image.pixels, - crate::channel_view::render(&original, &app.session.documents()[0].channel_view, r, 1, false, app.session.documents()[0].active_layer).unwrap(), + crate::channel_view::render( + &original, + &app.session.documents()[0].channel_view, + r, + 1, + crate::channel_view::Look::default(), + app.session.documents()[0].active_layer + ) + .unwrap(), "target was painted" ); delta.clear(); @@ -7472,7 +7489,7 @@ mod tests { &app.session.documents()[0].channel_view, original.bounds(), 1, - false, + crate::channel_view::Look::default(), app.session.documents()[0].active_layer ) .unwrap() @@ -8420,7 +8437,8 @@ mod rotation_preview_isolation_tests { let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; let st = app.session.active().unwrap(); - let original = crate::channel_view::render(&before, &st.channel_view, before.bounds(), 1, false, st.active_layer).unwrap(); + let original = + crate::channel_view::render(&before, &st.channel_view, before.bounds(), 1, crate::channel_view::Look::default(), st.active_layer).unwrap(); assert_ne!(image.pixels, original, "a nontrivial filter must be visible before OK"); delta.clear(); assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); @@ -8581,7 +8599,8 @@ mod rotation_preview_isolation_tests { let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; crate::dialogs::confirm(&mut app, dialog).unwrap(); let st = app.session.active().unwrap(); - let expected = crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let expected = + crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), 1, crate::channel_view::Look::default(), st.active_layer).unwrap(); assert_eq!(image.pixels, expected, "quick_mask={quick_mask}"); delta.clear(); } diff --git a/crates/ui-egui/src/channel_options_ui.rs b/crates/ui-egui/src/channel_options_ui.rs index c50244447e..7ff77d1146 100644 --- a/crates/ui-egui/src/channel_options_ui.rs +++ b/crates/ui-egui/src/channel_options_ui.rs @@ -2,6 +2,9 @@ //! Quick Mask row): name, Color Indicates Masked Areas / Selected Areas / Spot Color, the overlay //! or ink colour, and opacity (solidity for spot channels). OK runs the engine's //! `channel.options`, which agents can also call directly with params (#3761). +//! +//! On the layer mask (double-click its Channels row, or Mask Options… on its Layers thumbnail) +//! it is Photoshop's Layer Mask Display Options: just the rubylith's colour and opacity. use egui::{Align2, Sense, pos2, vec2}; use photocraft_doc::ColorIndicates; @@ -21,11 +24,27 @@ fn hex(rgb: [f32; 3]) -> String { crate::color_picker_ui::hex(rgb) } -/// The dialog's fields for `channel` (an alpha/spot channel index, or `"quickMask"`), or `None` -/// when there's no document or no such channel. +/// Whether `f` is Layer Mask Display Options rather than a channel's options. +fn layer_mask(f: &Map) -> bool { + f.get("channel").and_then(Value::as_str) == Some("mask") +} + +/// The dialog's fields for `channel` (an alpha/spot channel index, `"quickMask"`, or `"mask"` for +/// Layer Mask Display Options), or `None` when there's no document or no such channel. pub fn fields(app: &PhotocraftApp, channel: &Value) -> Option> { let doc = &app.session.active()?.doc; let kind = |i: ColorIndicates| if i == ColorIndicates::SelectedAreas { "selected" } else { "masked" }; + if channel.as_str() == Some("mask") { + let o = &app.session.prefs().layer_mask_overlay; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Layer Mask Display Options…")); + f.insert("channel".into(), json!("mask")); + f.insert("color".into(), json!(hex(o.rgb()))); + f.insert("opacity".into(), json!((o.alpha() * 100.0).round())); + return Some(f); + } let (name, indicates, color, percent) = if channel.as_str() == Some("quickMask") { let o = app.session.quick_mask_options; (None, kind(o.indicates), o.color, o.opacity) @@ -84,6 +103,9 @@ fn label(ui: &mut egui::Ui, text: &str) { pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.spacing_mut().item_spacing.y = 6.0; + if layer_mask(f) { + return mask_body(ui, f); + } let quick = f.get("channel").and_then(Value::as_str) == Some("quickMask"); if let Some(mut name) = f.get("name").and_then(Value::as_str).map(str::to_string) { ui.horizontal(|ui| { @@ -129,6 +151,24 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { }); } +/// Layer Mask Display Options: Color and Opacity, as Photoshop's dialog has them. +fn mask_body(ui: &mut egui::Ui, f: &mut Map) { + ui.horizontal(|ui| { + label(ui, tl!("Color:")); + let current = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([1.0, 0.0, 0.0]); + let c = current.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(c[0], c[1], c[2]), tl!("Overlay Color")).clicked() { + crate::color_picker_ui::request(f, &crate::color_picker_ui::title_for(tl!("Overlay Color"))); + } + ui.add_space(12.0); + ui.label(tl!("Opacity:")); + let mut pct = f.get("opacity").and_then(Value::as_f64).unwrap_or(50.0) as f32; + if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() { + f.insert("opacity".into(), json!(pct.round())); + } + }); +} + // --------------------------------------------------------------------------------------------- // New Spot Channel…: Name and Ink Characteristics (Color, Solidity). OK runs `channel.newSpot` // (from the selection, as Photoshop does) and remembers the ink and solidity for next time. diff --git a/crates/ui-egui/src/channel_options_ui_tests.rs b/crates/ui-egui/src/channel_options_ui_tests.rs index 9a4a3d4c37..210a4ff0e3 100644 --- a/crates/ui-egui/src/channel_options_ui_tests.rs +++ b/crates/ui-egui/src/channel_options_ui_tests.rs @@ -103,13 +103,42 @@ fn quick_mask_options_have_no_name_or_spot_and_bad_channels_open_nothing() { assert!(app.ui.dialogs.is_empty()); } +/// Layer Mask Display Options: just the colour and opacity, from and to the +/// `layer_mask_overlay` preference; Channel Options… opens it on a targeted layer mask. +#[test] +fn layer_mask_display_options_set_the_rubylith() { + let mut app = app(); + let f = fields(&app, &json!("mask")).unwrap(); + assert_eq!(f["__label"], json!("Layer Mask Display Options…")); + assert!(!f.contains_key("name") && !f.contains_key("indicates")); + assert_eq!((f["color"].clone(), f["opacity"].clone()), (json!("#ff0000"), json!(50.0))); + assert_eq!(params(&f), json!({"channel": "mask", "color": "#ff0000", "opacity": 50.0})); + let id = open(&mut app, &json!("mask")).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#00ff00")); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.prefs().layer_mask_overlay.color, "#00ff00"); + // Channels › Channel Options… with the layer mask targeted. + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("mask")); + // Or with it targeted by `\` (the rubylith), which leaves the Channels target alone. + app.run("channel.target", json!({"channel": "composite"})).unwrap(); + app.ui.dialogs.clear(); + app.run(photocraft_engine::mask_view_cmds::OVERLAY_ID, json!({})).unwrap(); + app.sync_mask_targets(); + crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("mask")); +} + #[test] fn dialog_body_renders_for_alpha_spot_and_quick_mask() { let mut app = app(); app.run("channel.options", json!({"channel": 0, "indicates": "spot"})).unwrap(); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - for channel in [json!(0), json!("quickMask")] { + for channel in [json!(0), json!("quickMask"), json!("mask")] { let mut f = fields(&app, &channel).unwrap(); ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); assert!(owns(&f)); diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index 5cbc960b8e..0a5e1958a3 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -65,6 +65,31 @@ fn sample(s: &Surface, r: Rect, factor: u32) -> Vec { out } +/// How the view draws what it shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + /// Single colour channels tinted instead of grayscale (Photoshop's "Show Channels in Color"). + pub in_color: bool, + /// The layer mask rubylith's colour and opacity (0..1): Layer Mask Display Options. + pub mask_rgb: [f32; 3], + pub mask_opacity: f32, +} + +impl Default for Look { + /// Photoshop's: grayscale channels, a red rubylith at 50 %. + fn default() -> Self { + Self { in_color: false, mask_rgb: AlphaChannel::DEFAULT_COLOR.to_rgb(), mask_opacity: 0.5 } + } +} + +impl Look { + /// The current preferences of `s`. + pub fn of(s: &photocraft_engine::Session) -> Self { + let p = s.prefs(); + Self { in_color: p.interface.show_channels_in_color, mask_rgb: p.layer_mask_overlay.rgb(), mask_opacity: p.layer_mask_overlay.alpha() } + } +} + /// Composite RGBA (over white, like Photoshop's channel views) at `factor` stride. /// Render in bands instead of allocating a full-resolution float composite to sample a preview. fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { @@ -86,11 +111,10 @@ fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { } /// The channel view of `r` (document pixels, sampled every `factor` px) as premultiplied pixels, -/// or `None` when the plain composite is all there is to show. `in_color` shows single colour -/// channels tinted instead of grayscale (Photoshop's "Show Channels in Color"). -pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, in_color: bool, active: Option) -> Option> { +/// or `None` when the plain composite is all there is to show. +pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, look: Look, active: Option) -> Option> { let mut unused = None; - render_with_source(doc, v, r, factor, in_color, active, CompositeSource { cached: None, fresh: &mut unused }) + render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused }) } fn render_with_source( @@ -98,13 +122,15 @@ fn render_with_source( v: &ChannelView, r: Rect, factor: u32, - in_color: bool, + look: Look, active: Option, source: CompositeSource<'_>, ) -> Option> { if v.is_plain(doc) || r.is_empty() { return None; } + let in_color = look.in_color; + let (mask_rgb, mask_opacity) = (look.mask_rgb, look.mask_opacity.clamp(0.0, 1.0)); let layer_mask = v.shown_layer_mask(doc); // ⌥-click mask view (#196): the pixel mask alone, in grayscale (white reveals); ⇧⌥ with // nothing else shown: just the rubylith. Use the compositor's cached feather for the pixel @@ -123,9 +149,9 @@ fn render_with_source( match shown_mask { Some((m, MaskViewMode::Gray)) => return Some(mask_px(m, r, factor, |g| Color32::from_gray(q(g)))), Some((m, MaskViewMode::Overlay)) if only_mask => { - let c = AlphaChannel::DEFAULT_COLOR.to_rgb(); + let c = mask_rgb; return Some(mask_px(m, r, factor, |g| { - let a = 0.5 * (1.0 - g.clamp(0.0, 1.0)); + let a = mask_opacity * (1.0 - g.clamp(0.0, 1.0)); Color32::from_rgba_premultiplied(q(c[0] * a), q(c[1] * a), q(c[2] * a), q(a)) })); } @@ -212,10 +238,11 @@ fn render_with_source( }; tint(sample(&ch.surface, r, factor), color, opacity, &|x| ch.overlay_coverage(x)); } - // ⇧⌥-click mask view (#196): the layer mask as Quick Mask's red over the hidden areas - // (Photoshop's Layer Mask Display Options default). + // ⇧⌥-click mask view (#196): the layer mask tinted over the hidden areas in the Layer Mask + // Display Options colour and opacity (red at 50 % unless set). if !solo_mask && let Some((m, MaskViewMode::Overlay)) = shown_mask { - tint(sample(m, r, factor), AlphaChannel::DEFAULT_COLOR, 0.5, &|x| 1.0 - x); + let [cr, cg, cb] = mask_rgb; + tint(sample(m, r, factor), photocraft_color::Color::rgb(cr, cg, cb), mask_opacity, &|x| 1.0 - x); } Some(out.into_iter().map(|p| Color32::from_rgba_premultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3]))).collect()) } @@ -252,9 +279,9 @@ fn q(x: f32) -> u8 { (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 } -fn view_key(v: &ChannelView, in_color: bool) -> u64 { - let s = serde_json::to_string(v).unwrap_or_default(); - s.bytes().fold(in_color as u64 + 1, |h, b| h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64)) +fn view_key(v: &ChannelView, look: Look) -> u64 { + let s = serde_json::to_string(&(v, look.mask_rgb, look.mask_opacity)).unwrap_or_default(); + s.bytes().fold(look.in_color as u64 + 1, |h, b| h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64)) } /// Make the channel-view texture of document `idx` current; `None` = nothing to draw. @@ -276,10 +303,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // The plain-composite path above needs no preview work. let (doc, preview_key) = crate::canvas::filter_channel_preview(app, idx, ctx).unwrap_or_else(|| crate::canvas::display_doc(app, idx)); // Interface ▸ Show Channels in Color tints single colour channel views on the canvas too, - // not just the panel thumbnails (#2385). The view key carries the flag, so toggling it - // rebuilds the texture. - let in_color = app.session.prefs().interface.show_channels_in_color; - let key = view_key(&view, in_color); + // not just the panel thumbnails (#2385). The view key carries the flag and the Layer Mask + // Display Options, so changing them rebuilds the texture. + let look = Look::of(&app.session); + let key = view_key(&view, look); // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); @@ -315,7 +342,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let d = d.intersect(&doc.bounds()); let r = Rect::new(d.x0.div_euclid(f) * f, d.y0.div_euclid(f) * f, d.x1, d.y1).intersect(&doc.bounds()); if !r.is_empty() - && let Some(px) = render(&doc, &view, r, factor, in_color, active) + && let Some(px) = render(&doc, &view, r, factor, look, active) { let img = egui::ColorImage::new([r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize], px); c.tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], img, TextureOptions::LINEAR); @@ -327,7 +354,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let b = doc.bounds(); let cached = app.channel_views.get(&id).and_then(|c| c.source.as_ref()).map(|s| s.pixels.as_slice()); let mut fresh = None; - let px = render_with_source(&doc, &view, b, factor, in_color, active, CompositeSource { cached, fresh: &mut fresh })?; + let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh })?; let new_source = fresh.is_some(); let size = [b.width().div_ceil(factor) as usize, b.height().div_ceil(factor) as usize]; let img = egui::ColorImage::new(size, px); @@ -429,14 +456,14 @@ mod tests { let st = app.session.active().unwrap(); assert_eq!(refreshed.revision, st.revision); assert_ne!(refreshed.pixels[0], original_sample, "new pixels must be recomposited"); - let expected = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let expected = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); let mut unused = None; let actual = render_with_source( &st.doc, &st.channel_view, st.doc.bounds(), 1, - false, + Look::default(), st.active_layer, CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused }, ) @@ -449,7 +476,7 @@ mod tests { fn plain_composite_draws_nothing() { let s = session(); let st = s.active().unwrap(); - assert!(render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).is_none()); + assert!(render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).is_none()); } #[test] @@ -458,7 +485,7 @@ mod tests { s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); s.execute("select.editInQuickMaskMode", json!({})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::TRANSPARENT, "selected: no overlay"); assert_eq!(px[6], Color32::from_rgba_premultiplied(128, 0, 0, 128), "masked: 50% red"); } @@ -473,12 +500,12 @@ mod tests { } s.execute("select.editInQuickMaskMode", json!({})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert!(px.iter().all(|p| *p == Color32::TRANSPARENT), "{before:?}"); if let Some(i) = after { s.execute("channel.options", json!({"channel": "quickMask", "indicates": i})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert!(px.iter().all(|p| *p == Color32::TRANSPARENT), "{before:?} → {i}"); } } @@ -488,7 +515,7 @@ mod tests { s.execute("select.all", json!({})).unwrap(); s.execute("select.editInQuickMaskMode", json!({})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert!(px.iter().all(|p| *p == Color32::from_rgba_premultiplied(128, 0, 0, 128))); } @@ -503,14 +530,14 @@ mod tests { s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); let st = s.active().unwrap(); let b = st.doc.bounds(); - let full = render(&st.doc, &st.channel_view, b, 1, false, st.active_layer).unwrap(); + let full = render(&st.doc, &st.channel_view, b, 1, Look::default(), st.active_layer).unwrap(); assert!(full[0].r() < 10 && full[36].r() > 245 && full[0].a() == 255, "opaque grayscale ramp"); // A sub-rect starting on the factor grid matches the same pixels of the full render. for f in [1u32, 2, 3] { - let all = render(&st.doc, &st.channel_view, b, f, false, st.active_layer).unwrap(); + let all = render(&st.doc, &st.channel_view, b, f, Look::default(), st.active_layer).unwrap(); let w = b.width().div_ceil(f) as usize; let r = Rect::new(3 * f as i32, 2 * f as i32, 30, 19); - let part = render(&st.doc, &st.channel_view, r, f, false, st.active_layer).unwrap(); + let part = render(&st.doc, &st.channel_view, r, f, Look::default(), st.active_layer).unwrap(); let pw = r.width().div_ceil(f) as usize; for (i, p) in part.iter().enumerate() { assert_eq!(*p, all[(i / pw + 2) * w + i % pw + 3], "factor {f}, pixel {i}"); @@ -518,7 +545,7 @@ mod tests { } s.execute("view.layerMask", json!({"mode": "overlay"})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, b, 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, b, 1, Look::default(), st.active_layer).unwrap(); assert!(px[0].r() >= 124 && px[0].g() == 0 && px[0].a() >= 124, "about 50% red over the hidden end: {:?}", px[0]); assert!(px[36].a() <= 4, "next to nothing over the revealed end: {:?}", px[36]); } @@ -541,17 +568,37 @@ mod tests { let softened = effective.sample_channel(0, 0, 0); assert!((softened - raw).abs() > 0.01, "the fixture has a visible feather at its edge"); - let gray = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + let gray = render(&st.doc, &st.channel_view, bounds, 1, Look::default(), st.active_layer).unwrap(); assert_eq!(gray[0], Color32::from_gray(q(softened)), "gray view uses the compositor's feathered mask"); s.execute("view.layerMask", json!({"mode": "overlay"})).unwrap(); let st = s.active().unwrap(); - let overlay = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + let overlay = render(&st.doc, &st.channel_view, bounds, 1, Look::default(), st.active_layer).unwrap(); let alpha = 0.5 * (1.0 - softened); let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); assert_eq!(overlay[0], Color32::from_rgba_premultiplied(q(red[0] * alpha), q(red[1] * alpha), q(red[2] * alpha), q(alpha))); } + /// Layer Mask Display Options: the rubylith takes their colour and opacity, alone over the + /// composite and with a colour channel hidden. + #[test] + fn layer_mask_overlay_takes_its_display_options() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + s.execute("view.layerMask", json!({"mode": "overlay"})).unwrap(); + let green = Look { mask_rgb: [0.0, 1.0, 0.0], mask_opacity: 0.25, ..Look::default() }; + let st = s.active().unwrap(); + let b = st.doc.bounds(); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, Look::default(), st.active_layer).unwrap()[0], Color32::from_rgba_premultiplied(128, 0, 0, 128)); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, green, st.active_layer).unwrap()[0], Color32::from_rgba_premultiplied(0, 64, 0, 64)); + // Red hidden: the white picture shows cyan, then green at 25 % over it. + s.execute("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, green, st.active_layer).unwrap()[0], Color32::from_rgb(0, 255, 191)); + } + /// `cargo test --release -p photocraft-ui-egui --lib mask_view_switch_24mp -- --ignored --nocapture` #[test] #[ignore = "benchmark"] @@ -569,7 +616,7 @@ mod tests { let mut best = f64::MAX; for _ in 0..8 { let t = std::time::Instant::now(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), factor, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), factor, Look::default(), st.active_layer).unwrap(); let img = egui::ColorImage::new([3000, 2000], px); std::hint::black_box(img); best = best.min(t.elapsed().as_secs_f64() * 1000.0); @@ -588,17 +635,20 @@ mod tests { s.execute("channel.target", json!({"channel": "green"})).unwrap(); let st = s.active().unwrap(); let b = st.doc.bounds(); - assert_eq!(render(&st.doc, &st.channel_view, b, 1, false, st.active_layer).unwrap()[0], Color32::from_gray(128)); - assert_eq!(render(&st.doc, &st.channel_view, b, 1, true, st.active_layer).unwrap()[0], Color32::from_rgb(0, 128, 0)); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, Look::default(), st.active_layer).unwrap()[0], Color32::from_gray(128)); + assert_eq!( + render(&st.doc, &st.channel_view, b, 1, Look { in_color: true, ..Look::default() }, st.active_layer).unwrap()[0], + Color32::from_rgb(0, 128, 0) + ); let mut s = Session::new(); s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk"})).unwrap(); s.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); s.execute("channel.target", json!({"channel": "cyan"})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, true, st.active_layer).unwrap()[0]; + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look { in_color: true, ..Look::default() }, st.active_layer).unwrap()[0]; assert!(px.r() < 100 && px.g() > 150 && px.b() > 150, "cyan ink, not gray: {px:?}"); - let gray = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap()[0]; + let gray = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap()[0]; assert_eq!(gray.r(), gray.g(), "off stays grayscale"); } @@ -611,12 +661,12 @@ mod tests { let b = s.active().unwrap().doc.bounds(); s.execute("channel.target", json!({"channel": {"color": 0}})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, b, 1, true, st.active_layer).unwrap()[0]; + let px = render(&st.doc, &st.channel_view, b, 1, Look { in_color: true, ..Look::default() }, st.active_layer).unwrap()[0]; let spread = px.r().max(px.g().max(px.b())) as i32 - px.r().min(px.g().min(px.b())) as i32; assert!(spread <= 3, "Lightness stays gray: {px:?}"); s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, b, 1, true, st.active_layer).unwrap()[0]; + let px = render(&st.doc, &st.channel_view, b, 1, Look { in_color: true, ..Look::default() }, st.active_layer).unwrap()[0]; assert!(px.r() != px.g() || px.g() != px.b(), "a channel is tinted, not gray: {px:?}"); } @@ -634,6 +684,10 @@ mod tests { assert!(ensure(&mut app, &ctx, 0).is_some()); let key_on = app.channel_views.values().next().unwrap().key; assert_ne!(key_off, key_on, "the preference changes the cached view key"); + // So do the Layer Mask Display Options. + app.session.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 70})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + assert_ne!(app.channel_views.values().next().unwrap().key, key_on, "the overlay options change the cached view key"); } #[test] @@ -644,17 +698,17 @@ mod tests { s.execute("channel.new", json!({"fill": "selection"})).unwrap(); let st = s.active().unwrap(); assert_eq!(st.channel_view.target, ChannelTarget::Alpha(0)); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!((px[0], px[5]), (Color32::WHITE, Color32::BLACK)); // Green alone: grayscale of the green channel; with blue hidden only. s.execute("channel.target", json!({"channel": "green"})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::from_gray(128)); s.execute("channel.target", json!({"channel": "composite"})).unwrap(); s.execute("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 2, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 2, Look::default(), st.active_layer).unwrap(); assert_eq!(px.len(), 4 * 2); assert_eq!(px[0], Color32::from_rgb(0, 128, 0)); } @@ -667,7 +721,7 @@ mod tests { s.execute("channel.setVisible", json!({"channel": "composite", "visible": true})).unwrap(); s.execute("channel.setVisible", json!({"channel": "composite", "visible": false})).unwrap(); let st = s.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::WHITE, "Alpha alone is the channel, not the red image"); let mut q = session(); @@ -675,7 +729,7 @@ mod tests { q.execute("select.editInQuickMaskMode", json!({})).unwrap(); q.execute("channel.setVisible", json!({"channel": "composite", "visible": false})).unwrap(); let st = q.active().unwrap(); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::WHITE, "Quick Mask alone is grayscale, not the red composite"); let mut m = session(); @@ -685,7 +739,7 @@ mod tests { m.execute("channel.setVisible", json!({"channel": "composite", "visible": false})).unwrap(); let st = m.active().unwrap(); assert_eq!(st.channel_view.visible_colors(3), 0); - let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::BLACK, "the hidden mask reads black, not the red layer"); } } diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index 7f175b6209..0b233633a4 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -223,6 +223,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if row == Row::QuickMask && resp.double_clicked() { actions.push(("ui.channelOptions".into(), json!("quickMask"))); } + // The layer mask's row: Layer Mask Display Options (the rubylith's colour, opacity). + if row == Row::LayerMask && resp.double_clicked() { + actions.push(("ui.channelOptions".into(), json!("mask"))); + } if let Row::Alpha(i) = row { if resp.double_clicked() && on_thumb { actions.push(("ui.channelOptions".into(), json!(i))); @@ -281,6 +285,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let enabled = masked.as_ref().and_then(|l| l.mask.as_ref()).is_none_or(|m| m.enabled); item(ui, a, crate::layer_menu_ui::mask_toggle_label(enabled), "layer.layerMask.enabled", json!({})); item(ui, a, "Delete Layer Mask", "layer.layerMask.delete", json!({})); + item(ui, a, "Layer Mask Display Options…", "ui.channelOptions", json!("mask")); } } ui.separator(); @@ -541,7 +546,8 @@ mod tests { let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.layerMask.revealAll", json!({"layer": masked})).unwrap(); s.execute("channel.new", json!({})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 1000.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); PhotocraftApp::new(s, crate::Services::default()) }); @@ -703,6 +709,33 @@ mod tests { assert!(h.ctx.data(|d| d.get_temp::<(Row, String)>(drag_id())).is_none(), "the drag ended"); } + /// Double-clicking the layer mask's row opens Layer Mask Display Options, as in Photoshop; OK + /// sets the rubylith's colour and opacity, a view setting rather than an undo step. + #[test] + fn double_clicking_the_layer_mask_row_opens_its_display_options() { + let (mut h, _) = channels_harness(); + let steps = h.state().session.active().unwrap().history.past_len(); + let p = at(&h, "Masked Mask"); + h.hover_at(p); + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialogs.last().expect("a dialog opens"); + assert!(crate::channel_options_ui::owns(&d.fields), "{:?}", d.fields); + assert_eq!((d.fields["channel"].clone(), d.fields["color"].clone(), d.fields["opacity"].clone()), (json!("mask"), json!("#ff0000"), json!(50.0))); + let id = d.id; + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("color".into(), json!("#0000ff")); + d.fields.insert("opacity".into(), json!(80.0)); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let o = &h.state().session.prefs().layer_mask_overlay; + assert_eq!((o.color.as_str(), o.opacity), ("#0000ff", 80.0)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "no history step"); + } + /// The layer mask's row asks "Apply mask to layer before removing?", like its Layers thumbnail. #[test] fn the_layer_mask_row_on_the_trash_asks_first() { diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..436e28e242 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -225,7 +225,7 @@ fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) let (width, height) = (doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); if let Some(st) = app.session.active() && let Some(pixels) = - crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, app.session.prefs().interface.show_channels_in_color, st.active_layer) + crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, crate::channel_view::Look::of(&app.session), st.active_layer) { let image = egui::ColorImage::new([width as usize, height as usize], pixels); return ctx.load_texture(name, image, egui::TextureOptions::LINEAR); @@ -332,7 +332,9 @@ mod tests { let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; let pixels = image.pixels.clone(); delta.clear(); - let expected = crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, false, st.active_layer).unwrap(); + let expected = + crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, crate::channel_view::Look::default(), st.active_layer) + .unwrap(); assert_eq!(pixels, expected, "depth {depth}, factor {k}"); assert_ne!(pixels[0], pixels[pixels.len() - 1], "the alpha edge is visible, not the white RGB layer"); assert!(pixels.iter().any(|p| p.r() > 0 && p.r() < 255), "the blurred edge is visible"); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..69807056d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -19,6 +19,10 @@ # (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin letters; the trailing "…" of # dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Možnosti masky… + Layer Mask Display Options… Možnosti zobrazení masky vrstvy… + # Channel Options and New Spot Channel (#3761) Channel Color Barva kanálu Color Indicates: Barva označuje: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..bb643f9bf3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -15,6 +15,10 @@ # way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskenoptionen… + Layer Mask Display Options… Anzeigeoptionen für Ebenenmasken… + # Channel Options and New Spot Channel (#3761) Channel Color Kanalfarbe Color Indicates: Farbe kennzeichnet: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..cd9c5ec595 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3,6 +3,10 @@ # Original translations based on general Greek graphics/computing terminology. # Covers every source label the i18n coverage check lists. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Επιλογές μάσκας… + Layer Mask Display Options… Επιλογές εμφάνισης μάσκας επιπέδου… + # Channel Options and New Spot Channel (#3761) Channel Color Χρώμα καναλιού Color Indicates: Το χρώμα υποδεικνύει: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..c489b2d6fb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opciones de máscara… + Layer Mask Display Options… Opciones de visualización de máscara de capa… + # Channel Options and New Spot Channel (#3761) Channel Color Color del canal Color Indicates: El color indica: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..5f9af4b4fe 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -19,6 +19,10 @@ # ArtCraft, OpenType, Lab ...) stay as they are; RGB/CMYK read RVB/CMJN; the trailing "…" of # dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Options de masque… + Layer Mask Display Options… Options d'affichage du masque de calque… + # Channel Options and New Spot Channel (#3761) Channel Color Couleur de la couche Color Indicates: La couleur indique : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..f93821f8cf 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4067,6 +4067,10 @@ cameraRaw Yellow Sárga set in Print Settings… a nyomtatási beállításokban adható meg… ICO is limited to 256 × 256 pixels Az ICO legfeljebb 256 × 256 képpont lehet +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maszkbeállítások… + Layer Mask Display Options… Rétegmaszk megjelenítési beállításai… + # Channel Options and New Spot Channel (#3761) Channel Color Csatorna színe Color Indicates: A szín jelzése: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..3f0d7c110d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opsi Mask… + Layer Mask Display Options… Opsi Tampilan Mask Layer… + # Channel Options and New Spot Channel (#3761) Channel Color Warna Saluran Color Indicates: Warna Menunjukkan: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..fa6f5d60c7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing … of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opzioni maschera… + Layer Mask Display Options… Opzioni di visualizzazione maschera di livello… + # Channel Options and New Spot Channel (#3761) Channel Color Colore canale Color Indicates: Il colore indica: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..2c701a0c29 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -15,6 +15,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… マスクオプション… + Layer Mask Display Options… レイヤーマスクの表示オプション… + # Channel Options and New Spot Channel (#3761) Channel Color チャンネルカラー Color Indicates: 着色表示: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..6503e9f49a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7,6 +7,10 @@ # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. # Product names and technology names retain their spelling. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 마스크 옵션… + Layer Mask Display Options… 레이어 마스크 표시 옵션… + # Channel Options and New Spot Channel (#3761) Channel Color 채널 색상 Color Indicates: 색상 표시 내용: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..2681e76ae0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -8,6 +8,10 @@ # stay as they are; the trailing "…" of dialog commands is kept. UI text addresses the user # informally ("je") and uses sentence case. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskeropties… + Layer Mask Display Options… Weergaveopties voor laagmasker… + # Channel Options and New Spot Channel (#3761) Channel Color Kanaalkleur Color Indicates: Kleur geeft aan: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..9399049333 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -18,6 +18,10 @@ # Conventions: menus are nouns (Plik, Edycja, Obraz ...), commands are infinitives (Otwórz…, # Zapisz jako…), sentence case as in the English source. Quotes: „…”. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opcje maski… + Layer Mask Display Options… Opcje wyświetlania maski warstwy… + # Channel Options and New Spot Channel (#3761) Channel Color Kolor kanału Color Indicates: Kolor oznacza: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..135a833e47 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -15,6 +15,10 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opções de máscara… + Layer Mask Display Options… Opções de exibição de máscara de camada… + # Channel Options and New Spot Channel (#3761) Channel Color Cor do canal Color Indicates: A cor indica: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..6dad91f99a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -17,6 +17,10 @@ # letters; the trailing "…" of dialog commands is kept; the colon of a label ("Width:") becomes # the Russian typographic equivalent, with the space the source carries. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Параметры маски… + Layer Mask Display Options… Параметры отображения маски слоя… + # Channel Options and New Spot Channel (#3761) Channel Color Цвет канала Color Indicates: Цветом обозначены: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..e949d90af1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4219,6 +4219,10 @@ shape Appearance Izgled # ICO dimensions ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opcije maske… + Layer Mask Display Options… Opcije prikaza maske sloja… + # Channel Options and New Spot Channel (#3761) Channel Color Boja kanala Color Indicates: Boja označava: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..1e00122678 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3878,6 +3878,10 @@ cameraData Lens Lens set in Print Settings… Yazdırma Ayarlarında belirlenir… ICO is limited to 256 × 256 pixels ICO 256 × 256 piksel ile sınırlıdır +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maske Seçenekleri… + Layer Mask Display Options… Katman Maskesi Görüntüleme Seçenekleri… + # Channel Options and New Spot Channel (#3761) Channel Color Kanal Rengi Color Indicates: Rengin Gösterdiği: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..4bf9d021d6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8,6 +8,10 @@ # No proprietary translation resources were used. # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Параметри маски… + Layer Mask Display Options… Параметри відображення маски шару… + # Channel Options and New Spot Channel (#3761) Channel Color Колір каналу Color Indicates: Кольором позначено: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..d385b501ec 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -20,6 +20,10 @@ # (`plural_none` in i18n/mod.rs). +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Tùy chọn mặt nạ… + Layer Mask Display Options… Tùy chọn hiển thị mặt nạ lớp… + # Channel Options and New Spot Channel (#3761) Channel Color Màu kênh Color Indicates: Màu biểu thị: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..45db9feb7d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -11,6 +11,10 @@ # Licensed MIT OR Apache-2.0, as the code. # Product and technology names retain their spelling. See docs/localization-zh-hans.md. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 蒙版选项… + Layer Mask Display Options… 图层蒙版显示选项… + # Channel Options and New Spot Channel (#3761) Channel Color 通道颜色 Color Indicates: 色彩指示: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..225f11547f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -19,6 +19,10 @@ # kept. Full-width punctuation (,。:) inside sentences; half-width parentheses and the " · " # separators as in the English. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 遮色片選項… + Layer Mask Display Options… 圖層遮色片顯示選項… + # Channel Options and New Spot Channel (#3761) Channel Color 色版顏色 Color Indicates: 色彩指示: diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index beb3e9bc32..a2c1139e9b 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -158,8 +158,7 @@ pub fn show(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bo } /// The menu of `l`'s layer-mask thumbnail (#4575), in Photoshop's order. It acts on `l`, which it -/// selects first like the row menu. Photoshop's Mask Options… (the overlay colour) has no -/// command yet, so it is left out. +/// selects first like the row menu. pub fn show_mask(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actions: &mut Vec<(String, Value)>) { let Some(mask) = &l.mask else { return }; crate::widgets::menu_scroll(ui, |ui| { @@ -189,6 +188,13 @@ pub fn show_mask(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actio actions.push(("select.selectAndMask".into(), Value::Null)); ui.close(); } + // Layer Mask Display Options: the rubylith's colour and opacity (`channel.options`). + if ui.button(tl!("Mask Options…")).clicked() { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + actions.push(("ui.maskTarget".into(), json!(true))); + actions.push(("channel.options".into(), Value::Null)); + ui.close(); + } }); } @@ -521,6 +527,15 @@ mod tests { assert!(sel.sample_channel(10, 30, 0) > 0.99, "the selection is kept"); assert!(sel.sample_channel(80, 30, 0) > 0.99, "the clicked layer's mask is added"); assert!(sel.sample_channel(40, 30, 0) < 0.01, "nothing else is selected"); + // Mask Options…: Layer Mask Display Options (the rubylith's colour and opacity). + let (thumbs, _) = crate::mask_thumbs_ui::recorded(&h.ctx, masked.0).expect("mask thumbnail drawn"); + let at = thumbs.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); + click(&mut h, at, PointerButton::Secondary); + let at = h.get_by_label("Mask Options…").rect().center(); + click(&mut h, at, PointerButton::Primary); + let d = h.state().ui.dialogs.last().expect("Mask Options… opens a dialog"); + assert!(crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!("mask"), "{:?}", d.fields); + assert_eq!(d.fields["__label"], json!("Layer Mask Display Options…")); } #[test] diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 163c7cbae8..fcfea89c65 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -451,7 +451,8 @@ fn mask_view(h: &Harness<'_, PhotocraftApp>) -> serde_json::Value { fn canvas_px(h: &Harness<'_, PhotocraftApp>) -> Option> { let st = h.state().session.active().unwrap(); - crate::channel_view::render(&st.doc, &st.channel_view, photocraft_geom::Rect::new(0, 0, 200, 100), 1, false, st.active_layer) + let look = crate::channel_view::Look::of(&h.state().session); + crate::channel_view::render(&st.doc, &st.channel_view, photocraft_geom::Rect::new(0, 0, 200, 100), 1, look, st.active_layer) } fn near(a: Rect, b: Rect) -> bool { @@ -621,6 +622,9 @@ fn backslash_toggles_the_rubylith() { assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"), "painting paints the mask"); let px = canvas_px(&h).unwrap(); assert_eq!(px[50 * 200 + 50], egui::Color32::from_rgba_premultiplied(128, 0, 0, 128), "50% red over hidden areas"); + // Layer Mask Display Options: opaque green over what the mask hides instead. + h.state_mut().run("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 100})).unwrap(); + assert_eq!(canvas_px(&h).unwrap()[50 * 200 + 50], egui::Color32::from_rgb(0, 255, 0)); h.key_press(egui::Key::Backslash); h.run_steps(2); assert_eq!(mask_view(&h), serde_json::Value::Null, "\\ again hides it"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2839e51f13..36f8d97001 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -428,13 +428,16 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::stroke_ui::open(app)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, or Quick Mask Options while in Quick Mask (with params: the engine directly). + // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while + // in Quick Mask (with params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), - _ => return Err("target an alpha or spot channel first".into()), + _ if mask => json!("mask"), + _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) diff --git a/docs/context-menu-inventory.md b/docs/context-menu-inventory.md index 3d7bc522fb..a932a58b20 100644 --- a/docs/context-menu-inventory.md +++ b/docs/context-menu-inventory.md @@ -30,7 +30,7 @@ Canvas invariant: secondary click must not accidentally start a paint stroke, co | Surface | Current entries or behavior | Command route / gap | |---|---|---| | Layers panel row | Blending Options; Duplicate/Delete; Group from Layers; Quick Export PNG / Export As; Artboard/Frame from Layers; Convert to Smart Object; content-specific Rasterize; Add/Disable/Apply/Delete Layer Mask; Create/Release Clipping Mask; Link/Select Linked; Copy/Paste/Clear Layer Style; Merge Layers/Down, Merge Visible, Flatten Image; Rename Layer. Multi-select changes duplicate/delete/merge labels and target. | `layer_menu_ui::entries` supplies IDs for all except inline rename, then `menus::invoke` or engine dispatch. Missing or disabled commands are filtered/greyed. Check menu visibility and enabled state for groups, backgrounds, masks, type, adjustment/fill, smart objects, and multi-selection. | -| Channels panel row | Alpha: Duplicate/Delete/Rename, spot conversion or merge, selection-display and overlay controls. Quick Mask: Exit, display and overlay. Composite/color: Duplicate. Layer mask: Enable/Disable, Delete. Common: New Channel, New Spot Channel, Split, Merge. | `channel.*`, `layer.layerMask.*`, `select.editInQuickMaskMode`, `ui.renameChannel`. Check selection loading/saving, channel options, and per-row enablement against Photoshop. | +| Channels panel row | Alpha: Duplicate/Delete/Rename, spot conversion or merge, selection-display and overlay controls. Quick Mask: Exit, display and overlay. Composite/color: Duplicate. Layer mask: Enable/Disable, Delete, Layer Mask Display Options… (also on double-click). Common: New Channel, New Spot Channel, Split, Merge. | `channel.*`, `layer.layerMask.*`, `select.editInQuickMaskMode`, `ui.renameChannel`. Check selection loading/saving, channel options, and per-row enablement against Photoshop. | | Brushes panel preset | Rename Brush, Delete Brush. | Routed through `brush.presets.*` panel actions. Check duplicate/export/import and library organization. | | Brushes panel group | Rename Group, Delete Group. | Routed through preset panel actions; confirm group deletion consequences. | | Generic presets group (gradients, patterns, swatches, etc.) | Rename Group, Delete Group. | `preset_panels.rs`; verify each collection's appropriate create, export/import, and delete semantics. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 14d416f19b..4b397a9de3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -105,6 +105,7 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.new.layer` | `{"name":"Ink"}` | | `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | +| `channel.options` | `{"channel":index\|name\|"quickMask"\|"mask",…}`: Channel Options (name, `indicates`, overlay `color`, `opacity`/`solidity`). On `"mask"` it is Layer Mask Display Options: the `color` and `opacity` of every layer mask's rubylith (`\`, ⇧⌥-click), kept in the preferences as `layerMaskOverlay` (not an undo step) and returned under that key | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | | `layer.fill.pattern.set` | `{"layer":id?,"document":id?,"pattern":id\|name?,"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]?}`: edit a Pattern Fill (the Pattern Fill dialog's OK) in one undo step; omitted values keep the layer's, `phase` `[0,0]` is Snap to Origin. `layer.newFillLayer.pattern` from a menu (no params) and `layer.layerContentOptions` on a Pattern Fill layer open the Pattern Fill dialog. | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..e6c59d5d35 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -179,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 170** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 2f21722eb436d406b40328897c621d1cd8eeb601 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 17:31:19 +0200 Subject: [PATCH 0337/1452] Content-Aware Move: live preview for large selections (TOOL-213-4, #6680) The live preview only covered selections up to 256x256 px; larger ones (the command takes up to 16 M) errored, the UI remembered the failure and showed nothing until release. Engine: a preview whose selection (or landed area) is over 256x256 is now solved on a scaled-down copy of the area around the selection and where it lands (area-average resize of the target, or of the composite with Sample All Layers, and of the selection), on a one-layer document of its own, then enlarged back (bilinear, float samples, so any depth) over the selection's shape where it lands and, for a move, where it left. The landed content is sampled through the scaled placement, so rounding the scaled offset does not shift it. ContentAwarePreview::refine gives the full-resolution job, with the command's own margin, so the refined preview is the command's result bit for bit. Every step checks the JobCtx; the session is never touched; bad params and selections over the command's limit still error. Selections within 256x256 take the unchanged path (same output as before, checked by hash against main). The bilinear resampler used by Transform On Drop now runs its rows in parallel (same results). Measured (release, 24 MP 8-bit document, Ryzen 9 7950X with other builds running): coarse preview 600x400 0.26 s, 1500x1000 0.38 s, 3000x2000 0.69 s (256x256 full-resolution preview: 0.21 s); the exact refine takes as long as the command (0.8 s, 4.1 s, 9.4 s). UI: cam_preview shows the coarse result, then starts the refine as a second cancellable job and swaps it in; a failed refine leaves the coarse result shown, and a failed first job is still not retried. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/retouch_cmds/content_aware_move.rs | 269 +++++++++++++++--- crates/engine/src/retouch_cmds/tests.rs | 84 +++++- crates/ui-egui/src/cam_preview.rs | 96 ++++++- docs/gaps.md | 10 +- docs/scorecard.md | 4 +- docs/target-app-parity.md | 7 +- scorecard/tools.toml | 4 +- 7 files changed, 411 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index ddca9c6e0f..25bc52536c 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -149,31 +149,36 @@ fn mapped_bounds(h: &Homography, r: Rect) -> Rect { } /// `src` resampled (bilinear, edges clamped) through `inv` (destination → source) over `rect`: -/// the content as it lands. Works on float samples, so at any depth. +/// the content as it lands. Works on float samples, so at any depth; rows in parallel. fn resample(src: &Region, inv: &Homography, rect: Rect) -> Region { + use rayon::prelude::*; let (sw, sh, n) = (src.rect.width() as usize, src.rect.height() as usize, src.ch); - let mut out = Region { rect, ch: n, data: vec![0.0; rect.width() as usize * rect.height() as usize * n] }; - if sw == 0 || sh == 0 { + let w = rect.width() as usize; + let mut out = Region { rect, ch: n, data: vec![0.0; w * rect.height() as usize * n] }; + if sw == 0 || sh == 0 || w == 0 || n == 0 { return out; } let px = |x: usize, y: usize, c: usize| src.data.get((y * sw + x) * n + c).copied().unwrap_or(0.0); - for (i, dst) in out.data.chunks_exact_mut(n).enumerate() { - let (qx, qy) = (rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); - let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); - let fx = (sx - 0.5 - f64::from(src.rect.x0)).clamp(0.0, (sw - 1) as f64); - let fy = (sy - 0.5 - f64::from(src.rect.y0)).clamp(0.0, (sh - 1) as f64); - if !(fx.is_finite() && fy.is_finite()) { - continue; + out.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| { + let qy = rect.y0 + row as i32; + for (col, dst) in line.chunks_exact_mut(n).enumerate() { + let qx = rect.x0 + col as i32; + let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); + let fx = (sx - 0.5 - f64::from(src.rect.x0)).clamp(0.0, (sw - 1) as f64); + let fy = (sy - 0.5 - f64::from(src.rect.y0)).clamp(0.0, (sh - 1) as f64); + if !(fx.is_finite() && fy.is_finite()) { + continue; + } + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + for (c, d) in dst.iter_mut().enumerate() { + let top = px(x0, y0, c) * (1.0 - tx) + px(x1, y0, c) * tx; + let bottom = px(x0, y1, c) * (1.0 - tx) + px(x1, y1, c) * tx; + *d = top * (1.0 - ty) + bottom * ty; + } } - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); - let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); - for (c, d) in dst.iter_mut().enumerate() { - let top = px(x0, y0, c) * (1.0 - tx) + px(x1, y0, c) * tx; - let bottom = px(x0, y1, c) * (1.0 - tx) + px(x1, y1, c) * tx; - *d = top * (1.0 - ty) + bottom * ty; - } - } + }); out } @@ -217,18 +222,26 @@ fn plan(s: &Session, p: &Value, margin: i32) -> Result { return Err(bad(CMD, format!("the selection is too large to move ({pixels} pixels, at most {MAX_PIXELS})"))); } let map = placement(p, area, (dx, dy))?; - let dest = match &map { + if map.is_none() && (dx, dy) == (0, 0) { + return Err(bad(CMD, "drag the selection to where the content should go")); + } + let dest = landing(map.as_ref(), area, (dx, dy), canvas)?; + let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) +} + +/// Where the content of `area` lands on `canvas`: moved by `offset`, or (Transform On Drop) the +/// bounds of the area mapped by `map`, clipped to the canvas. +fn landing(map: Option<&Homography>, area: Rect, (dx, dy): (i32, i32), canvas: Rect) -> Result { + match map { None => { - if (dx, dy) == (0, 0) { - return Err(bad(CMD, "drag the selection to where the content should go")); - } // Content dropped beyond the edge would be lost: keep it on the canvas, as the Patch // Tool does. let dest = area.translate(dx, dy); if !canvas.contains_rect(&dest) { return Err(bad(CMD, "the moved selection must stay inside the canvas")); } - dest + Ok(dest) } Some(h) => { let landed = mapped_bounds(h, area); @@ -240,11 +253,9 @@ fn plan(s: &Session, p: &Value, margin: i32) -> Result { if dest.is_empty() { return Err(bad(CMD, "the transformed selection must land on the canvas")); } - dest + Ok(dest) } - }; - let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) + } } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -528,18 +539,24 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { ) } -/// Largest selection (bounding box, in pixels) the live preview computes. +/// Largest area (the selection's bounding box, or where it lands if that is larger, in pixels) a +/// preview solves at full resolution. A larger one is solved on a copy scaled down to about this +/// size ([`ContentAwarePreview::is_coarse`]). const PREVIEW_MAX_PIXELS: u64 = 256 * 256; /// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve. const PREVIEW_MARGIN: i32 = 48; /// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of -/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and -/// only small selections), so the result is close to, not the same as, what the command makes. +/// [`preview`], with the move's params. Cheaper than the command: a small sampling margin, and a +/// selection larger than [`PREVIEW_MAX_PIXELS`] is solved scaled down and enlarged back (coarse). +/// The result is close to, not the same as, what the command makes; [`Self::refine`] gives the +/// command's own result for a coarse one. pub struct ContentAwarePreview { doc: std::sync::Arc, plan: Plan, params: Value, + /// Below 1: the scale the move is solved at before it is enlarged back. + scale: f64, } impl ContentAwarePreview { @@ -548,19 +565,197 @@ impl ContentAwarePreview { pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> { let mut work = (*self.doc).clone(); let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?; + if self.is_coarse() { + let damage = run_scaled(&mut work, sel, &self.plan, &self.params, self.scale, ctx)?; + return Ok((work, damage)); + } let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; Ok((work, damage)) } + + /// Whether the preview is solved on a scaled-down copy (a selection larger than a preview + /// solves at full resolution): fast, but softer than the result. + pub fn is_coarse(&self) -> bool { + self.scale < 1.0 + } + + /// For a coarse preview, the full-resolution one: exactly what `paint.contentAwareMove` makes + /// (the command's sampling margin), and as slow. `None` when this preview already is at full + /// resolution. + pub fn refine(&self) -> Option { + self.is_coarse().then(|| ContentAwarePreview { + doc: self.doc.clone(), + plan: Plan { margin: MAX_MARGIN, ..self.plan }, + params: self.params.clone(), + scale: 1.0, + }) + } } /// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors -/// like the command does, and for a selection larger than a preview takes. +/// as the command does (bad params, nothing to move, a selection larger than the command takes). pub fn preview(s: &Session, p: &Value) -> Result { let plan = plan(s, p, PREVIEW_MARGIN)?; - let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height()); - if pixels > PREVIEW_MAX_PIXELS { - return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})"))); - } + let px = |r: Rect| u64::from(r.width()) * u64::from(r.height()); + let pixels = px(plan.area).max(px(plan.dest)); + let scale = if pixels > PREVIEW_MAX_PIXELS { (PREVIEW_MAX_PIXELS as f64 / pixels as f64).sqrt() } else { 1.0 }; let d = s.active().ok_or(EngineError::NoDocument)?; - Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() }) + Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone(), scale }) +} + +/// The surface's tiles over `r` (shared, not copied) on a surface of their own: what a resize of +/// that part reads. +fn crop(surf: &Surface, r: Rect) -> Surface { + let mut all = surf.clone(); + let mut out = Surface::with_default(surf.format(), &surf.default_pixel()); + out.put_tiles(all.take_tiles(r)); + out +} + +/// A translation by `(dx, dy)`. +fn shift(dx: f64, dy: f64) -> Homography { + Homography([1.0, 0.0, dx, 0.0, 1.0, dy, 0.0, 0.0, 1.0]) +} + +/// [`run_move`] solved `scale` times smaller (`scale` < 1) and enlarged back: the area around the +/// selection and where it lands is scaled down (area average), moved there with a preview's +/// sampling margin, and the result enlarged (bilinear) over the selection's shape where it lands +/// and, for a move, where it left. The landed content is sampled where the scaled solve put it, so +/// rounding the scaled offset doesn't shift it. Writes into `doc` (a copy) and returns the +/// rectangle it changed. +fn run_scaled(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, scale: f64, ctx: &crate::jobs::JobCtx) -> Result { + use photocraft_algo::resample::{Resample, resize_surface_in_canvas, scaled_rect}; + let f = scale; + if !(f > 0.0 && f < 1.0) { + return Err(EngineError::Other("internal error: a coarse preview needs a scale below 1".into())); + } + let (dx, dy) = plan.offset; + let full_map = plan.map.unwrap_or_else(|| shift(f64::from(dx), f64::from(dy))); + let full_inv = full_map.inverse().ok_or_else(|| bad(CMD, "the transform collapses the selection"))?; + // What the scaled solve reads: both windows, with two scaled pixels to spare for the filter. + let reach = (f64::from(PREVIEW_MARGIN + 2) / f).ceil().min(1e6) as i32; + let reach_rect = plan.area.union(&plan.dest).inflate(reach).intersect(&plan.canvas); + ctx.check()?; + + // 1. The pixels and the selection scaled down, on a document of their own (one layer). + let fmt = crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0.format(); + let src = if plan.target.all_layers { + let region = read(doc, plan.target, p, reach_rect)?; + let mut s = Surface::new(fmt); + s.write_region(reach_rect, ®ion.data); + s + } else { + crop(crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0, reach_rect) + }; + let small = resize_surface_in_canvas(&src, f, f, Resample::Bilinear, plan.canvas); + let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect), f, f, Resample::Bilinear, plan.canvas); + ctx.check()?; + let canvas = scaled_rect(plan.canvas, f, f); + let size = photocraft_geom::Size { width: canvas.width(), height: canvas.height() }; + let mut mini = Document::new("Content-Aware Move preview", size, doc.mode, doc.depth); + let layer = photocraft_doc::Layer::new("preview", LayerContent::Raster(small)); + let id = layer.id; + mini.layers.push(layer); + + // 2. The move, scaled. + let area = selection_area(&small_sel, canvas); + if area.is_empty() { + return Err(bad(CMD, "the selection is empty")); + } + let mut offset = ((f64::from(dx) * f).round() as i32, (f64::from(dy) * f).round() as i32); + if plan.map.is_none() { + // Rounding may push the scaled patch a pixel past the edge: keep it on the canvas. + let moved = area.translate(offset.0, offset.1); + offset.0 += (canvas.x0 - moved.x0).max(0) - (moved.x1 - canvas.x1).max(0); + offset.1 += (canvas.y0 - moved.y0).max(0) - (moved.y1 - canvas.y1).max(0); + } + let map = match plan.map { + Some(_) => placement(p, area, offset)?, + None => None, + }; + let dest = landing(map.as_ref(), area, offset, canvas)?; + let small_plan = Plan { target: Target { id: Some(id), all_layers: false }, canvas, area, offset, map, dest, margin: PREVIEW_MARGIN, ..*plan }; + // The small document's layer is the target: no channel or mask targeting there. + let mut small_p = p.clone(); + if let Some(o) = small_p.as_object_mut() { + o.remove("target"); + } + let (solved, _, _) = run_move(&mut mini, &small_sel, &small_plan, &small_p, ctx, "Content-Aware Move preview")?; + ctx.check()?; + + // 3. Enlarged back over the selection's shape: where it lands (through the scaled placement) + // and, for a move, where it left (in place). + let solved = Region::read(&solved, scaled_rect(reach_rect, f, f).inflate(1).intersect(&canvas)); + let to_small = Homography([f, 0.0, 0.0, 0.0, f, 0.0, 0.0, 0.0, 1.0]); + let small_map = map.unwrap_or_else(|| shift(f64::from(offset.0), f64::from(offset.1))); + let cov = if plan.map.is_some() { coverage(sel, plan.area) } else { Vec::new() }; + let landed_in = |r: Rect| -> Vec { + match &plan.map { + None => mask(sel, r.translate(-dx, -dy)), + Some(_) => landed_mask(&cov, plan.area, &full_inv, r), + } + }; + let landed = landed_in(plan.dest); + let content = resample(&solved, &small_map.mul(&to_small).mul(&full_inv), plan.dest); + let mut damage = replace(doc, plan.target, p, &content, &landed)?; + if plan.mode == Mode::Move { + ctx.check()?; + let hole: Vec = mask(sel, plan.area).iter().zip(landed_in(plan.area)).map(|(s, l)| *s && !l).collect(); + if hole.iter().any(|h| *h) { + damage = damage.union(&replace(doc, plan.target, p, &resample(&solved, &to_small, plan.area), &hole)?); + } + } + Ok(damage) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::JobCtx; + + fn textured(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let v = ((x * 7 + y * 13) % 17) as f32 / 17.0; + data.extend(photocraft_raster::from_rgba(&fmt, [v, 1.0 - v, 0.5, 1.0])); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s + } + + #[test] + fn a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit() { + let mut s = textured(300, 300); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 60, "height": 50})).unwrap(); + for p in [json!({"offset": [120, 90]}), json!({"offset": [120, 90], "rotate": 30, "mode": "extend"})] { + let job = preview(&s, &p).unwrap(); + assert!(!job.is_coarse()); + let (got, damage) = job.run(&JobCtx::new()).unwrap(); + // What the preview has always computed: the move at full resolution, with the + // preview's margin. + let plan = plan(&s, &p, PREVIEW_MARGIN).unwrap(); + let d = s.active().unwrap(); + let mut want = (*d.doc).clone(); + let (_, want_damage, _) = run_move(&mut want, d.doc.selection.as_ref().unwrap(), &plan, &p, &JobCtx::new(), "test").unwrap(); + let b = want.bounds(); + assert_eq!(damage, want_damage); + assert!(got.layers[0].surface().unwrap().read_region(b) == want.layers[0].surface().unwrap().read_region(b), "{p}"); + } + // 256 × 256 is the largest selection solved at full resolution. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 256, "height": 256})).unwrap(); + assert!(!preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse()); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 257, "height": 256})).unwrap(); + assert!(preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse()); + } } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e20706857a..4c8e2e6247 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1097,14 +1097,92 @@ fn content_aware_move_preview_shows_the_result_without_touching_the_session() { // The session is untouched. assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31))); assert_eq!(s.active().unwrap().revision, before); - // A cancelled preview stops; a selection past the preview's size and bad params are errors. + // A small selection is solved at full resolution, with nothing to refine. + let job = content_aware_move_preview(&s, &p).unwrap(); + assert!(!job.is_coarse() && job.refine().is_none()); + // A cancelled preview stops; bad params are errors. let ctx = JobCtx::new(); ctx.cancel(); assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled))); assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err()); assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err()); - s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap(); - assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err()); + assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "mode": "copy"})).is_err()); +} + +/// A 30×30 object in the middle of a 400×300 selection (over the 256×256 a preview solves at full +/// resolution) on a 700×400 canvas, dragged by (250, 60). +fn large_move(depth: u64) -> Session { + let mut s = session(700, 400, depth, "rgb"); + paint_layer(&mut s, |x, y| if (195..225).contains(&x) && (145..175).contains(&y) { [0.05, 0.1, 0.9, 1.0] } else { texture(x, y) }); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 400, "height": 300})).unwrap(); + s +} + +#[test] +fn content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths() { + use crate::jobs::JobCtx; + let p = json!({"offset": [250, 60]}); + for depth in DEPTHS { + let mut s = large_move(depth); + let before = s.active().unwrap().revision; + let job = content_aware_move_preview(&s, &p).unwrap(); + assert!(job.is_coarse(), "depth {depth}: solved scaled down"); + let (coarse, damage) = job.run(&JobCtx::new()).unwrap(); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + // The object lands where the command puts it (its middle; the coarse pass softens the + // edges) and its old place is filled. + for (x, y) in [(460, 220), (470, 230), (455, 215), (465, 225)] { + assert!(is_object(px(&coarse, x, y)), "depth {depth}: the object lands at {x},{y}: {:?}", px(&coarse, x, y)); + } + for (x, y) in [(435, 220), (485, 220), (460, 195), (460, 245)] { + assert!(!is_object(px(&coarse, x, y)), "depth {depth}: nothing of the object at {x},{y}"); + } + assert!(!is_object(px(&coarse, 210, 160)), "depth {depth}: the old place is filled"); + assert!(damage.contains(470, 230) && damage.contains(210, 160), "{damage:?}"); + // Outside the selection and where it lands, nothing changes. + for (x, y) in [(5, 5), (690, 20), (20, 390)] { + assert_eq!(px(&coarse, x, y), rgba(&s, x, y), "depth {depth}: {x},{y} untouched"); + } + // The session is untouched. + assert!(is_object(rgba(&s, 210, 160)) && !is_object(rgba(&s, 460, 220))); + assert_eq!(s.active().unwrap().revision, before); + // Cancelled, either pass stops. + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(job.run(&ctx), Err(EngineError::Cancelled)), "depth {depth}"); + let fine = job.refine().unwrap(); + assert!(!fine.is_coarse() && fine.refine().is_none()); + assert!(matches!(fine.run(&ctx), Err(EngineError::Cancelled)), "depth {depth}"); + if depth != 8 { + continue; + } + // The refined preview is the command's result, bit for bit. + let (exact, _) = fine.run(&JobCtx::new()).unwrap(); + assert_eq!(s.active().unwrap().revision, before); + s.execute("paint.contentAwareMove", p.clone()).unwrap(); + let b = exact.bounds(); + let made = s.active().unwrap().doc.layers[0].surface().unwrap().read_region(b); + assert!(exact.layers[0].surface().unwrap().read_region(b) == made, "refined preview = command"); + } +} + +#[test] +fn content_aware_move_coarse_preview_handles_transform_extend_and_limits() { + use crate::jobs::JobCtx; + let s = large_move(8); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + // Transform On Drop: turned 90° about the selection's centre (210, 160), then moved; the + // object sits at the centre, so it lands at (460, 220) either way. + let (turned, _) = content_aware_move_preview(&s, &json!({"offset": [250, 60], "rotate": 90})).unwrap().run(&JobCtx::new()).unwrap(); + assert!(is_object(px(&turned, 460, 220)) && !is_object(px(&turned, 210, 160))); + // Extend keeps the original. + let (extended, _) = content_aware_move_preview(&s, &json!({"offset": [250, 60], "mode": "extend"})).unwrap().run(&JobCtx::new()).unwrap(); + assert!(is_object(px(&extended, 460, 220)) && is_object(px(&extended, 210, 160))); + // Off the canvas, or past the command's own limit, is still an error. + assert!(content_aware_move_preview(&s, &json!({"offset": [400, 0]})).is_err()); + let mut big = session(5000, 4000, 8, "rgb"); + big.execute("select.rect", json!({"x": 0, "y": 0, "width": 4500, "height": 3900})).unwrap(); + assert!(content_aware_move_preview(&big, &json!({"offset": [10, 10]})).is_err()); } #[test] diff --git a/crates/ui-egui/src/cam_preview.rs b/crates/ui-egui/src/cam_preview.rs index 4065e3353c..45f6c5f1ed 100644 --- a/crates/ui-egui/src/cam_preview.rs +++ b/crates/ui-egui/src/cam_preview.rs @@ -1,16 +1,18 @@ //! Content-Aware Move drags and its Transform On Drop box shown with the result: once the pointer //! (or the box) rests for a moment, the move is computed as a background job //! ([`photocraft_engine::retouch_cmds::content_aware_move_preview`]: a smaller sampling window than -//! the command and only small selections) and the canvas shows the document with it. Any change of -//! the offset, scale or rotation cancels the job and starts the wait again, so the UI never waits -//! for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the document is -//! untouched until then. Not available on wasm (no threads). +//! the command) and the canvas shows the document with it. A selection too large to solve at once +//! shows a coarse result first (solved scaled down), then the exact one, computed as a second job. +//! Any change of the offset, scale or rotation cancels the jobs and starts the wait again, so the +//! UI never waits for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the +//! document is untouched until then. Not available on wasm (no threads). use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError}; use photocraft_doc::{DocId, Document}; use photocraft_engine::jobs::JobCtx; +use photocraft_engine::retouch_cmds::ContentAwarePreview; use serde_json::Value; use crate::PhotocraftApp; @@ -27,6 +29,10 @@ struct Running { key: String, ctx: JobCtx, rx: Receiver>, + /// The full-resolution job that follows a coarse one. + refine: Option, + /// This job refines a coarse preview already shown: its failure leaves that one shown. + refining: bool, } pub(crate) struct CamPreview { @@ -35,7 +41,7 @@ pub(crate) struct CamPreview { /// The params the move has now, and when they last changed (ms). key: String, since: f64, - /// Params whose preview failed (too large a selection, say): not tried again. + /// Params whose preview failed (a locked layer, say): not tried again. failed: Option, running: Option, /// The latest finished preview, with the params it shows. @@ -126,15 +132,21 @@ fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc, u64)> { (Ok(d), Some(r)) => { // Duotone documents display through their inks. let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d); - p.shown = Some((r.key, Arc::new(d))); + p.shown = Some((r.key.clone(), Arc::new(d))); p.serial += 1; + // A coarse result: the exact one follows. + if let Some(job) = r.refine { + p.running = spawn(job, r.key, true); + } } + // A failed refinement leaves the coarse result shown. + (_, Some(r)) if r.refining => {} (_, Some(r)) => p.failed = Some(r.key), _ => {} } } Err(TryRecvError::Disconnected) => { - if let Some(r) = p.running.take() { + if let Some(r) = p.running.take().filter(|r| !r.refining) { p.failed = Some(r.key); } } @@ -158,6 +170,12 @@ fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc, u64)> { /// Compute the preview of `params` on a worker thread. fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option { let job = photocraft_engine::retouch_cmds::content_aware_move_preview(&app.session, params).ok()?; + spawn(job, key, false) +} + +/// Run `job` on a worker thread (`refining` a coarse preview already shown). +fn spawn(job: ContentAwarePreview, key: String, refining: bool) -> Option { + let refine = job.refine(); let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel(); let worker = ctx.clone(); @@ -167,7 +185,7 @@ fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option { let _ = tx.send(job.run(&worker).map(|(d, _)| d)); }) .ok()?; - Some(Running { key, ctx, rx }) + Some(Running { key, ctx, rx, refine, refining }) } /// Keep frames coming while a preview waits out its debounce or computes. @@ -268,18 +286,72 @@ mod tests { assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none()); } + /// Step until the canvas shows a preview with a key past `after`, or give up after a while. + fn wait_for(app: &mut PhotocraftApp, now: &mut f64, after: u64) -> Option<(Arc, u64)> { + for _ in 0..6000 { + if let Some(shown) = step(app, *now).filter(|s| s.1 > after) { + return Some(shown); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + *now += 10.0; + } + None + } + #[test] - fn a_selection_too_large_to_preview_is_not_retried() { + fn a_large_selection_shows_a_coarse_result_then_the_exact_one() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.run("paint.pencil", json!({"points": [[150, 160], [170, 160]], "size": 12, "color": "#0000ff"})).unwrap(); app.run("select.rect", json!({"x": 10, "y": 10, "width": 300, "height": 300})).unwrap(); app.ui.tool = Tool::ContentAwareMove; let m = egui::Modifiers::NONE; tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); - tool_event(&mut app, ToolEvent::Move { x: 140.0, y: 100.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 340.0, y: 100.0, pressure: 1.0 }, m); assert!(step(&mut app, 0.0).is_none()); - assert!(step(&mut app, DEBOUNCE_MS + 1.0).is_none()); + let mut now = DEBOUNCE_MS; + // First the coarse result, while the exact one computes. + let (coarse, first) = wait_for(&mut app, &mut now, 0).expect("a coarse preview"); + assert!(blue(&coarse, 400, 160) && !blue(&coarse, 160, 160), "the coarse preview shows the move"); + let refining = app.cam_preview.as_ref().unwrap().running.as_ref().is_some_and(|r| r.refining); + assert!(refining || app.cam_preview.as_ref().unwrap().serial > 1, "the exact result follows"); + // Then the exact one: the command's own result. + let (exact, _) = wait_for(&mut app, &mut now, first).expect("the exact preview"); + assert!(app.cam_preview.as_ref().unwrap().running.is_none() && showing(&app)); + assert!(!blue(&app.session.active().unwrap().doc, 400, 160), "the document is untouched"); + tool_event(&mut app, ToolEvent::Up { x: 340.0, y: 100.0 }, m); + let doc = app.session.active().unwrap().doc.clone(); + let b = doc.bounds(); + assert!( + exact.layers[0].surface().unwrap().read_region(b) == doc.layers[0].surface().unwrap().read_region(b), + "the exact preview is what release makes" + ); + } + + #[test] + fn a_failed_preview_is_not_retried() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("select.rect", json!({"x": 6, "y": 14, "width": 32, "height": 32})).unwrap(); + app.run("layer.lockLayers", json!({"pixels": true})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none()); + let mut now = DEBOUNCE_MS; + for _ in 0..3000 { + assert!(step(&mut app, now).is_none(), "nothing to show"); + if app.cam_preview.as_ref().unwrap().failed.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + } let p = app.cam_preview.as_ref().unwrap(); - assert!(p.running.is_none() && p.failed.as_deref() == Some(p.key.as_str())); + assert!(p.running.is_none() && p.shown.is_none() && p.failed.as_deref() == Some(p.key.as_str())); + // Resting longer starts nothing new. + assert!(step(&mut app, now + 1000.0).is_none()); + assert!(app.cam_preview.as_ref().unwrap().running.is_none()); } } diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..1862eb5642 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -66,9 +66,10 @@ what an agent can't finish alone. ### G7. Workspaces shown as parameter dialogs - **Missing:** Select and Mask workspace layout (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area - (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview - of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, - TOOL-214-7, are done). + (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14). Content-Aware Move's live preview + of the result is done (TOOL-213-4, 2026-10-16, #6680: every selection the command takes, coarse + first for large ones, then the exact result), as are Transform On Drop and Content-Aware Scale + through the transform box (TOOL-214-7). Blur Gallery: Field, Iris, Tilt-Shift and Spin Blur are edited on the image with Photoshop's pins and handles (2026-10-11, #3373); Tilt-Shift has one band (the engine takes one), and Path Blur (#1759) is still a parameter dialog. @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G7: Content-Aware Move's live preview covers every selection the command takes (over 256 × 256: a coarse result solved scaled down, then the exact one); TOOL-213-4 done (#6680) | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..366a4a2556 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -135,7 +135,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, #4385); a selection over 256x256 shows a coarse result solved scaled down first, then the command's exact result as a second job (#6680; 24 MP document, release: 600x400 coarse 0.26 s then exact 0.8 s, 1500x1000 0.38 s then 4.1 s, 3000x2000 0.69 s then 9.4 s; was no preview above 256x256). Tests: content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths, content_aware_move_coarse_preview_handles_transform_extend_and_limits, a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit, a_large_selection_shows_a_coarse_result_then_the_exact_one | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..3d42ced996 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-16 · **Change:** minor (Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -444,7 +444,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is done (`TOOL-213-4`): Move/Extend, Structure/Color, Sample All Layers, Transform On Drop, and a live result preview for every selection the command takes (coarse first for large ones, then exact; 2026-10-16, #6680). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | @@ -553,7 +553,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). checked against Photoshop's output on every build. Their pass rate becomes the headline parity number. 4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790; Add/Delete Anchor Point, #1044); - finish Content-Aware Move's Transform On Drop and live result preview. + Content-Aware Move's Transform On Drop and live result preview are done (2026-10-16). Pencil, Mixer Brush, Patch, Vertical Type, Pattern Stamp and Rotate View are already available on the toolbar. 5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49). 6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Content-Aware Move: live result preview for every selection the command takes (TOOL-213-4 done, #6680); percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..dd4eb88ff3 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -29,9 +29,9 @@ note = "paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J [[item]] id = "TOOL-213-4" target = "Content-Aware Move tool reusing inpaint completion" -status = "partial" +status = "done" issue = 213 -note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections" +note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, #4385); a selection over 256x256 shows a coarse result solved scaled down first, then the command's exact result as a second job (#6680; 24 MP document, release: 600x400 coarse 0.26 s then exact 0.8 s, 1500x1000 0.38 s then 4.1 s, 3000x2000 0.69 s then 9.4 s; was no preview above 256x256). Tests: content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths, content_aware_move_coarse_preview_handles_transform_extend_and_limits, a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit, a_large_selection_shows_a_coarse_result_then_the_exact_one" [[item]] id = "TOOL-213-5" From e5a4af76487551bb7e23e98319807b4c4f20977c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:44:00 +0000 Subject: [PATCH 0338/1452] Find small objects in large documents with the Object Finder Fixes #6576 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/segment/grabcut.rs | 29 ++++++++++++++++++++++++++++- crates/algo/src/segment/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..9e53325edb 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -144,6 +144,10 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. +/// +/// When the whole-canvas pass finds nothing in a large document (an object only a few working +/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of +/// the same working size around the point, down to full resolution. pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { return None; @@ -152,7 +156,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u if !canvas.contains(px, py) { return None; } - let step = super::scale_for(canvas, work_px.max(1024)); + let work_px = work_px.max(1024); + let mut step = super::scale_for(canvas, work_px); + if let Some(r) = object_in(sampler, canvas, px, py, step) { + return Some(r); + } + // Side (working pixels) of the square windows of the finer passes. + let side = (work_px as f64).sqrt() as usize; + while step > 1 { + step = (step / 4).max(1); + let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32; + let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas); + if window.width() < 4 || window.height() < 4 { + continue; + } + if let Some(r) = object_in(sampler, window, px, py, step) { + return Some(r); + } + } + None +} + +/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`; +/// `(px, py)` lies inside `canvas`. +fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option { let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); if w < 2 || h < 2 { diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..46dda6cb0f 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -762,6 +762,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } +/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is). +#[test] +fn object_at_finds_small_object_in_large_canvas() { + let (w, h) = (4000usize, 3000usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] { + let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh; + let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let truth: Vec = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32); + let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX); + let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found")); + let v = iou(®, &truth, w, h); + assert!(v > 0.8, "{ow}×{oh}: IoU {v}"); + } + // The flat background around the small object is still nothing. + let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + // ---------- Select Subject on the cases reported in #2788 ---------- /// A figure whose upper part is close to the background colour (a cream shirt on a near-white From 8dcc64bd2f2e08412ec793fdfa140bfb174d930d Mon Sep 17 00:00:00 2001 From: tareqziad1998 <83318513+tareqziad1998@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:58:53 +0000 Subject: [PATCH 0339/1452] Type: paragraph direction buttons in the Paragraph panel and options bar (TYPE-218-2, #218) The engine has had TextDirection (Auto / LTR / RTL) and type.setStyle's "direction" key, but nothing in the UI set it. Now: - Window > Paragraph and the Properties Paragraph section have a row of three buttons under the alignment row: Automatic, Left-to-right and Right-to-left paragraph direction. They set the selected layers' direction (or the selected text's paragraphs while typing) in one undo step, and show the current one. - The Type options bar has the same three buttons after its alignment buttons. Like alignment, they also set the direction new type starts with (new tool option `type_direction`, "auto" by default, reset with the tool's other options). - The glyph is drawn (a pilcrow over an arrow), so no new icon asset. - The three tooltips are translated in all 20 catalogs (every_tl_literal_is_translated). Tests: paragraph_direction_buttons_set_and_show_the_direction and options_bar_direction_sets_selected_layers_and_new_type. TYPE-218-2 goes from missing to partial: the visual RTL caret (arrow keys moving on screen rather than in logical order) is still to do. scorecard/type.toml, docs/scorecard.md and docs/gaps.md (G8) are updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpQQNxTPBzSLvgAhX27Vtv --- crates/ui-egui/src/i18n/cs.tsv | 5 +++ crates/ui-egui/src/i18n/de.tsv | 5 +++ crates/ui-egui/src/i18n/el.tsv | 5 +++ crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/fr.tsv | 5 +++ crates/ui-egui/src/i18n/hu.tsv | 5 +++ crates/ui-egui/src/i18n/id.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/ko.tsv | 5 +++ crates/ui-egui/src/i18n/nl.tsv | 5 +++ crates/ui-egui/src/i18n/pl.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++ crates/ui-egui/src/i18n/ru.tsv | 5 +++ crates/ui-egui/src/i18n/sr.tsv | 5 +++ crates/ui-egui/src/i18n/tr.tsv | 5 +++ crates/ui-egui/src/i18n/uk.tsv | 5 +++ crates/ui-egui/src/i18n/vi.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/tool_reset.rs | 4 +- crates/ui-egui/src/type_tool.rs | 64 +++++++++++++++++++++++++++ crates/ui-egui/src/type_tool_tests.rs | 56 +++++++++++++++++++++++ docs/gaps.md | 9 ++-- docs/scorecard.md | 4 +- scorecard/type.toml | 4 +- 27 files changed, 236 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..da6e8c4f0d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3816,3 +3816,8 @@ navigator Zoom Zvětšení This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatický směr odstavce + Left-to-right paragraph direction Směr odstavce zleva doprava + Right-to-left paragraph direction Směr odstavce zprava doleva diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..24004c7cd0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3210,3 +3210,8 @@ curves Smooth Glätten This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatische Absatzrichtung + Left-to-right paragraph direction Absatzrichtung von links nach rechts + Right-to-left paragraph direction Absatzrichtung von rechts nach links diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..7e0c5fcd91 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3908,3 +3908,8 @@ curves Smooth Εξομάλυνση This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Αυτόματη κατεύθυνση παραγράφου + Left-to-right paragraph direction Κατεύθυνση παραγράφου από αριστερά προς τα δεξιά + Right-to-left paragraph direction Κατεύθυνση παραγράφου από δεξιά προς τα αριστερά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..ffb18a1b7d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4170,3 +4170,8 @@ navigator Zoom Zoom This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Dirección de párrafo automática + Left-to-right paragraph direction Dirección de párrafo de izquierda a derecha + Right-to-left paragraph direction Dirección de párrafo de derecha a izquierda diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..dfe36de15b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3213,3 +3213,8 @@ curves Smooth Lisser This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Sens du paragraphe automatique + Left-to-right paragraph direction Sens du paragraphe de gauche à droite + Right-to-left paragraph direction Sens du paragraphe de droite à gauche diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..59f06dd3d4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4127,3 +4127,8 @@ cameraRaw Yellow Sárga This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatikus bekezdésirány + Left-to-right paragraph direction Balról jobbra irányuló bekezdés + Right-to-left paragraph direction Jobbról balra irányuló bekezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..15d707cef1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3200,3 +3200,8 @@ curves Smooth Haluskan This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Arah paragraf otomatis + Left-to-right paragraph direction Arah paragraf kiri ke kanan + Right-to-left paragraph direction Arah paragraf kanan ke kiri diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..716b5df952 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4329,3 +4329,8 @@ shape Appearance Aspetto This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Direzione del paragrafo automatica + Left-to-right paragraph direction Direzione del paragrafo da sinistra a destra + Right-to-left paragraph direction Direzione del paragrafo da destra a sinistra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..9a1a3e9762 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3884,3 +3884,8 @@ curves Smooth スムーズ This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 段落の方向を自動判別 + Left-to-right paragraph direction 左から右への段落方向 + Right-to-left paragraph direction 右から左への段落方向 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..6d5d07a25e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3386,3 +3386,8 @@ curves Smooth 매끄럽게 This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 자동 단락 방향 + Left-to-right paragraph direction 왼쪽에서 오른쪽 단락 방향 + Right-to-left paragraph direction 오른쪽에서 왼쪽 단락 방향 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..2194bd133b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3968,3 +3968,8 @@ curves Smooth Vloeiend maken This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatische alinearichting + Left-to-right paragraph direction Alinearichting van links naar rechts + Right-to-left paragraph direction Alinearichting van rechts naar links diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..981c877471 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3806,3 +3806,8 @@ curves Smooth Wygładź This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatyczny kierunek akapitu + Left-to-right paragraph direction Kierunek akapitu od lewej do prawej + Right-to-left paragraph direction Kierunek akapitu od prawej do lewej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..82660050f1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3998,3 +3998,8 @@ layerLabel No Color Sem cor This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Direção automática do parágrafo + Left-to-right paragraph direction Direção do parágrafo da esquerda para a direita + Right-to-left paragraph direction Direção do parágrafo da direita para a esquerda diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..98c591e7e3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3178,3 +3178,8 @@ curves Smooth Сгладить This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Автоматическое направление абзаца + Left-to-right paragraph direction Направление абзаца слева направо + Right-to-left paragraph direction Направление абзаца справа налево diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..ca845fcdf2 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4281,3 +4281,8 @@ shape Appearance Izgled This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatski smer pasusa + Left-to-right paragraph direction Smer pasusa sleva nadesno + Right-to-left paragraph direction Smer pasusa zdesna nalevo diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..47bec8c0e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3938,3 +3938,8 @@ cameraData Lens Lens This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Otomatik paragraf yönü + Left-to-right paragraph direction Soldan sağa paragraf yönü + Right-to-left paragraph direction Sağdan sola paragraf yönü diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..ccb9dbd17a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3907,3 +3907,8 @@ curves Smooth Згладити This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Автоматичний напрямок абзацу + Left-to-right paragraph direction Напрямок абзацу зліва направо + Right-to-left paragraph direction Напрямок абзацу справа наліво diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..33e59f69d4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3694,3 +3694,8 @@ curves Smooth Làm mượt This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Hướng đoạn văn tự động + Left-to-right paragraph direction Hướng đoạn văn từ trái sang phải + Right-to-left paragraph direction Hướng đoạn văn từ phải sang trái diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..b0e62d1f16 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2824,3 +2824,8 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 自动段落方向 + Left-to-right paragraph direction 从左到右的段落方向 + Right-to-left paragraph direction 从右到左的段落方向 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..167d7c85b4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2809,3 +2809,8 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 自動段落方向 + Left-to-right paragraph direction 由左至右的段落方向 + Right-to-left paragraph direction 由右至左的段落方向 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..7ae845c53c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -539,6 +539,9 @@ pub struct ToolOptions { pub type_size: f32, pub type_aa: String, pub type_align: String, + /// Type tool: paragraph direction of new type, "auto" (from the first strong character), + /// "ltr" or "rtl" (TYPE-218-2). + pub type_direction: String, /// Clone Stamp / Healing Brush. pub clone_aligned: bool, /// Pattern Stamp: lock the tile origin in document space across strokes. @@ -784,6 +787,7 @@ impl Default for ToolOptions { type_size: 48.0, type_aa: "sharp".into(), type_align: "left".into(), + type_direction: "auto".into(), clone_aligned: true, pattern_stamp_aligned: true, pattern_stamp_impressionist: false, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 27e9622ab3..3e7a1d9ecf 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -150,7 +150,9 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), - Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => { + reset!(type_font, type_style, type_size, type_aa, type_align, type_direction) + } Tool::Zoom => reset!(zoom_scrubby), Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::Healing => reset!(clone_aligned, clone_sample), diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..ce9b337b03 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -317,6 +317,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { "fontStyle": o.type_style, "size": o.type_size, "align": o.type_align, + "direction": o.type_direction, "color": hex(app.session.tools.foreground), }); if w >= min && h >= min { @@ -1780,6 +1781,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } ui.spacing_mut().item_spacing.x = 8.0; crate::widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 2.0; + for (key, tip, d) in direction_items() { + if direction_button(ui, d, o.type_direction == key, tip).clicked() { + app.ui.tool_options.type_direction = key.into(); + apply(app, ui.ctx(), json!({"direction": key})); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + crate::widgets::vline(ui, 22.0); // Text colour swatch with a picker popup. let c = shown .as_ref() @@ -1983,6 +1993,47 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl } } +/// Paragraph direction choices for the Paragraph panel and the options bar (TYPE-218-2): the +/// `direction` value `type.setStyle` takes, the tooltip, and the direction it sets. +fn direction_items() -> [(&'static str, &'static str, photocraft_doc::text::TextDirection); 3] { + use photocraft_doc::text::TextDirection as D; + [ + ("auto", tl!("Automatic paragraph direction"), D::Auto), + ("ltr", tl!("Left-to-right paragraph direction"), D::Ltr), + ("rtl", tl!("Right-to-left paragraph direction"), D::Rtl), + ] +} + +/// A pilcrow over an arrow pointing the way the paragraph runs; Auto points both ways. +fn direction_glyph(p: &egui::Painter, r: egui::Rect, d: photocraft_doc::text::TextDirection, c: Color32) { + use photocraft_doc::text::TextDirection as D; + let g = p.layout_no_wrap("¶".to_string(), egui::FontId::proportional(11.0), c); + p.galley(egui::pos2(r.center().x - g.size().x / 2.0, r.top() - 2.0), g, c); + let (y, s) = (r.bottom() - 1.5, Stroke::new(1.3, c)); + p.line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], s); + let head = |x: f32, towards: f32| { + for dy in [-3.0, 3.0] { + p.line_segment([egui::pos2(x, y), egui::pos2(x - towards * 3.0, y + dy)], s); + } + }; + if d != D::Rtl { + head(r.right(), 1.0); + } + if d != D::Ltr { + head(r.left(), -1.0); + } +} + +/// The options bar's 24 px square button for one paragraph direction, styled like its icon buttons. +fn direction_button(ui: &mut egui::Ui, d: photocraft_doc::text::TextDirection, selected: bool, tip: &str) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let tint = crate::icons::button_chrome(ui, r, selected, resp.hovered()); + direction_glyph(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)), d, tint); + let tip = tip.to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &tip)); + resp.on_hover_text(tip) +} + /// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`. fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool { let t = crate::theme::Tokens::get(ui.ctx()); @@ -2305,6 +2356,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); ui.add_space(ROW_GAP); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (key, tip, d) in direction_items() { + let clicked = toggle_cell(ui, cw, para.direction == d, tip, |ui, r, col| { + let g = egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)); + direction_glyph(ui.painter(), g, d, col); + }); + if clicked { + apply(app, ui.ctx(), json!({"direction": key})); + } + } + }); + ui.add_space(ROW_GAP); row(ui, &mut |ui| { if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) { apply(app, ui.ctx(), json!({"startIndent": v})); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 7a23c9ccc3..869a1acc80 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1438,6 +1438,62 @@ mod multi_layer_formatting { }); } + /// The Paragraph section's direction buttons set the selected layers' paragraph direction in + /// one undo step, show the current one, and leave unselected layers and the tool's default for + /// new type alone (TYPE-218-2). + #[test] + fn paragraph_direction_buttons_set_and_show_the_direction() { + use egui::accesskit::Toggled; + use egui_kittest::kittest::NodeT; + use photocraft_doc::text::TextDirection; + let (app, ids) = fixture(); + let dir = |app: &PhotocraftApp, id: LayerId| text(app, id).paragraph_runs()[0].style.direction; + let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled(); + let mut h = controls(app, true); + assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::True), "new type follows its text"); + assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::False)); + let steps = h.state().session.active().unwrap().history.entries().len(); + h.get_by_label("Right-to-left paragraph direction").click(); + h.run(); + for id in &ids[..2] { + assert_eq!(dir(h.state(), *id), TextDirection::Rtl, "selected layers get the direction"); + } + assert_eq!(dir(h.state(), ids[2]), TextDirection::Auto, "an unselected layer is untouched"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one undo step"); + assert_eq!(h.state().ui.tool_options.type_direction, "auto", "the panel edits layers, not new type"); + h.run(); + assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::True), "the button shows it"); + assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::False)); + h.get_by_label("Left-to-right paragraph direction").click(); + h.run(); + assert_eq!(dir(h.state(), ids[0]), TextDirection::Ltr); + assert!(h.state_mut().session.undo()); + assert!(h.state_mut().session.undo()); + for id in &ids[..2] { + assert_eq!(dir(h.state(), *id), TextDirection::Auto, "undo restores it"); + } + } + + /// The options bar's direction buttons set the selected layers' direction and the one new + /// type starts with, as its alignment buttons do (TYPE-218-2). + #[test] + fn options_bar_direction_sets_selected_layers_and_new_type() { + use photocraft_doc::text::TextDirection; + let (app, ids) = fixture(); + let mut h = controls(app, false); + h.get_by_label("Right-to-left paragraph direction").click(); + h.run(); + assert_eq!(h.state().ui.tool_options.type_direction, "rtl"); + for id in &ids[..2] { + assert_eq!(text(h.state(), *id).paragraph_runs()[0].style.direction, TextDirection::Rtl); + } + assert_eq!(text(h.state(), ids[2]).paragraph_runs()[0].style.direction, TextDirection::Auto); + super::super::pointer_up(h.state_mut(), [40.0, 60.0], [40.0, 60.0]); + let id = h.state().session.active().unwrap().active_layer.unwrap(); + assert!(!ids.contains(&id), "a new type layer"); + assert_eq!(text(h.state(), id).paragraph_runs()[0].style.direction, TextDirection::Rtl, "new type starts right to left"); + } + #[test] fn options_font_family_preserves_peer_faces_and_batches_layer_flags() { let (app, ids) = fixture(); diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..46d1074157 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -78,13 +78,13 @@ what an agent can't finish alone. - **Estimate:** 80–140 h. ### G8. Type depth -- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script - fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus +- **Missing:** type on a path (TYPE-218-1), the visual RTL caret (TYPE-218-2; the paragraph + direction buttons landed 2026-10-16), complex-script fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type (TYPE-218-9), text free transform (#2630). Done since the re-measure: manual and optical kerning with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985). -- **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select +- **Evidence:** scorecard Type: 4 done, 4 partial, 1 missing. 2026-10-11: 2/3/4/5 clicks select word, line, paragraph and all text, with tests (TYPE-218-8, #3493). The Type tool froze the app for 60 s on one macOS machine (#2518). - **Estimate:** 120–200 h. @@ -270,6 +270,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar; TYPE-218-2 partial (the visual RTL caret is still missing) | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..26553161df 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -10,7 +10,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | -| [Type](#type) | 4 | 3 | 2 | | +| [Type](#type) | 4 | 4 | 1 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | @@ -214,7 +214,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| | TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | -| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | +| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order) | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | diff --git a/scorecard/type.toml b/scorecard/type.toml index ee8a876a41..9b1957cbf8 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -14,9 +14,9 @@ note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve [[item]] id = "TYPE-218-2" target = "Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret" -status = "missing" +status = "partial" issue = 218 -note = "Engine has TextDirection (text.rs:155); no UI" +note = "Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order)" [[item]] id = "TYPE-218-3" From d5535f35174d048861c9c9f21cfeb4b29e1b4142 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 03:08:00 +1100 Subject: [PATCH 0340/1452] Correct IFF chunky decoding and preserve bitmap transparency. --- crates/codecs/README.md | 13 ++-- crates/codecs/src/codecs/iff.rs | 113 +++++++++++++++++++++++------ crates/codecs/src/format.rs | 2 +- crates/codecs/tests/detect_caps.rs | 2 + 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 958d71d888..4cb0c0b4f2 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -48,7 +48,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | | Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | -| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB | no | no | no | no | no | no | n/a | built in | +| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -105,11 +105,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. -* **Amiga IFF (read-only).** `FORM ... ILBM` (interleaved) and `FORM ... PBM ` (planar) bitmaps - from the published IFF/ILBM layout: 1–8 bitplanes decoded through the `CMAP` palette (a grey - ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) - compression. Other bit-plane counts or compressions return `CodecError::Unsupported`; a - malformed chunk length or run errors rather than panicking. There is no IFF writer; save as PNG. +* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes; + `FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support + stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing + entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB + viewport modes, PBM mask planes, other bit depths and compressions return + `CodecError::Unsupported`. There is no IFF writer; save as PNG. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/iff.rs b/crates/codecs/src/codecs/iff.rs index 421d55c894..cae046f3b7 100644 --- a/crates/codecs/src/codecs/iff.rs +++ b/crates/codecs/src/codecs/iff.rs @@ -1,4 +1,4 @@ -//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the planar +//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky //! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte //! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data). //! Read-only: modern saves should use PNG. @@ -32,7 +32,7 @@ fn be16(b: &[u8], at: usize) -> Option { Some(u16::from_be_bytes(s)) } -/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` planar) at offset 8. +/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` chunky) at offset 8. pub(crate) fn looks_like_iff(b: &[u8]) -> bool { b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM ")) } @@ -44,8 +44,9 @@ struct Header { planes: u8, masking: u8, compression: u8, - /// `ILBM` interleaves a row's planes; `PBM ` stores a whole plane before the next. - interleaved: bool, + transparent: u16, + /// `PBM ` stores one palette index per byte, padded to even row lengths. + chunky: bool, } /// One four-byte chunk's id and byte range. @@ -73,7 +74,7 @@ fn chunks(b: &[u8]) -> impl Iterator> { }) } -fn parse_header(b: &[u8], interleaved: bool) -> Result { +fn parse_header(b: &[u8], chunky: bool) -> Result { let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?; let d = bmhd.data; if d.len() < 20 { @@ -96,7 +97,11 @@ fn parse_header(b: &[u8], interleaved: bool) -> Result { if compression > 1 { return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)"))); } - Ok(Header { width, height, planes, masking, compression, interleaved }) + if chunky && (planes != 8 || masking == 1) { + return Err(unsupported("PBM requires 8-bit indices without a mask plane")); + } + let transparent = be16(d, 12).ok_or_else(|| bad("BMHD transparent colour"))?; + Ok(Header { width, height, planes, masking, compression, transparent, chunky }) } /// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80` @@ -133,32 +138,43 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { if !looks_like_iff(b) { return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)")); } - let interleaved = b.get(8..12) == Some(b"ILBM"); - let h = parse_header(b, interleaved)?; - limits.check(h.width, h.height, ChannelLayout::Rgb, SampleType::U8)?; + let h = parse_header(b, b.get(8..12) == Some(b"PBM "))?; + if let Some(camg) = chunks(b).find(|c| &c.id == b"CAMG") { + let mode = be32(camg.data, 0).ok_or_else(|| bad("short CAMG chunk"))?; + if mode & (0x80 | 0x800) != 0 { + return Err(unsupported("EHB and HAM viewport modes")); + } + } + let layout = if h.masking == 0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }; + limits.check(h.width, h.height, layout, SampleType::U8)?; // Palette: three bytes per colour; missing entries read as black. - let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data); + let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data).filter(|p| !p.is_empty()); let colors = 1usize << h.planes; let palette = |idx: usize| -> [u8; 3] { - cmap.and_then(|p| p.get(idx * 3..idx * 3 + 3)).map(|c| [c[0], c[1], c[2]]).unwrap_or({ - // No palette: a grey ramp across the index range. + if let Some(p) = cmap { + p.get(idx * 3..idx * 3 + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([0; 3]) + } else { let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8; [v, v, v] - }) + } }; let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?; let (w, hh) = (h.width as usize, h.height as usize); // Each plane row is stored as a whole number of 16-bit words. - let plane_row = w.div_ceil(16) * 2; + let plane_row = if h.chunky { w.div_ceil(2) * 2 } else { w.div_ceil(16) * 2 }; // A masking type 1 bitmap carries one extra (mask) plane per row. - let rows_planes: usize = if h.masking == 1 { usize::from(h.planes) + 1 } else { usize::from(h.planes) }; + let rows_planes: usize = if h.chunky { 1 } else { usize::from(h.planes) + usize::from(h.masking == 1) }; // Decode every stored plane row up front, then recombine. let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; - let mut stored = vec![0u8; total_bytes]; + if total_bytes as u64 > limits.max_alloc { + return Err(CodecError::LimitExceeded("bitmap storage exceeds max_alloc".into())); + } + Limits::check_plausible(total_bytes as u64, body.len(), if h.compression == 0 { 1 } else { 128 })?; + let mut stored = crate::options::alloc_zeroed::(total_bytes)?; let mut cursor = 0usize; for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) { match h.compression { @@ -176,22 +192,24 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { } let mut out = Vec::new(); - out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(3)).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(layout.channels())).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; let planes = usize::from(h.planes); for y in 0..hh { for x in 0..w { - let index = if h.interleaved { - // Row-major, interleaved: row y, plane p sits at (y * rows_planes + p). - (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + let index = if h.chunky { + usize::from(*stored.get(y * plane_row + x).ok_or_else(|| bad("PBM pixel outside BODY"))?) } else { - // Planar: plane p's rows come first, then the next plane. - (0..planes).filter(|&p| bit_at(&stored, (p * hh + y) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) }; let c = palette(index); out.extend_from_slice(&c); + if h.masking != 0 { + let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) }; + out.push(if opaque { 255 } else { 0 }); + } } } - Image::from_u8(h.width, h.height, ChannelLayout::Rgb, out) + Image::from_u8(h.width, h.height, layout, out) } /// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first). @@ -228,6 +246,9 @@ mod tests { for c in palette { b.extend_from_slice(c); } + if palette.len() % 2 == 1 { + b.push(0); + } // BODY b.extend_from_slice(b"BODY"); b.extend_from_slice(&(body.len() as u32).to_be_bytes()); @@ -272,6 +293,52 @@ mod tests { assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]); } + #[test] + fn pbm_reads_chunky_indices_with_odd_width_padding() { + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90]]; + for (compression, body) in [(0, vec![2, 0, 1, 255, 1, 2, 0, 255]), (1, vec![3, 2, 0, 1, 255, 3, 1, 2, 0, 255])] { + let mut b = ilbm(3, 2, 8, &pal, compression, &body); + b[8..12].copy_from_slice(b"PBM "); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[70, 80, 90, 10, 20, 30, 40, 50, 60, 40, 50, 60, 70, 80, 90, 10, 20, 30]); + } + } + + #[test] + fn transparency_uses_the_mask_plane_or_transparent_index() { + for masking in [1, 2] { + let mut b = ilbm(2, 1, 1, &[[10, 20, 30], [40, 50, 60]], 0, if masking == 1 { &[0x40, 0, 0x40, 0] } else { &[0x40, 0] }); + b[29] = masking; + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.data(), &[10, 20, 30, 0, 40, 50, 60, 255]); + } + let mut b = ilbm(2, 1, 8, &[[10, 20, 30], [40, 50, 60]], 0, &[0, 1]); + b[8..12].copy_from_slice(b"PBM "); + b[29] = 2; + b[32..34].copy_from_slice(&1u16.to_be_bytes()); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[10, 20, 30, 255, 40, 50, 60, 0]); + } + + #[test] + fn rejects_ham_and_ehb_instead_of_returning_wrong_colours() { + for mode in [0x80u32, 0x800] { + let mut b = ilbm(1, 1, 1, &[], 0, &[0, 0]); + b.extend_from_slice(b"CAMG"); + b.extend_from_slice(&4u32.to_be_bytes()); + b.extend_from_slice(&mode.to_be_bytes()); + let len = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&len.to_be_bytes()); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } + } + + #[test] + fn missing_palette_entries_are_black() { + let b = ilbm(1, 1, 2, &[[10, 20, 30]], 0, &[0x80, 0, 0x80, 0]); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]); + } + #[test] fn hostile_input_errors_not_panics() { // Truncations of a valid file never panic. diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 6d89bf2414..1aa5b9e84f 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -274,7 +274,7 @@ pub fn caps(format: Format) -> FormatCaps { // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, - Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb], alpha: false, ..base }, + Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base }, } } diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index c0cd569e3a..87b2755cc4 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -206,6 +206,8 @@ fn detect_magic_iff() { assert_eq!(from_extension("art.LBM"), Some(Format::Iff)); assert_eq!(from_extension("art.ilbm"), Some(Format::Iff)); assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only"); + assert!(caps(Format::Iff).alpha); + assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba)); assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff)); // A bare FORM without a bitmap type is not IFF. assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None); From 87918a6c850b7a3f89f2b2bd84b1c648b0ed0517 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:13 +0000 Subject: [PATCH 0341/1452] Linked smart objects: follow stored paths only to local files or the document's share A linked smart object's path comes from the document. Every place that follows it now goes through one resolver (photocraft_io::doc_path, built on craft-docpath): a relative path is taken from the document's folder, and only local files or files on the share the document itself is on are opened. Paths to other computers, device paths and URLs are treated like a missing file: the layer keeps its last rendering. - Link watch: such links are not stamped, and no background look starts when no link of the document is followed. - Update Modified Content and other re-renders read only followed files; the error names why a link is not followed. - Package lists such links as missing instead of copying them. - Reveal in Finder for a linked layer shows only a followed file; the document's own Reveal is unchanged. The macOS invocation ends options with `--`, and Explorer is not asked to select a path containing a comma or a quotation mark. - Place Linked stores a relative path as an absolute one. The document path travels to smart-object renders in a thread-local scope entered by Session::edit, command dispatch and background jobs. Co-authored-by: David Mann --- Cargo.lock | 5 + Cargo.toml | 1 + crates/engine/src/doc_path.rs | 58 +++++++++ crates/engine/src/doc_path/tests.rs | 129 +++++++++++++++++++++ crates/engine/src/file_cmds.rs | 11 +- crates/engine/src/file_cmds/tests.rs | 2 +- crates/engine/src/jobs.rs | 7 ++ crates/engine/src/layer_menu_cmds.rs | 32 +++-- crates/engine/src/layer_menu_cmds/tests.rs | 39 ++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/link_watch.rs | 32 +++-- crates/engine/src/print_cmds.rs | 13 ++- crates/engine/src/smart_cmds.rs | 17 ++- crates/io/Cargo.toml | 1 + crates/io/src/doc_path.rs | 59 ++++++++++ crates/io/src/lib.rs | 1 + 16 files changed, 377 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/doc_path.rs create mode 100644 crates/engine/src/doc_path/tests.rs create mode 100644 crates/io/src/doc_path.rs diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..e1206c2277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,6 +1222,10 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-print" version = "0.1.0" @@ -4869,6 +4873,7 @@ name = "photocraft-io" version = "0.9.0" dependencies = [ "blake3", + "craft-docpath", "crc32fast", "flate2", "photocraft-affinity", diff --git a/Cargo.toml b/Cargo.toml index 0d0f93ae7c..f215b0d049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 0000000000..b51d164e74 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,58 @@ +//! Paths stored in a document reach the file system only through [`resolve`] (built on +//! [`photocraft_io::doc_path::resolve`]): linked smart objects, the Displace map and Color Lookup +//! file a smart filter re-reads, data-set images and video footage. A refused path is handled like +//! a missing file: the layer keeps its last rendering, and commands that need the file say why. +//! +//! Smart-object rendering has no `Session` argument, so the path of the document being worked on +//! travels in a thread-local scope, entered where commands run ([`crate::Session::edit`], command +//! dispatch, background jobs). Outside a scope there is no document: relative paths are refused +//! and only local absolute paths are followed. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +pub use photocraft_io::doc_path::NotFollowed; + +thread_local! { + static DOCUMENT: RefCell> = const { RefCell::new(None) }; +} + +/// Restores the previous document path when dropped. +pub(crate) struct DocumentScope(Option); + +impl Drop for DocumentScope { + fn drop(&mut self) { + let previous = self.0.take(); + DOCUMENT.with(|d| *d.borrow_mut() = previous); + } +} + +/// Resolve stored paths against `document` (the path of the document being worked on, `None` +/// for one without a file) until the returned scope is dropped. +pub(crate) fn scope(document: Option<&Path>) -> DocumentScope { + let next = document.filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf); + DocumentScope(DOCUMENT.with(|d| d.replace(next))) +} + +/// The document path of the current scope (to carry it to another thread). +pub(crate) fn current() -> Option { + DOCUMENT.with(|d| d.borrow().clone()) +} + +/// The file to open for `stored`, a path stored in the document of the current scope. +pub fn resolve(stored: &str) -> Result { + resolve_in(stored, current().as_deref()) +} + +/// The file to open for `stored`, a path stored in the document saved at `document`. +pub fn resolve_in(stored: &str, document: Option<&Path>) -> Result { + photocraft_io::doc_path::resolve(stored, document) +} + +/// The message for a stored path that is not followed. +pub(crate) fn refusal(what: &str, stored: &str, why: NotFollowed) -> crate::EngineError { + crate::EngineError::Other(format!("{what} {stored} is not opened: {why}")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs new file mode 100644 index 0000000000..6b8657911f --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,129 @@ +//! Each kind of place that follows a path stored in a document goes through [`super::resolve`]: a +//! link to another computer is never followed, and a relative link next to the document is. + +use std::path::{Path, PathBuf}; + +use photocraft_doc::{LayerContent, LayerId, SmartSource}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +/// A link to a share on a TEST-NET-1 address, which never answers. +const REMOTE: &str = r"\\192.0.2.1\share\x.png"; + +/// An empty folder of its own (tests run in parallel). +fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-doc-path-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A 32×32 PNG whose pixel at column `x` is `rgb(x)`. +fn png(path: &Path, rgb: impl Fn(u32) -> [u8; 3]) { + let mut px = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + px.extend(rgb(x)); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); +} + +/// On Unix `//` followed by an absolute path names that same local file, while the resolver sees a +/// path on another computer: a place that skipped the resolver would read the file. +#[cfg(unix)] +fn network_alias(p: &Path) -> String { + format!("/{}", p.display()) +} + +/// A 32×32 white document saved (as far as its links know) at `d/doc.pcraft`, with `file` placed +/// linked → (session, layer id). +fn document(d: &Path, file: &Path) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + s.execute("file.placeLinked", json!({"path": file.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + (s, id) +} + +/// Point smart object `id` at `path` without re-rendering it. +fn relink(s: &mut Session, id: LayerId, path: &str) { + s.edit("Relink", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.source = SmartSource::Linked { path: path.into() }; + } + Ok(()) + }) + .unwrap(); +} + +fn centre(s: &Session) -> [f32; 3] { + let p = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(16, 16, 17, 17)).px[0]; + [p[0], p[1], p[2]] +} + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; + +fn update(s: &mut Session) -> Result { + s.execute("layer.smartObjects.updateModifiedContent", json!({})).map_err(|e| e.to_string()) +} + +#[test] +fn linked_smart_objects_follow_only_local_files() { + let d = dir("links"); + png(&d.join("red.png"), |_| [255, 0, 0]); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let (mut s, id) = document(&d, &d.join("red.png")); + assert_eq!(centre(&s), RED); + + relink(&mut s, id, REMOTE); + s.start_link_scan(); + assert!(s.link_scan.is_none(), "no look at a file on another computer"); + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []})); + let err = update(&mut s).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED, "the layer keeps its rendering"); + #[cfg(unix)] + { + relink(&mut s, id, &network_alias(&d.join("blue.png"))); + assert!(update(&mut s).is_err()); + assert_eq!(centre(&s), RED, "the file was not read"); + } + + // A relative link is taken from the document's folder. + relink(&mut s, id, "blue.png"); + update(&mut s).unwrap(); + assert_eq!(centre(&s), BLUE); + s.start_link_scan(); + assert!(s.link_scan.is_some(), "a followed link is looked at"); +} + +#[test] +fn package_copies_only_followed_links() { + let d = dir("package"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + #[allow(unused_mut)] + let mut remote = vec![REMOTE.to_string()]; + #[cfg(unix)] + remote.push(network_alias(&d.join("red.png"))); + for path in &remote { + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, path); + } + let r = s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy()})).unwrap(); + let mut missing: Vec = serde_json::from_value(r["missing"].clone()).unwrap(); + missing.sort(); + remote.sort(); + assert_eq!(missing, remote); + let links = r["links"].as_array().unwrap(); + assert_eq!(links.len(), 1); + assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}"); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..d9eb4d10c0 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -578,7 +578,16 @@ fn place(s: &mut Session, p: &Value, linked: bool) -> Result { let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" }; let path = str_param(p, "path", cmd)?.to_string(); let bytes = read_file(&path)?; - place_bytes(s, &path, bytes, linked.then(|| path.clone()), p) + // The link is stored absolute: a relative one would later be taken from the document's + // folder (`doc_path`), not the working directory the path was given in. + let link = linked.then(|| { + if std::path::Path::new(&path).is_relative() { + std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned()) + } else { + path.clone() + } + }); + place_bytes(s, &path, bytes, link, p) } // ---------- File Info (XMP) ---------- diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..624edfdd3f 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1033,7 +1033,7 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { let path = "/tmp/pics/a.psd"; s.active_mut().unwrap().path = Some(path.into()); let r = s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); - let (program, args) = crate::layer_menu_cmds::reveal_command(path); + let (program, args) = crate::layer_menu_cmds::reveal_command(path).unwrap(); assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); // The tab menu names a background tab: the active document is not the one revealed. diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index de2afd1556..0e9569d71d 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -323,6 +323,9 @@ pub fn run( ) -> Result { let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); let _authorization = AuthorizationScope::enter(authorization); + // The job's document: stored paths it follows are resolved against it on any thread. + let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from); + let _document = crate::doc_path::scope(document.as_deref()); if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); @@ -337,8 +340,10 @@ pub fn run( }; let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); + let apply_document = document.clone(); let erased: Apply = Box::new(move |s, any| { let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(apply_document.as_deref()); let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; apply(s, *t) }); @@ -352,6 +357,7 @@ pub fn run( // no way to get it back, so the error is reported instead. let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || { let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(document.as_deref()); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged")))); // The receiver is gone when the job was cancelled: nothing to report. @@ -694,6 +700,7 @@ impl Session { gate(id, ¶ms)?; } let _authorization = AuthorizationScope::enter(self.authorize); + let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); // Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and // run as a job like that filter, so a recorded step replays the same filter anywhere // (#3089), as Transform Again records its transform (#1440). With no filter yet it falls diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..8e033b244e 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -373,14 +373,17 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { let SmartSource::Linked { path } = &sm.source else { return Err(other("embedded smart objects have no file to reveal")); }; - reveal(path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) + // The path is stored in the document: shown only when it would be followed. + let file = + crate::doc_path::resolve_in(path, d.path.as_deref().map(std::path::Path::new)).map_err(|why| crate::doc_path::refusal("the linked file", path, why))?; + reveal(&file.to_string_lossy(), p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) } /// Show `path` in the platform file manager ([`reveal_command`] + [`spawn`]); `dry_run` answers /// the program and arguments instead (tests). Shared by the smart-object command and the document /// tab's Reveal in Finder (UI-217-6). pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { - let (program, args) = reveal_command(path); + let (program, args) = reveal_command(path)?; if dry_run { return Ok(json!({"program": program, "args": args})); } @@ -389,14 +392,23 @@ pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { } /// The file-manager invocation that selects `path` on this platform. -pub fn reveal_command(path: &str) -> (&'static str, Vec) { - if cfg!(target_os = "macos") { - ("open", vec!["-R".into(), path.into()]) - } else if cfg!(target_os = "windows") { - ("explorer", vec![format!("/select,{path}")]) - } else { - let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); - ("xdg-open", vec![parent]) +pub fn reveal_command(path: &str) -> Result<(&'static str, Vec)> { + reveal_command_on(std::env::consts::OS, path) +} + +/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value). +fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec)> { + match os { + // `--` ends the options: a path starting with `-` is not read as one. + "macos" => Ok(("open", vec!["-R".into(), "--".into(), path.into()])), + // Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path + // would select something else. + "windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))), + "windows" => Ok(("explorer", vec![format!("/select,{path}")])), + _ => { + let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); + Ok(("xdg-open", vec![parent])) + } } } diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 46688500b1..72ace605cb 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -465,16 +465,13 @@ fn stack_mode_rejects_sources_without_visible_layers() { #[test] fn reveal_in_finder_dry_run_and_enabled() { + let link = std::env::temp_dir().join("pics").join("a.png").to_string_lossy().into_owned(); let mut s = session(8, "rgb"); assert!(!s.is_enabled("layer.smartObjects.revealInFinder")); s.edit("linked", |doc, active| { let mut l = Layer::new( "linked", - LayerContent::Smart(photocraft_doc::SmartObject::new( - SmartSource::Linked { path: "/tmp/pics/a.png".into() }, - photocraft_geom::Affine::IDENTITY, - None, - )), + LayerContent::Smart(photocraft_doc::SmartObject::new(SmartSource::Linked { path: link.clone() }, photocraft_geom::Affine::IDENTITY, None)), ); l.visible = true; *active = Some(doc.insert_above(*active, l)); @@ -483,11 +480,41 @@ fn reveal_in_finder_dry_run_and_enabled() { .unwrap(); assert!(s.is_enabled("layer.smartObjects.revealInFinder")); let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap(); - let (prog, args) = reveal_command("/tmp/pics/a.png"); + let (prog, args) = reveal_command(&link).unwrap(); assert_eq!(r["program"], json!(prog)); assert_eq!(r["args"], json!(args)); } +#[test] +fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { + let mut s = session(8, "rgb"); + let folder = std::env::temp_dir().join("pics"); + s.active_mut().unwrap().path = Some(folder.join("doc.pcraft").to_string_lossy().into_owned()); + let mut link = |path: &str| { + s.edit("linked", |doc, active| { + let so = photocraft_doc::SmartObject::new(SmartSource::Linked { path: path.into() }, photocraft_geom::Affine::IDENTITY, None); + *active = Some(doc.insert_above(*active, Layer::new("linked", LayerContent::Smart(so)))); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).map_err(|e| e.to_string()) + }; + let err = link(r"\\192.0.2.1\share\x.png").unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + // A relative link is shown next to the document. + let r = link("a.png").unwrap(); + assert_eq!(r["args"], json!(reveal_command(&folder.join("a.png").to_string_lossy()).unwrap().1)); + // The document's own file is shown wherever it is, a network share included. + s.active_mut().unwrap().path = Some(r"\\192.0.2.1\share\doc.psd".into()); + s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); + + assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()])); + assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()])); + for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] { + assert!(reveal_command_on("windows", path).is_err(), "{path}"); + } +} + #[test] fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8d6167c3d..c46db72b85 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ mod crop_fill; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; +pub mod doc_path; pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; @@ -782,6 +783,7 @@ impl Session { // Every mutation path (linked-file refresh, transform, place) re-renders smart objects // through this entry; the requesting session's authority must reach those renders. let _authorization = crate::jobs::render_authorization_scope(self.authorize); + let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); let restrict = self.color_restrict; let sampler_map = self.sampler_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 5c235fb919..1885812ce0 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -32,15 +32,17 @@ pub type Stamp = (u64, u128); /// ids, stamp)]). pub type LinkScan = Arc, Option)>)>>>; +/// Stamps `file`, a linked path resolved by [`crate::doc_path`]; `None` (not looked at, like a +/// missing file) when it is not followed. #[cfg(not(target_arch = "wasm32"))] -fn stamp(path: &str) -> Option { - let meta = std::fs::metadata(path).ok()?; +fn stamp(file: Option<&std::path::Path>) -> Option { + let meta = std::fs::metadata(file?).ok()?; let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; Some((meta.len(), modified.as_nanos())) } #[cfg(target_arch = "wasm32")] -fn stamp(_: &str) -> Option { +fn stamp(_: Option<&std::path::Path>) -> Option { None } @@ -61,11 +63,24 @@ pub fn linked_files(d: &Document) -> Vec<(String, Vec)> { /// the layers of the ones that changed. pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let (doc, files) = (st.doc.id.0, resolved_files(st)); + let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); s.apply_link_stamps(doc, now) } +/// [`linked_files`] of `st`'s document with the file each path resolves to, or `None` for a path +/// that is not followed ([`crate::doc_path`]). +fn resolved_files(st: &crate::DocState) -> Vec<(String, Option, Vec)> { + let document = st.path.as_deref().map(std::path::Path::new); + linked_files(&st.doc) + .into_iter() + .map(|(p, ids)| { + let file = crate::doc_path::resolve_in(&p, document).ok(); + (p, file, ids) + }) + .collect() +} + impl Session { /// Start stamping the active document's linked files on a worker thread, unless a look is /// still running (or the build has no files to look at: the web app). @@ -74,14 +89,15 @@ impl Session { return; } let Some(st) = self.active() else { return }; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - if files.is_empty() { + let (doc, files) = (st.doc.id.0, resolved_files(st)); + // Nothing to look at: no links, or none that is followed. + if files.iter().all(|(_, file, _)| file.is_none()) { return; } let slot: LinkScan = Arc::default(); let out = slot.clone(); let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || { - let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let stamps = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); *out.lock().unwrap_or_else(PoisonError::into_inner) = Some((doc, stamps)); }); if spawned.is_ok() { diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..de16a94ac4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -602,8 +602,12 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result { fn package(s: &mut Session, p: &Value) -> Result { let cmd = "file.package"; let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?; + // The copies are relinked by absolute path: a relative one would be resolved against the + // package document's folder, not the working directory `dir` is relative to. + let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned()); let d = s.active().ok_or(EngineError::NoDocument)?; let doc = (*d.doc).clone(); + let document = d.path.clone(); let name = stem(&d.path.clone().unwrap_or_else(|| doc.name.clone())); // The native format keeps external links; our PSD writer has no `lnkE` (external linked // file) support yet, so a PSD package would embed or flatten them. @@ -612,7 +616,7 @@ fn package(s: &mut Session, p: &Value) -> Result { Some(f) => return Err(bad(cmd, format!("unknown package format `{f}` (pcraft|psd|psb)"))), None => "pcraft".into(), }; - let folder = join(dir, &crate::file_cmds::sanitize(&name)); + let folder = join(&dir, &crate::file_cmds::sanitize(&name)); let links_dir = join(&folder, "Links"); // Copy every linked file once; relink the copy of the document to the copies. let mut copied: Vec<(String, String)> = Vec::new(); @@ -629,7 +633,12 @@ fn package(s: &mut Session, p: &Value) -> Result { fname = format!("{}-{}", copied.len(), fname); } let t = join(&links_dir, &fname); - match read_file(path) { + // A path that is not followed is packaged as missing, like a file that is gone. + let Ok(file) = crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)) else { + missing.push(path.clone()); + continue; + }; + match read_file(&file.to_string_lossy()) { Ok(bytes) => { write_file(&t, &bytes)?; copied.push((path.clone(), t.clone())); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..1f1cde7987 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -77,8 +77,13 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() } +/// A linked file's bytes; `None` when it is missing or its path is not followed +/// ([`crate::doc_path::resolve`]). fn read_file(path: &str) -> Option> { - if path.is_empty() { None } else { photocraft_format::read_file(std::path::Path::new(path)).ok() } + if path.is_empty() { + return None; + } + photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok() } /// The source file of a smart object: embedded bytes, the PSD's embedded linked-layer data (PSD @@ -492,10 +497,14 @@ fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> { fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> { if refresh_in(doc, id, ctx)? { - Ok(()) - } else { - Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) + return Ok(()); } + if let SmartSource::Linked { path } = &smart(doc, id)?.source + && let Err(why @ crate::doc_path::NotFollowed::Refused(_)) = crate::doc_path::resolve(path) + { + return Err(crate::doc_path::refusal("the linked file", path, why)); + } + Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) } fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 9bd4c559c5..eb8c0b00d7 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -31,6 +31,7 @@ photocraft-text = { workspace = true } photocraft-paint = { workspace = true } photocraft-vector = { workspace = true } photocraft-format = { path = "../format" } +craft-docpath = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs new file mode 100644 index 0000000000..37e7daf214 --- /dev/null +++ b/crates/io/src/doc_path.rs @@ -0,0 +1,59 @@ +//! Files named by paths stored in a document: linked smart objects, the Displace map and Color +//! Lookup file of a smart filter, data-set images, video footage. +//! +//! Such a path is followed only through [`resolve`]: a relative path is taken from the document's +//! folder, and only local files, or files on the network share the document itself is on, are +//! opened (see `craft_docpath`). A refused path is treated like a missing file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is not a local file, or is not on the document's own share. + Refused(Refused), + /// The path is relative and the document has no file to take it from. + NoDocumentFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoDocumentFolder => f.write_str("the path is relative and the document has not been saved"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to open for `stored`, a path or `file:` URL stored in a document, or why it is not +/// followed. `document` is the path of the document it is stored in, if the document has a file; +/// a relative `document` is taken from the working directory, where it was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result { + let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); + let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NoDocumentFolder); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder() { + let doc = std::env::temp_dir().join("art").join("poster.pcraft"); + assert_eq!(resolve("logo.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("logo.png"))); + assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network))); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 24b8b3f948..e3c6365d9a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -40,6 +40,7 @@ pub mod blocks; pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; +pub mod doc_path; pub mod effects_map; pub mod exr_parts; mod flat; From 348499a5e01ce58a56380f9d28dbf1d2f956a635 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:21 +0000 Subject: [PATCH 0342/1452] PSD export: embed linked files only when they are local or on the document's share Save As PSD/PSB, layered TIFF and Package as PSD read a linked smart object's file to embed it. The path is resolved with the same resolver as other linked-file reads, against the document's own file (the new ExportOptions::document_path / PsdExportOptions::document_path, set by the engine's saves and the app's Save As). A link that is not followed is written as pixels with a warning that says why, as for a missing file. Nested smart-object documents resolve against the same document. --- crates/engine/src/doc_path/tests.rs | 18 ++++++++++++++++++ crates/engine/src/file_cmds.rs | 2 ++ crates/io/src/lib.rs | 11 ++++++++++- crates/io/src/psd_export.rs | 19 +++++++++++++++---- crates/io/src/tiff_layers.rs | 9 +++++++-- crates/ui-egui/src/lib.rs | 6 ++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 6b8657911f..b28fe3fb4f 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -127,3 +127,21 @@ fn package_copies_only_followed_links() { assert_eq!(links.len(), 1); assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}"); } + +#[test] +fn psd_saves_embed_only_followed_links() { + let d = dir("psd"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, REMOTE); + let warnings = |r: serde_json::Value| r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect::>(); + let saved = warnings(s.execute("file.saveACopy", json!({"path": d.join("copy.psd").to_string_lossy()})).unwrap()); + assert!(saved.iter().any(|w| w.contains("red.png was embedded")), "{saved:?}"); + assert!(saved.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{saved:?}"); + // Package as PSD writes the copy of the document, whose remote link stays as it was. + let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap()); + assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}"); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index d9eb4d10c0..07ae78904b 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -340,6 +340,8 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, + // Linked files a PSD embeds are resolved against the document being worked on. + document_path: crate::doc_path::current(), ..Default::default() }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e3c6365d9a..3543cd359c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -188,6 +188,9 @@ pub struct ExportOptions { /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as /// Photoshop's Always. pub psd_maximize_compatibility: bool, + /// The path of the document's own file, which linked smart objects that PSD/PSB and layered + /// TIFF embed are resolved against ([`doc_path::resolve`]). + pub document_path: Option, } impl Default for ExportOptions { @@ -200,6 +203,7 @@ impl Default for ExportOptions { tga_bits: None, image_previews: true, psd_maximize_compatibility: true, + document_path: None, } } } @@ -412,7 +416,12 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; + let o = PsdExportOptions { + force_psb: opts.force_psb || ext == "psb", + maximize_compatibility: opts.psd_maximize_compatibility, + document_path: opts.document_path.clone(), + ..Default::default() + }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..64a5e7073a 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -41,11 +41,14 @@ pub struct PsdExportOptions { /// readers that only show the merged image see white. A document without layer records always /// gets the real image, since its pixels live nowhere else. pub maximize_compatibility: bool, + /// The path of the document's own file: linked smart objects are resolved against it + /// ([`crate::doc_path::resolve`]) before they are read and embedded. + pub document_path: Option, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } + PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None } } } @@ -53,6 +56,8 @@ struct Ex { fmt: PixelFormat, /// Document resolution (for generated type-layer data). dpi: f32, + /// The document's own file, which linked smart objects are resolved against. + document: Option, /// Character/paragraph styles written into every type layer's engine data. text_styles: photocraft_doc::TextStyles, mask_fmt: PixelFormat, @@ -929,7 +934,8 @@ impl Ex { SmartSource::Linked { path } => { #[cfg(not(target_arch = "wasm32"))] { - let bytes = std::fs::read(path).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; + let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?; + let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); self.embed(sm.contents_id, &name, &bytes) @@ -998,8 +1004,12 @@ impl Ex { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; - let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); + let (file, warnings) = document_to_psd_nested( + &doc, + &PsdExportOptions { force_psb: true, document_path: self.document.clone(), ..Default::default() }, + self.smart.depth + 1, + &Interrupt::NONE, + ); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1519,6 +1529,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let mut ex = Ex { fmt, dpi: doc.resolution_dpi, + document: opts.document_path.clone(), text_styles: doc.text_styles.clone(), text_index: Default::default(), mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false), diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3cd8de5b4c..628517b6f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -202,8 +202,13 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = - PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; + let psd_opts = PsdExportOptions { + force_psb: opts.force_psb || big, + merged_matte: false, + merged_compression: Compression::Raw, + maximize_compatibility: true, + document_path: opts.document_path.clone(), + }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..5004e8ccf3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -281,6 +281,9 @@ pub struct ExportSettings { /// Embed EXIF, resolution and other non-XMP metadata when the format supports it. Export As /// turns this off for Metadata: None; Save As leaves it on. pub embed_metadata: bool, + /// The path of the document's own file, which linked smart objects that PSD/PSB embed are + /// resolved against ([`photocraft_io::ExportOptions::document_path`]). + pub document_path: Option, } impl Default for ExportSettings { @@ -296,6 +299,7 @@ impl Default for ExportSettings { psd_maximize_compatibility: true, embed_icc: true, embed_metadata: true, + document_path: None, } } } @@ -319,6 +323,7 @@ impl ExportSettings { opts.tga_bits = self.tga_bits; opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts.document_path = self.document_path.clone(); opts } } @@ -1549,6 +1554,7 @@ impl PhotocraftApp { settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless); settings.webp_quality = e.webp_quality.map(|q| q.min(100)); + settings.document_path = st.path.as_deref().map(std::path::PathBuf::from); } settings } From 0a328189aaef8e11e5f36f76dbb54985ee763e90 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:28 +0000 Subject: [PATCH 0343/1452] Smart filters, data sets, video: re-read stored files only when local or on the document's share Paths these features keep in the document go through the same resolver as linked smart objects (relative to the document's folder; other computers, devices and URLs refused): - A Displace smart filter's `mapPath` and a Color Lookup smart filter's LUT `file`, re-read on every re-render: a path that is not followed leaves the pixels as they are, as for a missing map or LUT. - Apply Data Set's Pixel Replacement images. - Reload Frame of a video layer's footage. Apply Data Set and Reload Frame say why a path is not followed. --- crates/engine/src/adjust_cmds.rs | 12 +++++ crates/engine/src/doc_path/tests.rs | 80 +++++++++++++++++++++++++++++ crates/engine/src/filters_ext.rs | 6 ++- crates/engine/src/variables_cmds.rs | 4 +- crates/engine/src/video_cmds.rs | 4 +- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..b9bcd437aa 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -296,6 +296,18 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { /// Image › Adjustments run as a smart filter (`image.adjustments.` recorded on a smart /// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run. pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option { + // A Color Lookup filter re-reads its LUT `file`, a path stored in the document: followed only + // as `doc_path` allows, else the filter can't run (like a missing file). + let resolved; + let p = match p.get("file").and_then(Value::as_str).filter(|f| kind == "colorLookup" && !f.is_empty()) { + Some(file) => { + let mut q = p.clone(); + q["file"] = json!(crate::doc_path::resolve(file).ok()?.to_str()?); + resolved = q; + &resolved + } + None => p, + }; let mut out = surf.clone(); if kind == "shadowsHighlights" { let sh = shadows_highlights_params(p); diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index b28fe3fb4f..393e85a062 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -145,3 +145,83 @@ fn psd_saves_embed_only_followed_links() { let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap()); assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}"); } + +/// The pixels of the active document after its smart object `id` re-renders with its first smart +/// filter's params set to `params`. +fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32; 4]> { + s.edit("Params", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.smart_filters[0].params = params; + } + crate::smart_cmds::refresh(doc, id).map(|_| ()) + }) + .unwrap(); + let doc = &s.active().unwrap().doc; + photocraft_compose::render(doc, doc.bounds()).px +} + +#[test] +fn smart_filters_reread_only_followed_files() { + let d = dir("filters"); + png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]); + png(&d.join("map.png"), |_| [255, 255, 255]); + // An inverting look: corner (r, g, b) of the cube maps to (1-r, 1-g, 1-b), red fastest. + let corners = (0..8).map(|i| format!("{} {} {}\n", 1 - (i & 1), 1 - ((i >> 1) & 1), 1 - ((i >> 2) & 1))).collect::(); + std::fs::write(d.join("invert.cube"), format!("LUT_3D_SIZE 2\n{corners}")).unwrap(); + let path = |name: &str| d.join(name).to_string_lossy().into_owned(); + + // Displace re-reads its `mapPath`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path("map.png"), "horizontal": 50, "vertical": 0})).unwrap(); + let displaced = replayed(&mut s, id, json!({"mapPath": path("map.png"), "horizontal": 50, "vertical": 0})); + let unfiltered = replayed(&mut s, id, json!({"mapPath": REMOTE, "horizontal": 50, "vertical": 0})); + assert_ne!(displaced, unfiltered, "the map moves the pixels"); + assert_eq!(replayed(&mut s, id, json!({"mapPath": "map.png", "horizontal": 50, "vertical": 0})), displaced, "a relative map next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"mapPath": network_alias(&d.join("map.png")), "horizontal": 50, "vertical": 0})), unfiltered); + + // Color Lookup re-reads its LUT `file`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("image.adjustments.colorLookup", json!({"layer": id.0, "lut": "none"})).unwrap(); + let inverted = replayed(&mut s, id, json!({"file": path("invert.cube")})); + let unfiltered = replayed(&mut s, id, json!({"file": REMOTE})); + assert_ne!(inverted, unfiltered, "the look changes the pixels"); + assert_eq!(replayed(&mut s, id, json!({"file": "invert.cube"})), inverted, "a relative look next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered); +} + +#[test] +fn data_sets_and_footage_read_only_followed_files() { + let d = dir("datasets"); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "#ff0000"})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "img", "layer": layer.0, "type": "pixelReplacement", "method": "conform"}]})).unwrap(); + let set = |name: &str, path: &str| json!({"name": name, "values": [{"variable": "img", "kind": "pixels", "value": path}]}); + s.execute("image.variables.dataSets", json!({"dataSets": [set("remote", REMOTE), set("relative", "blue.png")]})).unwrap(); + let err = s.execute("image.applyDataSet", json!({"name": "remote"})).unwrap_err().to_string(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED); + s.execute("image.applyDataSet", json!({"name": "relative"})).unwrap(); + assert_eq!(centre(&s), BLUE); + + // Reload Frame re-reads the video layer's footage. + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": d.join("blue.png").to_string_lossy()})).unwrap(); + let footage = |s: &mut Session, path: &str| { + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("Footage", |doc, _| { + if let Some(v) = doc.layer_mut(id).and_then(|l| l.video.as_mut()) { + v.source = photocraft_doc::VideoSource::File { path: path.into() }; + } + Ok(()) + }) + .unwrap(); + s.execute("layer.videoLayers.reloadFrame", json!({})).map_err(|e| e.to_string()) + }; + let err = footage(&mut s, REMOTE).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + footage(&mut s, "blue.png").unwrap(); +} diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index dae5e9b98b..e4f6523a19 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -466,13 +466,15 @@ fn map_from_path(path: &str, layer: Option) -> Result> /// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not /// the map, so their re-render reads the map again here; without it the filter passed the pixels -/// through. `mapDocument` needs the session and is left unresolved. +/// through. The path is stored in the document, so it is followed only as +/// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved. pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { if let FilterParams::Displace { map, .. } = fp && map.is_none() && let Some(path) = map_path(p) { - *map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the displacement map", path, why))?; + *map = map_from_path(&file.to_string_lossy(), p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); } Ok(()) } diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index f42b744844..6308269703 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -199,7 +199,9 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM return Ok(()); } let fmt = doc.pixel_format(); - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + // The path is stored in the document's data sets. + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the image", path, why))?; + let bytes = std::fs::read(&file).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; let src = crate::file_cmds::import(path, &bytes)?; let img = crate::file_cmds::flattened(&src, fmt); let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64); diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 6fb9ab4574..820745fd38 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -365,7 +365,9 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { let photocraft_doc::VideoSource::File { path } = src else { return Err(EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "this layer has no file source to reload".into() }); }; - let frames = load_frames(&path, fmt)?.frames; + // The footage path is stored in the document. + let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; + let frames = load_frames(&file.to_string_lossy(), fmt)?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; From d21683cae3752c89c14942b90599a38e496ebb99 Mon Sep 17 00:00:00 2001 From: Davos <58948657+Elements2720@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:01:28 +0300 Subject: [PATCH 0344/1452] fix(ui): disable Batch without a recorded action --- crates/ui-egui/src/view_cmds.rs | 3 +++ crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..b677c5e2fa 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -476,6 +476,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")) } }, + // Batch's dialog is built from an Actions-panel action. Keep it disabled until one + // exists instead of opening a menu item that can only report "record an action". + "file.automate.batch" => crate::actions::selected_action(app).is_some(), i if wraps(i) => app.session.is_enabled(i), _ => true, }) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..a8a0f44f4f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -207,6 +207,20 @@ fn type_menu_checkmarks_follow_the_layer() { assert_eq!(checked(&app, "type.orientation.horizontal"), Some(true)); } +#[test] +fn batch_menu_requires_a_recorded_action() { + let (mut app, ctx) = app_with(1); + assert!(!crate::menus::is_enabled(&app, "file.automate.batch")); + + app.run("actions.record", json!({"name": "Invert"})).unwrap(); + app.run("image.adjustments.invert", json!({})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + + assert!(crate::menus::is_enabled(&app, "file.automate.batch")); + let dialog = menu(&mut app, &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["action"], "Invert"); +} + #[test] fn engine_commands_open_their_dialogs() { let (mut app, ctx) = app_with(2); From 86ee9c9c7c65a4b9ef177260e754764cc15656a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 19:03:01 +0200 Subject: [PATCH 0345/1452] Paths: the path selected in the Paths panel is drawn on the canvas (#6550) Selecting a saved path in the Paths panel drew nothing: the canvas overlay only drew the work path, the active shape layer's path and a targeted vector mask. The selected saved path is Photoshop's target path, so it is now drawn with any tool (anchor squares with Path Selection, and with Direct Selection's overlay), still hidden by View > Show > Target Path and Extras. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/vector_ui.rs | 56 ++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..9878e10b8e 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -978,7 +978,15 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a painter.circle_filled(handle, 3.0, accent); } -/// Work path / active shape path outlines, anchors, and the pen path in progress. +/// The saved path selected in the Paths panel: Photoshop's target path, drawn on the canvas with +/// any tool (#6550). `None` when the panel selects the work path, a layer's path or nothing. +pub(crate) fn selected_saved_path(app: &PhotocraftApp) -> Option<&Path> { + let sel = app.ui.selected_path.as_deref().filter(|s| !matches!(*s, "work" | "layer"))?; + app.session.active()?.doc.paths.iter().find(|p| p.name == sel).map(|p| &p.path) +} + +/// Work path / active shape path / selected saved path outlines, anchors, and the pen path in +/// progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; let accent = Tokens::get(painter.ctx()).accent; @@ -998,6 +1006,10 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790). crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths); if paths { + // The selected saved path shows too, though Direct Selection doesn't edit it yet. + if let Some(p) = selected_saved_path(app) { + draw_path(p, true); + } crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { @@ -1006,6 +1018,10 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(wp) = &doc.work_path { draw_path(wp, tool == Tool::PathSelection); } + // The saved path selected in the Paths panel is the target path (#6550). + if let Some(p) = selected_saved_path(app) { + draw_path(p, tool == Tool::PathSelection); + } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content @@ -2072,6 +2088,44 @@ mod tests { assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn"); } + #[test] + fn the_saved_path_selected_in_the_paths_panel_is_drawn() { + // #6550: selecting a saved path in the Paths panel drew nothing on the canvas. + let ctx = egui::Context::default(); + let mut app = app(); + let tri = |o: f64| { + json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [20.0 + o, 20], "in": [20.0 + o, 20], "out": [20.0 + o, 20]}, + {"anchor": [90.0 + o, 20], "in": [90.0 + o, 20], "out": [90.0 + o, 20]}, + {"anchor": [90.0 + o, 90], "in": [90.0 + o, 90], "out": [90.0 + o, 90]} + ]}]}) + }; + app.run("path.set", json!({"name": "Path 1", "path": tri(0.0)})).unwrap(); + app.run("path.set", json!({"name": "Path 2", "path": tri(10.0)})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + for tool in [Tool::Move, Tool::Brush, Tool::Pen, Tool::PathSelection, Tool::DirectSelection] { + app.ui.tool = tool; + app.ui.selected_path = None; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: nothing selected, nothing drawn"); + app.ui.selected_path = Some("Path 1".into()); + assert_eq!(selected_saved_path(&app).map(|p| p.subpaths[0].knots[0].anchor.x), Some(20.0)); + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected saved path is drawn"); + app.ui.selected_path = Some("Path 2".into()); + assert_eq!(selected_saved_path(&app).map(|p| p.subpaths[0].knots[0].anchor.x), Some(30.0)); + app.ui.selected_path = Some("Gone".into()); + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: a missing path draws nothing"); + app.ui.selected_path = Some("Path 1".into()); + app.ui.view.show.target_path = false; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: View > Show > Target Path off"); + app.ui.view.show.target_path = true; + } + // Path Selection adds its anchor squares (the outline plus three anchors, two shapes each). + app.ui.tool = Tool::Move; + let plain = overlay_shapes(&app, &ctx); + app.ui.tool = Tool::PathSelection; + assert!(overlay_shapes(&app, &ctx) > plain); + } + #[test] fn shape_drag_creates_layers_with_modifiers() { let mut app = app(); From 65dc117b4b83704992d71743f6f72c5414c1a98c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 19:03:03 +0200 Subject: [PATCH 0346/1452] Shortcuts: a command shortcut leaves the focused menu bar and runs (#6573) After an Alt tap or F10 the in-window menu bar owns the keyboard, and shortcuts::handle returned before dispatching anything, so Ctrl+D, F7 and the other command shortcuts did nothing until Esc left the bar. Since the shortcuts run before the menu bar in a frame, the bar can't let go of the key itself in time. menu_keys::leave_for_shortcut takes the keyboard off the bar when a Cmd/Ctrl combo or a function key other than F10 is pressed while the bar is focused with no menu open; shortcuts::handle calls it before its early return, so the shortcut runs in the same press. Arrows, Enter, Esc, letters, Alt+letter and F10 keep driving the bar, and an open menu still swallows shortcuts. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/menu_keys.rs | 49 +++++++++++++++++++++++++++ crates/ui-egui/src/menu_keys_tests.rs | 26 ++++++++++++++ crates/ui-egui/src/shortcuts.rs | 4 ++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..2767db8280 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -61,6 +61,40 @@ pub fn is_focused(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(focus_id())).is_some_and(|seen| seen + 2 >= pass) } +/// A command shortcut pressed while the keyboard is on the bar with no menu open (⌘D, ⌘⇧I, F7…) +/// takes it off the bar, so the shortcut runs in the same press, as a native menu bar does +/// (#6573). Keys the bar uses (arrows, ↩, Esc, letters, Alt+letter, F10) keep driving it, and +/// an open menu still swallows shortcuts. True when the bar was left. +pub fn leave_for_shortcut(ctx: &egui::Context) -> bool { + if !is_focused(ctx) || crate::menu_nav::is_open(ctx) { + return false; + } + let shortcut = ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if is_command_key(*key, *modifiers)))); + if !shortcut { + return false; + } + let mut keys = Keys::load(ctx); + keys.focus = None; + keys.underline = false; + keys.store(ctx); + ctx.request_repaint(); + true +} + +/// A key press that is a command shortcut rather than a key of the bar: ⌘ / Ctrl with a key +/// (not the modifier keys themselves), or a function key other than F10 (which toggles the bar). +fn is_command_key(key: Key, mods: Modifiers) -> bool { + let modifier_key = matches!( + key, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ); + if modifier_key { + return false; + } + let function_key = key != Key::F10 && key.name().strip_prefix('F').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + mods.command || mods.ctrl || mods.mac_cmd || function_key +} + /// The mnemonic a key press stands for: the letter or digit of a plain key. fn key_char(key: Key) -> Option { let name = key.name(); @@ -296,6 +330,21 @@ mod tests { assert_eq!(key_char(Key::ArrowDown), None); assert_eq!(key_char(Key::Space), None); } + + #[test] + fn command_keys_are_command_combos_and_function_keys_but_f10() { + assert!(is_command_key(Key::D, Modifiers::COMMAND)); + assert!(is_command_key(Key::I, Modifiers::COMMAND | Modifiers::SHIFT)); + assert!(is_command_key(Key::F7, Modifiers::NONE)); + assert!(is_command_key(Key::F12, Modifiers::SHIFT)); + assert!(!is_command_key(Key::F10, Modifiers::NONE), "F10 toggles the bar"); + assert!(!is_command_key(Key::ControlLeft, Modifiers::CTRL), "Ctrl going down is no shortcut yet"); + assert!(!is_command_key(Key::E, Modifiers::NONE), "a letter is a mnemonic"); + assert!(!is_command_key(Key::E, Modifiers::ALT), "Alt+letter opens a menu"); + assert!(!is_command_key(Key::ArrowRight, Modifiers::NONE)); + assert!(!is_command_key(Key::Enter, Modifiers::NONE)); + assert!(!is_command_key(Key::Escape, Modifiers::NONE)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 1f226054e0..6b5aaa8363 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -210,3 +210,29 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { h.run_steps(3); assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); } + +/// The whole app: with the bar focused by an Alt tap, a command shortcut leaves the bar and runs +/// in the same press (#6573); the bar's own keys still drive it. +#[test] +fn a_command_shortcut_leaves_the_bar_and_runs() { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = app(); + app.services.native_menu = None; + app + }); + h.run_steps(4); + h.state_mut().run("select.all", json!({})).unwrap(); + h.run_steps(2); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File"); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ still moves along the bar"); + assert!(h.state().session.active().unwrap().doc.selection.is_some()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::D); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "⌘D deselects"); + assert_eq!(Keys::current(&h.ctx).focus, None, "and takes the keyboard off the bar"); + assert!(!crate::menu_keys::is_focused(&h.ctx)); + assert!(!is_open(&h.ctx), "no menu opened"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e8645a1511..ccb48561c7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -343,7 +343,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar // once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters - // pick an option rather than a tool (#1481). + // pick an option rather than a tool (#1481). A command shortcut (⌘D, F7…) takes the keyboard + // off the bar first and runs, as with a native menu bar (#6573). + crate::menu_keys::leave_for_shortcut(ctx); if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return; } From b4e0d2491dfe4e3493bb31e2e6123948de85fb48 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 19:24:17 +0200 Subject: [PATCH 0347/1452] macOS: Beep When Done plays the system alert sound (NSBeep) instead of a terminal bell (#6537) --- apps/photocraft/Cargo.toml | 5 +++-- apps/photocraft/src/services.rs | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5af663471..cb8fe0ef54 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -50,11 +50,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" # Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the +# system alert sound for Beep When Done (services.rs, NSBeep). Already in # Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..e0ab1c527f 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -574,10 +574,14 @@ fn platform_beep() { { let _ = winsafe::MessageBeep(winsafe::co::MBP::OK); } - #[cfg(not(target_os = "windows"))] + // The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal + // bell below is silent for an app started from the Finder or the Dock. + #[cfg(target_os = "macos")] + objc2_app_kit::NSBeep(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - // No beep API among this app's other platforms' dependencies: write the terminal bell - // (audible when launched from a terminal, which is the same cue macOS terminals use). + // No beep API among the other platforms' dependencies: write the terminal bell (audible + // when launched from a terminal). use std::io::Write as _; let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush()); } From e88268d7dd0e8a11e3523b1d08edf882983fba43 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 19:42:04 +0200 Subject: [PATCH 0348/1452] Paths: Delete Path on a shape layer's path row deletes the layer (#6552) The right-click menu of a shape layer's " Shape Path" row in the Paths panel had no Delete Path, while Photoshop offers it enabled. Measured in Photoshop: with a rectangle shape layer's shape path targeted in the Paths panel, Delete Path removes the whole shape layer as one "Delete Layer" history step (another shape layer below is untouched); it does not leave a full-canvas fill behind. The row now offers Delete Path, dispatching the existing layer.delete command for that row's layer (passed explicitly, so a multi-layer selection is not deleted with it). Saved and work path rows keep path.delete and a vector mask row keeps Delete Vector Mask. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/vector_ui.rs | 42 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..4190cbf6d8 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1697,8 +1697,9 @@ pub fn paths_footer(ctx: &egui::Context) -> Option { } /// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a -/// right-click acts on that row even when another path is selected in the panel. -fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, &'static str, Value)> { +/// right-click acts on that row even when another path is selected in the panel. `layer` is the +/// layer a `PathRow::Layer` row belongs to (the active layer). +fn path_context_actions(entry: &PathEntry, doc: &Document, layer: Option) -> Vec<(&'static str, &'static str, Value)> { let key = match entry.kind { PathRow::Work => "work".to_string(), PathRow::Layer => "layer".to_string(), @@ -1733,6 +1734,9 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, actions.push(("Delete Path", "path.delete", json!({"name": key}))); } else if entry.name.ends_with(" Vector Mask") { actions.push(("Delete Vector Mask", "layer.vectorMask.delete", json!({}))); + } else if let Some(id) = layer { + // A shape layer's path is the shape: Photoshop's Delete Path deletes the layer (#6552). + actions.push(("Delete Path", "layer.delete", json!({"layer": id.0}))); } actions } @@ -1744,7 +1748,8 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let doc = st.doc.clone(); - let rows = path_rows(&doc, st.active_layer); + let st_layer = st.active_layer; + let rows = path_rows(&doc, st_layer); let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; let mut stroke_dialog: Option = None; @@ -1807,7 +1812,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.set_min_width(190.0); let entry = PathEntry { name: name.clone(), path: path.clone(), kind: *kind }; let can_paint = app.session.active().and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))).is_some_and(|l| l.surface().is_some()); - for (label, cmd, params) in path_context_actions(&entry, &doc) { + for (label, cmd, params) in path_context_actions(&entry, &doc, st_layer) { let enabled = !matches!(cmd, "path.fill" | "path.stroke") || can_paint; if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() { if cmd == "path.stroke" { @@ -1992,7 +1997,7 @@ mod tests { app.run("path.set", json!({"name": "Second", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap(); - let entries = path_context_actions(&first, doc); + let entries = path_context_actions(&first, doc, None); assert_eq!(entries[0].1, "path.toSelection"); assert_eq!(entries[0].2["name"], "First"); assert!(entries.iter().all(|(_, id, _)| photocraft_engine::commands::find(id).is_some())); @@ -2004,7 +2009,7 @@ mod tests { app.run("path.set", json!({"name": "First copy 1", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap(); - let (_, _, duplicate) = path_context_actions(&first, doc).into_iter().find(|(_, id, _)| *id == "path.set").unwrap(); + let (_, _, duplicate) = path_context_actions(&first, doc, None).into_iter().find(|(_, id, _)| *id == "path.set").unwrap(); assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path"); } @@ -2015,14 +2020,35 @@ mod tests { app.run("path.set", json!({"name": "work", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let work = path_rows(doc, None).into_iter().find(|r| r.kind == PathRow::Work).unwrap(); - let entries = path_context_actions(&work, doc); + let entries = path_context_actions(&work, doc, None); assert!(entries.iter().any(|(_, id, _)| *id == "path.rename")); assert!(!entries.iter().any(|(_, id, _)| *id == "path.set")); let layer = PathEntry { kind: PathRow::Layer, ..work }; - let entries = path_context_actions(&layer, doc); + let entries = path_context_actions(&layer, doc, None); assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete")); } + /// #6552: a shape layer's " Shape Path" row offers Delete Path, and it deletes the + /// shape layer, as Photoshop's Delete Path does (measured: one "Delete Layer" step). + #[test] + fn shape_path_row_delete_path_deletes_the_shape_layer() { + let mut app = app(); + app.run("shape.create", json!({"kind": "ellipse", "rect": [20, 20, 100, 70]})).unwrap(); + let st = app.session.active().unwrap(); + let (doc, shape) = (st.doc.clone(), st.active_layer); + let layers = doc.layers.len(); + let row = path_rows(&doc, shape).into_iter().find(|r| r.kind == PathRow::Layer).unwrap(); + assert!(row.name.ends_with(" Shape Path"), "{}", row.name); + let (_, id, params) = + path_context_actions(&row, &doc, shape).into_iter().find(|(label, _, _)| *label == "Delete Path").expect("Delete Path is offered"); + app.run(id, params).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), layers - 1); + assert!(shape.and_then(|id| doc.layer(id)).is_none(), "the shape layer is gone"); + app.run("edit.undo", json!({})).unwrap(); + assert!(shape.and_then(|id| app.session.active().unwrap().doc.layer(id)).is_some(), "one undo step brings it back"); + } + /// How many shapes the path overlay paints for `app` this frame. fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { let doc = app.session.active().unwrap().doc.clone(); From ed4e94a4793488eaf71f79785a18f52e9c20034a Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 19:52:26 +0200 Subject: [PATCH 0349/1452] PSD: open an adjustment layer of an unknown type as an unsupported adjustment (#6575) An adjustment layer whose block no mapper knows (Photoshop 4's `hue `, or a type newer than PhotoCraft) opened as an empty pixel layer with no notice. Its block did survive in psd_blocks, but the layer showed as a paintable empty pixel layer and saved without the "pixel data irrelevant" flag. Photoshop writes every adjustment layer without pixels and with "pixel data irrelevant" set (every adjustment layer in the corpus; pixel, type and smart layers never have it). A record like that which is not a group, matches no known content kind, and carries exactly one block that is neither a known layer-information key nor a known adjustment now imports as Adjustment::Unsupported with that block, plus the usual "could not be read" warning naming the key. With two or more candidates nothing is guessed and the layer stays a pixel layer as before. The rule only fires for layers without pixels, so no pixels can be lost, and the block is written back verbatim either way. Export keeps an unknown-key Unsupported block in place while it is unchanged; before, it was written a second time beside the preserved copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/psd_export.rs | 10 +- crates/io/src/psd_import.rs | 36 ++++++++ crates/io/tests/psd_unknown_adjustment.rs | 106 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 crates/io/tests/psd_unknown_adjustment.rs diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..d13c176339 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -599,8 +599,16 @@ impl Ex { let keep = keep || (matches!(a, photocraft_doc::Adjustment::GradientMap { .. }) && raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels))); + // An adjustment type no mapper knows (#6575) is its block: kept verbatim, and + // never written a second time beside a stale copy. + let own = match a { + photocraft_doc::Adjustment::Unsupported { psd_key, .. } => Some(psd_key.as_bytes()), + _ => None, + }; + let keep = + keep || matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d == r)); if !keep { - raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); + raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && own != Some(&k[..])); let w = adjust_map::write(a); if w.is_empty() { self.warnings.push(format!("layer \"{}\": {} adjustment is not yet written to PSD", l.name, a.label())); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 12307f32ba..cdafe04cdc 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -465,6 +465,15 @@ impl Ctx<'_> { LayerContent::Raster(self.record_surface(rec, &name)) } } + } else if let Some(k) = unknown_adjustment_key(rec) { + // An adjustment type no mapper knows: kept verbatim like an unreadable one, not + // opened as an empty pixel layer without a word (#6575). + let key = String::from_utf8_lossy(&k).into_owned(); + self.warn(format!( + "layer \"{name}\": its adjustment settings could not be read (unknown adjustment type \"{key}\"); the layer is kept as saved but has no effect" + )); + let raw = rec.block(&k).map(|b| b.data.clone()).unwrap_or_default(); + LayerContent::Adjustment(photocraft_doc::Adjustment::Unsupported { psd_key: key, raw }) } else { LayerContent::Raster(self.record_surface(rec, &name)) }; @@ -595,6 +604,33 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize { depth } +/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in +/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects, +/// masks, metadata, and the fill, type, placed and shape content read before. +const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [ + b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp", + b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd", + b"FMsk", b"PxSc", b"PxSD", b"cinf", b"ffxi", b"shpa", b"anFX", b"SoCo", b"GdFl", b"PtFl", b"TySh", b"tySh", b"SoLd", b"SoLE", b"PlLd", b"plLd", +]; + +/// The key of an adjustment layer whose type no mapper knows (#6575), e.g. Photoshop 4's +/// `hue ` or a type newer than PhotoCraft. Photoshop writes every adjustment layer without +/// pixels and with "pixel data irrelevant" set (every one in the corpus; pixel, type and smart +/// layers never have it). Such a record that is not a group and carries exactly one block that +/// is neither a known layer-information key nor a known adjustment is that adjustment. More than +/// one candidate is a guess, so the layer stays an (empty) pixel layer, as before; either way no +/// pixels can be lost and the block is saved back verbatim. +fn unknown_adjustment_key(rec: &LayerRecord) -> Option<[u8; 4]> { + let marked = rec.flags.bit4_useful() && rec.flags.pixel_data_irrelevant(); + if !marked || !rec.rect.is_empty() || rec.block(b"lsct").is_some() || rec.block(b"lsdk").is_some() { + return None; + } + let mut unknown = rec.blocks.iter().filter(|b| !LAYER_INFO_KEYS.contains(&&b.key) && !ADJUSTMENT_KEYS.contains(&&b.key)); + let only = unknown.next()?; + // A key that isn't four printable ASCII bytes names no adjustment (nor saves back as one). + (unknown.next().is_none() && only.key.iter().all(|c| c.is_ascii_graphic() || *c == b' ')).then_some(only.key) +} + fn preserved_blocks(rec: &LayerRecord) -> Vec<([u8; 4], Arc>)> { rec.blocks.iter().filter(|b| !REGENERATED.contains(&&b.key)).map(|b| (b.key, Arc::new(b.data.clone()))).collect() } diff --git a/crates/io/tests/psd_unknown_adjustment.rs b/crates/io/tests/psd_unknown_adjustment.rs new file mode 100644 index 0000000000..5a7981c5c8 --- /dev/null +++ b/crates/io/tests/psd_unknown_adjustment.rs @@ -0,0 +1,106 @@ +//! An adjustment layer whose key PhotoCraft doesn't know (an older or newer adjustment type) +//! opens as an unsupported adjustment with a warning and saves its block back unchanged, instead +//! of opening silently as an empty pixel layer (#6575). Pixel layers that merely carry an unknown +//! block stay pixel layers. + +use photocraft_doc::{Adjustment, Document, LayerContent}; +use photocraft_io::*; +use photocraft_psd::testgen; +use photocraft_psd::{ChannelData, Compression, LayerFlags, LayerRecord, PsdFile, Rect, TaggedBlock, Version}; + +/// A layer record as Photoshop writes an adjustment layer: no pixels, "pixel data irrelevant" +/// set, the adjustment block first, then the usual metadata. +fn adjustment_record(name: &str, blocks: Vec) -> LayerRecord { + let mut flags = LayerFlags::default(); + flags.set_pixel_data_irrelevant(true); + let mut all = blocks; + all.extend([TaggedBlock::unicode_name(name), TaggedBlock::layer_id(77)]); + LayerRecord { name: name.as_bytes().to_vec(), flags, blocks: all, ..Default::default() } +} + +fn open(f: &PsdFile) -> (Document, Vec) { + let bytes = f.to_bytes().expect("write psd"); + let imp = import("x.psd", &bytes).expect("import"); + (imp.document, imp.warnings) +} + +fn save(d: &Document) -> PsdFile { + let r = export(d, "x.psd", &ExportOptions::default()).expect("export"); + PsdFile::from_bytes(&r.bytes).expect("reparse") +} + +fn content<'a>(d: &'a Document, name: &str) -> &'a LayerContent { + &d.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("layer {name}")).content +} + +fn record<'a>(f: &'a PsdFile, name: &str) -> &'a LayerRecord { + f.layers().iter().find(|r| r.name() == name).unwrap_or_else(|| panic!("record {name}")) +} + +#[test] +fn unknown_adjustment_key_opens_as_unsupported_and_saves_verbatim() { + // `hue ` is the Photoshop 4 Hue/Saturation block (in the spec, never mapped); `zNew` stands + // for an adjustment type newer than PhotoCraft. + for key in [*b"hue ", *b"zNew"] { + let data = vec![0, 2, 1, 2, 3, 4, 5, 6]; + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut().push(adjustment_record("Mystery", vec![TaggedBlock::new(key, data.clone())])); + let (d, warnings) = open(&f); + let k = String::from_utf8_lossy(&key).into_owned(); + assert!( + matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && *raw == data), + "{k}: {:?}", + content(&d, "Mystery") + ); + let w: Vec<&String> = warnings.iter().filter(|w| w.contains("Mystery")).collect(); + assert_eq!(w.len(), 1, "{k}: {warnings:?}"); + assert!(w[0].contains(&format!("\"{k}\"")) && w[0].contains("no effect"), "{k}: {}", w[0]); + + // Saved back: the block exactly once, byte-identical, still an adjustment record. + let g = save(&d); + let r = record(&g, "Mystery"); + let same: Vec<&TaggedBlock> = r.blocks.iter().filter(|b| b.key == key).collect(); + assert_eq!(same.len(), 1, "{k}: {:?}", r.blocks.iter().map(TaggedBlock::key_str).collect::>()); + assert_eq!(same[0].data, data, "{k}"); + assert!(r.flags.bit4_useful() && r.flags.pixel_data_irrelevant(), "{k}"); + assert!(r.rect.is_empty(), "{k}"); + // And it reopens the same way. + let (d2, _) = open(&g); + assert_eq!(content(&d2, "Mystery"), content(&d, "Mystery"), "{k}"); + } +} + +#[test] +fn pixel_layers_with_unknown_blocks_stay_pixel_layers() { + let extra = || TaggedBlock::new(*b"zNew", vec![9, 8, 7, 6]); + let mut f = testgen::small(Version::Psd, Compression::Raw); + // A normal pixel layer (pixels, flag clear) carrying an unknown block. + f.layers_mut()[0].blocks.push(extra()); + // Flagged but with pixels: never dropped for an adjustment. + let plane = |id: i16| ChannelData { id, compression: Some(Compression::Raw), data: vec![200; 4] }; + let mut painted = adjustment_record("painted", vec![extra()]); + painted.rect = Rect::from_xywh(0, 0, 2, 2); + painted.channels = (-1..3).map(plane).collect(); + f.layers_mut().push(painted); + // No pixels and an unknown block, but not marked "pixel data irrelevant": an empty pixel layer. + let mut unflagged = adjustment_record("unflagged", vec![extra()]); + unflagged.flags = LayerFlags::default(); + f.layers_mut().push(unflagged); + // Two unknown blocks: which one is the adjustment is a guess, so nothing is guessed. + f.layers_mut().push(adjustment_record("ambiguous", vec![extra(), TaggedBlock::new(*b"zOth", vec![1, 1, 1, 1])])); + let (d, warnings) = open(&f); + for name in ["a", "painted", "unflagged", "ambiguous"] { + assert!(matches!(content(&d, name), LayerContent::Raster(_)), "{name}: {:?}", content(&d, name)); + assert!(!warnings.iter().any(|w| w.contains(&format!("\"{name}\""))), "{name}: {warnings:?}"); + } + let LayerContent::Raster(s) = content(&d, "painted") else { unreachable!() }; + assert!(!s.content_bounds().is_empty(), "painted keeps its pixels"); + // The unknown block is still kept verbatim on save, once. + let g = save(&d); + for name in ["a", "painted", "unflagged", "ambiguous"] { + let r = record(&g, name); + let kept: Vec<&TaggedBlock> = r.blocks.iter().filter(|b| &b.key == b"zNew").collect(); + assert_eq!(kept.len(), 1, "{name}"); + assert_eq!(kept[0].data, vec![9, 8, 7, 6], "{name}"); + } +} From 5d3eb7f130132e3cafb0c6329d6331b81f0783a6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 19:56:28 +0200 Subject: [PATCH 0350/1452] Paths: drag a path row onto the Trash to delete it (#6553) In Photoshop a path is deleted by dragging its row in the Paths panel onto the Delete (Trash) button at the panel's bottom: the row follows the cursor and releasing over the Trash deletes that path, whichever row is selected. PhotoCraft's rows only reacted to clicks, so the drag gave no feedback and a drop on the Trash did nothing. Saved path, work path and vector mask rows now start a drag. While it runs the row's name follows the cursor (the ghost the Layers and History panels use) and the Trash is outlined when under the pointer; releasing there runs the row's own delete command (path.delete for a saved or the work path, layer.vectorMask.delete for a vector mask), reusing the History panel's dropped_on helper. A drag that never sees its release is dropped next frame. A shape layer's path row does not start a drag: what deleting it does is #6552's change, kept separate. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/vector_ui.rs | 111 +++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..f1a0d11c85 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1687,6 +1687,19 @@ fn footer_id() -> egui::Id { egui::Id::new("paths-footer") } +/// A Paths panel row being dragged, for a drop on the trash (#6553): its name and the command +/// that deletes it. +#[derive(Clone, Debug)] +struct PathDrag { + name: String, + cmd: &'static str, + params: Value, +} + +fn path_drag_key() -> egui::Id { + egui::Id::new("paths-drag") +} + fn ctx_data_footer(ctx: &egui::Context, r: Rect) { ctx.data_mut(|d| d.insert_temp(footer_id(), r)); } @@ -1748,6 +1761,13 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; let mut stroke_dialog: Option = None; + // A row dragged onto the trash is deleted (#6553); a drag that never saw its release must not + // outlive it. + let ctx = ui.ctx().clone(); + if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(path_drag_key())); + } + let drag = ctx.data(|d| d.get_temp::(path_drag_key())); // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1759,12 +1779,26 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(tl!("Draw with the Pen tool (P) or make a work path from a selection.")).color(t.text_faint).size(11.5)); } for PathEntry { name, path, kind } in &rows { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + // What dropping the row on the trash runs; a shape layer's path is not dragged. + let delete = match kind { + PathRow::Work => Some(("path.delete", json!({"name": "work"}))), + PathRow::Saved => Some(("path.delete", json!({"name": name}))), + PathRow::Layer if name.ends_with(" Vector Mask") => Some(("layer.vectorMask.delete", json!({}))), + PathRow::Layer => None, + }; + let sense = if delete.is_some() { Sense::click_and_drag() } else { Sense::click() }; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), sense); let key = match kind { PathRow::Work => "work".to_string(), PathRow::Layer => "layer".to_string(), PathRow::Saved => name.clone(), }; + if resp.drag_started() + && let Some((cmd, params)) = delete + { + let name = if *kind == PathRow::Work { tl!(name).to_string() } else { name.clone() }; + ui.ctx().data_mut(|d| d.insert_temp(path_drag_key(), PathDrag { name, cmd, params })); + } // Rows are painted: name them for screen readers and UI tests. resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, true, name)); let sel = app.ui.selected_path.as_deref() == Some(key.as_str()); @@ -1832,6 +1866,12 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }, ); + // The dragged row follows the cursor. + if let Some(drag) = &drag + && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) + { + crate::layer_transfer::ghost(&ctx, p, &drag.name); + } let sel = app.ui.selected_path.clone().filter(|s| s != "layer" || has_layer_path).unwrap_or_else(|| "work".into()); let buttons = crate::widgets::panel_footer(ui, |ui| { let fg = hex(app.session.tools.foreground); @@ -1867,11 +1907,21 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { action = Some((cmd, p)); } } + // A row dropped on the trash deletes that row's path, selected or not (#6553). + if matches!(cmd, "path.delete" | "layer.vectorMask.delete") + && let Some(drag) = &drag + && crate::panels::dropped_on(ui, &b) + { + action = Some((drag.cmd, drag.params.clone())); + } buttons = buttons.union(b.rect); } buttons }); ctx_data_footer(ui.ctx(), buttons); + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(path_drag_key())); + } if let Some(name) = stroke_dialog { crate::canvas_tool_menu::stroke_path_dialog(app, &name); } @@ -2715,6 +2765,65 @@ mod tests { h.run_steps(2); assert!(h.query_all(egui_kittest::kittest::by().label("Fill:").include_labels()).next().is_none()); } + + /// #6553: a Paths row dragged onto the footer's trash is deleted, whichever row is selected, + /// and a shape layer's path row does not start a drag. + #[test] + fn path_rows_dragged_onto_the_trash_are_deleted() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "Path 1", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.ui.selected_path = Some("work".into()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| paths_panel(app, ui), app); + h.run_steps(3); + let trash = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.into_iter().max_by(|a, b| a.center().x.total_cmp(&b.center().x)).unwrap().center() + }; + let drag = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2| { + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + }; + // The saved path, while the work path is selected: only the dragged row goes. + let (row, to) = (h.get_by_label("Path 1").rect().center(), trash(&h)); + drag(&mut h, row, to); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.paths.is_empty(), "the dragged saved path is deleted"); + assert!(doc.work_path.is_some(), "the selected work path is kept"); + // The work path. + let (row, to) = (h.get_by_label("Work Path").rect().center(), trash(&h)); + drag(&mut h, row, to); + assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "the dragged work path is deleted"); + // A drop elsewhere deletes nothing. + h.state_mut().run("path.set", json!({"name": "Path 2", "path": path})).unwrap(); + h.run_steps(2); + let row = h.get_by_label("Path 2").rect().center(); + drag(&mut h, row, row + vec2(0.0, 120.0)); + assert_eq!(h.state().session.active().unwrap().doc.paths.len(), 1, "a drop off the trash keeps the path"); + // A shape layer's path row is not dragged onto the trash (its deletion is #6552's). + h.state_mut().run("shape.create", json!({"kind": "ellipse", "rect": [20, 20, 100, 70]})).unwrap(); + h.run_steps(2); + let layers = h.state().session.active().unwrap().doc.layers.len(); + let (row, to) = (h.get_by_label("Ellipse 1 Shape Path").rect().center(), trash(&h)); + drag(&mut h, row, to); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.layers.len(), doc.paths.len()), (layers, 1), "nothing is deleted"); + } } #[cfg(test)] From 489feb2333d404f035b39392ec1ad9c3711d952c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:57:15 +0200 Subject: [PATCH 0351/1452] Tests: pcraft keeps text run baseline; tighten sub/superscript size check (#5645) Co-Authored-By: Claude Opus 5.5 --- crates/format/tests/roundtrip.rs | 23 +++++++++++++++++++++++ crates/text/src/tests.rs | 6 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..5a4fe87b2d 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() { assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::(), text.text.len()); } +#[test] +fn text_run_baseline_roundtrips() { + use photocraft_doc::text::FontBaseline; + let mut doc = Document::new("scripts", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Text", + LayerContent::Text(TextLayer { + text: "H2O2".into(), + runs: vec![ + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Subscript, ..Default::default() } }, + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Superscript, ..Default::default() } }, + ], + ..Default::default() + }), + )); + let loaded = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") }; + let baselines: Vec = text.char_runs().iter().map(|r| r.style.baseline).collect(); + assert_eq!(baselines, [FontBaseline::Normal, FontBaseline::Subscript, FontBaseline::Normal, FontBaseline::Superscript]); +} + #[test] fn zip_incremental_only_new_tiles() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f00a8e8862..4ae059be41 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -449,9 +449,11 @@ fn superscript_and_subscript_shrink_and_shift_the_run() { let plain = rect(&mut e, FontBaseline::Normal); let sup = rect(&mut e, FontBaseline::Superscript); let sub = rect(&mut e, FontBaseline::Subscript); - let ratio = |r: &photocraft_geom::Rect| r.height() as f32 / plain.height() as f32; + // Scripts render at 58.3% of the size. The pixel-snapped bounds of the antialiased glyph land + // within 2 px of the exact scaled height (measured: +2.2 / +1.2 px at 46 px plain height). + let expect = plain.height() as f32 * 0.583; for r in [&sup, &sub] { - assert!((ratio(r) - 0.583).abs() < 0.06, "plain {plain:?}, scripted {r:?}"); + assert!((r.height() as f32 - expect).abs() <= 2.5, "plain {plain:?}, scripted {r:?}"); } // The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below. assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}"); From 4cbe912fc588548145ef2040b1f1e42d0be9a305 Mon Sep 17 00:00:00 2001 From: Sergey Arseev <9413serg@gmail.com> Date: Fri, 16 Oct 2026 22:33:35 +0300 Subject: [PATCH 0352/1452] Puppet warp: Delete and Backspace remove the selected pin --- crates/ui-egui/src/distort_ui.rs | 2 + crates/ui-egui/src/puppet_ui.rs | 190 ++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 1f12b129f1..1fa2f6288e 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -184,6 +184,8 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { p.redo(); } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { p.undo(); + } else if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)) { + p.delete_selected_pin(); } return true; } diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index e233396cfd..8b682fe255 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -1,5 +1,6 @@ //! Edit › Puppet Warp on the canvas: click on the mesh to add pins, drag pins to move them, -//! ⌥-click a pin to remove it; the options bar sets mode, density, expansion, mesh visibility, +//! Delete/Backspace removes the selected pin; ⌥-click removes a pin under the pointer. +//! The options bar sets mode, density, expansion, mesh visibility, //! the selected pin's depth and rotation. ⌘Z / ⌘⇧Z undo and redo pin edits inside the session //! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! @@ -79,6 +80,22 @@ impl PuppetSession { true } + /// Removes the selected constraint and its deformation as one undoable pin edit. + pub fn delete_selected_pin(&mut self) -> bool { + let Some(i) = self.selected.filter(|i| *i < self.warp.pins.len()) else { return false }; + // Finish an in-progress drag first, so undo can restore the pin, then undo its move. + if let Some(before) = self.drag_from.take() { + self.record(before); + } + let before = self.warp.pins.clone(); + self.warp.pins.remove(i); + self.selected = None; + self.drag = None; + self.rebind(); + self.record(before); + true + } + pub fn describe(&self) -> Value { json!({ "layer": self.layer.0, @@ -242,6 +259,10 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { s.redo(); return Ok(s.describe()); } + if ui.get("deleteSelectedPin").and_then(Value::as_bool) == Some(true) { + s.delete_selected_pin(); + return Ok(s.describe()); + } let mut remesh = false; let (density, expansion) = (s.warp.density, s.warp.expansion); if let Some(m) = ui.get("mode").and_then(Value::as_str) { @@ -275,10 +296,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let before = s.warp.pins.clone(); if let Some(i) = s.pin_at(p, tol) { if mods.alt { - s.warp.pins.remove(i); - s.selected = None; - s.rebind(); - s.record(before); + s.selected = Some(i); + s.delete_selected_pin(); } else { s.selected = Some(i); s.drag = Some(i); @@ -454,6 +473,167 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + fn puppet_app(ctx: &egui::Context) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + begin(&mut app, ctx, "edit.puppetWarp").unwrap(); + app + } + + fn gesture(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + pointer(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, egui::Modifiers::NONE); + pointer(app, ToolEvent::Up { x: to[0], y: to[1] }, egui::Modifiers::NONE); + } + + fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + let input = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); + } + + #[test] + fn delete_keys_remove_selected_pin_and_restore_its_deformation_on_undo() { + for key in [egui::Key::Delete, egui::Key::Backspace] { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 30.0]); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let s = app.distort.puppet.as_mut().unwrap(); + // Rotation/depth are part of a deleted pin's state, not just its position. + let before = s.warp.pins.clone(); + s.warp.pins[0].rotate = Some(15.0); + s.warp.pins[0].depth = 2; + s.rebind(); + s.record(before); + let pins = s.warp.pins.clone(); + let deformed = s.deformed.clone(); + let order = s.order.clone(); + let edits = s.undo.len(); + let survivor = &pins[1]; + let solver = PuppetSolver::new(s.mesh.clone(), &[survivor.src], &[survivor.rotate.is_some()]); + let expected = solver.solve(&[survivor.dst], &[survivor.rotate], s.warp.mode, ITERATIONS, None).0; + let past = app.session.active().unwrap().history.past_len(); + + press(&mut app, &ctx, key, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins, pins[1..], "{key:?}: delete the selected first pin, not the last"); + assert_eq!(s.selected, None); + assert_eq!(s.deformed, expected, "solve from rest without the deleted constraint"); + assert_eq!(s.order, solver.draw_order(&[survivor.depth], &[survivor.src])); + assert_eq!(s.undo.len(), edits + 1); + press(&mut app, &ctx, key, egui::Modifiers::NONE); + assert_eq!(app.distort.puppet.as_ref().unwrap().undo.len(), edits + 1, "no selection: no edit"); + + press(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins, pins); + assert_eq!(s.deformed, deformed); + assert_eq!(s.order, order); + press(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.distort.puppet.as_ref().unwrap().deformed, expected); + assert_eq!(app.session.active().unwrap().history.past_len(), past, "pin edits stay in the open session"); + + let original = app.session.active().unwrap().doc.clone(); + commit(&mut app); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + let committed = app.session.active().unwrap().doc.clone(); + assert_ne!(committed, original); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, original); + app.session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, committed); + } + } + + #[test] + fn deleting_during_a_drag_settles_history_and_cannot_move_the_next_pin() { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 40.0]); + let original = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 25.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + let moved = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + press(&mut app, &ctx, egui::Key::Delete, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 5.0, y: 5.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Up { x: 5.0, y: 5.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_mut().unwrap(); + assert_eq!(s.warp.pins, original[1..]); + assert!(s.drag.is_none() && s.drag_from.is_none()); + assert!(s.undo()); + assert_eq!(s.warp.pins, moved, "first undo restores the deleted pin at its moved position"); + assert!(s.undo()); + assert_eq!(s.warp.pins, original, "second undo reverts the preceding drag"); + assert!(s.redo()); + s.selected = Some(0); + control(&mut app, &json!({"deleteSelectedPin": true})).unwrap(); + assert!(!app.distort.puppet.as_mut().unwrap().redo(), "a new deletion discards the redo branch"); + } + + #[test] + fn deleting_last_pin_restores_rest_mesh_and_empty_commit_is_not_an_edit() { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let past = app.session.active().unwrap().history.past_len(); + let s = app.distort.puppet.as_mut().unwrap(); + let pins = s.warp.pins.clone(); + s.selected = Some(usize::MAX); + press(&mut app, &ctx, egui::Key::Delete, egui::Modifiers::NONE); + assert_eq!(app.distort.puppet.as_ref().unwrap().warp.pins, pins, "stale selection is harmless"); + app.distort.puppet.as_mut().unwrap().selected = Some(0); + press(&mut app, &ctx, egui::Key::Backspace, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert!(s.warp.pins.is_empty()); + assert_eq!(s.deformed.len(), s.mesh.verts.len()); + for (deformed, rest) in s.deformed.iter().zip(&s.mesh.verts) { + assert!(dist(*deformed, *rest) < 1e-8, "no constraints: the mesh returns to rest"); + } + commit(&mut app); + assert!(app.distort.puppet.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + + #[test] + fn delete_keys_in_a_text_field_do_not_remove_pins() { + for key in [egui::Key::Delete, egui::Key::Backspace] { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let mut text = "123".to_string(); + ctx.run_ui(Default::default(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + let pins = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| { + assert!(ui.ctx().text_edit_focused()); + crate::shortcuts::handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut text); + }) + .textures_delta + .clear(); + assert_eq!(app.distort.puppet.as_ref().unwrap().warp.pins, pins); + assert_eq!(app.distort.puppet.as_ref().unwrap().undo.len(), 1); + } + } + /// #5092: a Puppet Warp session left open does not follow the user to a new document. #[test] fn session_is_dropped_when_another_document_becomes_active() { From fb99255513e7c8c347c77aac24c79d03b7f275a3 Mon Sep 17 00:00:00 2001 From: Ahmed Tammam Date: Fri, 16 Oct 2026 23:22:33 +0300 Subject: [PATCH 0353/1452] feat(ui): derive transform anchor from pivot and add missing i18n catalogs - Add 'Reference point location' translation to cs, de, ko, vi, zh-hans, and zh-hant catalogs - Derive highlighted 3x3 reference point cell dynamically from t.pivot matching point_on_quad - Add unit test assertions for matching_anchor --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/transform_tool.rs | 30 ++++++++++++++++++++-------- 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..5f906a3f53 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3805,6 +3805,7 @@ navigator Zoom Zvětšení {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) # Free Transform: a new layer while the box is open (#5859) + Reference point location Umístění referenčního bodu Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. The transform couldn't be applied. Transformaci nelze použít. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..e98111e9e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3199,6 +3199,7 @@ curves Smooth Glätten {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) # Free Transform: a new layer while the box is open (#5859) + Reference point location Position des Referenzpunkts Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..a52eeb237d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3375,6 +3375,7 @@ curves Smooth 매끄럽게 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) # Free Transform: a new layer while the box is open (#5859) + Reference point location 기준점 위치 Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. The transform couldn't be applied. 변형을 적용할 수 없습니다. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..bbb083f81b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3683,6 +3683,7 @@ curves Smooth Làm mượt {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) # Free Transform: a new layer while the box is open (#5859) + Reference point location Vị trí điểm tham chiếu Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. The transform couldn't be applied. Không thể áp dụng phép biến đổi. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..4352a5ffc9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2813,6 +2813,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) # Free Transform: a new layer while the box is open (#5859) + Reference point location 参考点位置 Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 The transform couldn't be applied. 无法应用变换。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..6c951d09cb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2798,6 +2798,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) # Free Transform: a new layer while the box is open (#5859) + Reference point location 參考點位置 Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 The transform couldn't be applied. 無法套用變形。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 03c8fc6a6e..0be1f59f50 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -2846,7 +2846,14 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes } } -fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: usize) -> Option { +fn matching_anchor(quad: [[f64; 2]; 4], pivot: [f64; 2]) -> Option { + (0..9).find(|&i| { + let p = point_on_quad(quad, i); + (p[0] - pivot[0]).abs() <= 0.05 && (p[1] - pivot[1]).abs() <= 0.05 + }) +} + +fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: Option) -> Option { let size = egui::vec2(16.0, 16.0); let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::click()); let tk = crate::theme::Tokens::get(ui.ctx()); @@ -2863,7 +2870,7 @@ fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: usize) -> Option if resp.clicked() && pointer.is_some_and(|p| (p.x - cx).abs() <= 3.0 && (p.y - cy).abs() <= 3.0) { clicked = Some(idx); } - if idx == current_anchor { + if current_anchor == Some(idx) { painter.rect_filled(pt_rect, 0.5, tk.accent); } else { painter.rect_filled(pt_rect, 0.5, tk.text_dim); @@ -2881,17 +2888,18 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let lbl = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0)); }; - let anchor_id = egui::Id::new("transform-anchor"); - let current_anchor: usize = ui.data(|d| d.get_temp(anchor_id)).unwrap_or(4); + let current_anchor = matching_anchor(t.quad, t.pivot); + let mut pivot = t.pivot; if let Some(new_anchor) = anchor_grid_widget(ui, current_anchor) { - ui.data_mut(|d| d.insert_temp(anchor_id, new_anchor)); + let new_p = point_on_quad(t.quad, new_anchor); if let Some(s) = app.ui.transform.as_mut() { - s.pivot = point_on_quad(s.quad, new_anchor); + s.pivot = new_p; } + pivot = new_p; } ui.add_space(4.0); - let mut px = t.pivot[0] as f32; - let mut py = t.pivot[1] as f32; + let mut px = pivot[0] as f32; + let mut py = pivot[1] as f32; lbl(ui, "X:"); let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0); lbl(ui, "Y:"); @@ -5433,5 +5441,11 @@ mod tests { assert_eq!(p_top_right, [200.0, 0.0]); let p_center = carry_pivot(quad, quad_scaled, [50.0, 25.0]); assert_eq!(p_center, [100.0, 50.0]); + + for anchor in 0..9 { + assert_eq!(matching_anchor(quad, point_on_quad(quad, anchor)), Some(anchor)); + } + assert_eq!(matching_anchor(quad, [12.3, 45.6]), None); + assert_eq!(matching_anchor(quad, [50.0, 30.0]), None); } } From 4354113e730dc1c2c83fd02dede69ebf222be877 Mon Sep 17 00:00:00 2001 From: nanopink Date: Fri, 16 Oct 2026 16:23:53 -0400 Subject: [PATCH 0354/1452] fix: match Photoshop Gradient Fill scale limits --- crates/engine/src/gradient_fill_cmds.rs | 9 +++--- crates/engine/src/gradient_fill_cmds/tests.rs | 28 ++++++++++--------- crates/engine/src/presets/gradients.rs | 4 +-- .../src/gradient_ui/properties_panel.rs | 3 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..356d2252db 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,9 +28,10 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -149,7 +150,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index efcdb494d9..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..2a700aad99 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -604,7 +604,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -696,7 +696,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index 39b961ee20..d975102539 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -49,7 +49,8 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "angle": value})); } - if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, 10.0..=150.0, "%") { + let (min, max) = cmds::SCALE_RANGE; + if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, min * 100.0..=max * 100.0, "%") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "scale": value})); } ui.horizontal_wrapped(|ui| { From c1499b20376e630642491cb36ec2567ec571cc0b Mon Sep 17 00:00:00 2001 From: Sergey Arseev <9413serg@gmail.com> Date: Fri, 16 Oct 2026 23:28:03 +0300 Subject: [PATCH 0355/1452] Puppet warp: Fix discrepancy between preview and the final state --- crates/algo/examples/bench_liquify.rs | 21 ++++- crates/ui-egui/src/puppet_ui.rs | 128 +++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/crates/algo/examples/bench_liquify.rs b/crates/algo/examples/bench_liquify.rs index 2eea3b951b..78165171f2 100644 --- a/crates/algo/examples/bench_liquify.rs +++ b/crates/algo/examples/bench_liquify.rs @@ -127,10 +127,23 @@ fn main() { photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3 ); - let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); - let t = Instant::now(); - let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v)); - println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3); + // Compare the old path-dependent preview with the command-equivalent drag solve, using + // changing destinations and the same cached factorization (no remeshing per event). + for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] { + let mut previous = v.clone(); + let mut times = Vec::new(); + for step in 1..=60 { + let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); + dst[1][0] -= f64::from(step) * 10.0; + dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0; + let t = Instant::now(); + let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice())); + times.push(t.elapsed().as_secs_f64() * 1e3); + previous = std::hint::black_box(next); + } + times.sort_by(f64::total_cmp); + println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]); + } let t = Instant::now(); let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic); println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3); diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index e233396cfd..05cb5fe35c 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -4,9 +4,9 @@ //! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! //! The preview is the layer texture on the deformed triangle mesh (an egui textured mesh, drawn -//! by the GPU), re-solved with a few warm-started ARAP rounds per drag event (well under a -//! millisecond with the banded Cholesky factor). When a drag ends the mesh is solved from scratch -//! exactly like the engine, so what you see at rest is what Commit produces. +//! by the GPU). Each changed pin position uses the engine's iteration count and rest-mesh +//! initialization, reusing the cached solver factorization. Warm-starting from earlier drag +//! previews made the result depend on pointer history and snap to a different shape on release. use std::sync::Arc; @@ -116,16 +116,15 @@ impl PuppetSession { let src: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.src).collect(); let fixed: Vec = self.warp.pins.iter().map(|p| p.rotate.is_some()).collect(); self.solver = Some(PuppetSolver::new(self.mesh.clone(), &src, &fixed)); - self.solve(true); + self.solve(); } - /// Solves the deformation: exactly like the engine (`exact`), or a few warm rounds. - fn solve(&mut self, exact: bool) { + /// Solves from rest, exactly like the engine, during dragging as well as at rest. + fn solve(&mut self) { let Some(solver) = &self.solver else { return }; let dst: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.dst).collect(); let rot: Vec> = self.warp.pins.iter().map(|p| p.rotate).collect(); - let warm = (!exact && self.deformed.len() == self.mesh.verts.len()).then_some(self.deformed.as_slice()); - let (v, _) = solver.solve(&dst, &rot, self.warp.mode, if exact { ITERATIONS } else { 6 }, warm); + let (v, _) = solver.solve(&dst, &rot, self.warp.mode, ITERATIONS, None); self.deformed = v; let src: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.src).collect(); let depths: Vec = self.warp.pins.iter().map(|p| p.depth).collect(); @@ -261,7 +260,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if remesh { s.remesh_or_restore(density, expansion)?; } else { - s.solve(true); + s.solve(); } Ok(s.describe()) } @@ -301,15 +300,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { && pin.dst != [x, y] { pin.dst = [x, y]; - s.solve(false); + s.solve(); } } ToolEvent::Up { x, y } => { if let Some(i) = s.drag.take() { - if let Some(pin) = s.warp.pins.get_mut(i) { + // A release usually repeats the last Move, but can carry a new position. + if let Some(pin) = s.warp.pins.get_mut(i) + && pin.dst != [x, y] + { pin.dst = [x, y]; + s.solve(); } - s.solve(true); if let Some(before) = s.drag_from.take() { s.record(before); } @@ -436,7 +438,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if rebind { s.rebind(); } else if resolve { - s.solve(true); + s.solve(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::primary_button(ui, "✓", 32.0) @@ -454,6 +456,106 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + fn long_puppet(ctx: &egui::Context, mode: PuppetMode, density: PuppetDensity) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 1000, "height": 300, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(40, 180, 960, 220), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + begin(&mut app, ctx, "edit.puppetWarp").unwrap(); + for x in [70.0, 160.0, 930.0] { + pointer(&mut app, ToolEvent::Down { x, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Up { x, y: 200.0 }, egui::Modifiers::NONE); + } + let s = app.distort.puppet.as_mut().unwrap(); + s.warp.mode = mode; + s.warp.density = density; + s.rebuild_mesh(); + app + } + + fn assert_same_mesh(a: &[[f64; 2]], b: &[[f64; 2]]) { + assert_eq!(a.len(), b.len()); + let offset = a.iter().zip(b).map(|(a, b)| dist(*a, *b)).fold(0.0, f64::max); + assert!(offset < 1e-8, "mesh moved by {offset} px with unchanged pins"); + } + + #[test] + fn drag_preview_matches_release_history_and_committed_geometry() { + use photocraft_algo::puppet::{deform, render}; + for mode in [PuppetMode::Rigid, PuppetMode::Normal, PuppetMode::Distort] { + for density in [PuppetDensity::Fewer, PuppetDensity::Normal, PuppetDensity::More] { + let ctx = egui::Context::default(); + let mut app = long_puppet(&ctx, mode, density); + let rest = app.distort.puppet.as_ref().unwrap().deformed.clone(); + let past = app.session.active().unwrap().history.past_len(); + pointer(&mut app, ToolEvent::Down { x: 930.0, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + for step in 1..=12 { + let x = 930.0 - f64::from(step) * 20.0; + let y = 200.0 - f64::from(step) * 10.0; + pointer(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + } + let s = app.distort.puppet.as_ref().unwrap(); + let preview = s.deformed.clone(); + let order = s.order.clone(); + pointer(&mut app, ToolEvent::Up { x: 690.0, y: 80.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_mut().unwrap(); + assert_same_mesh(&preview, &s.deformed); + assert_eq!(order, s.order); + let (_, canonical, _) = deform(&s.surface, s.bounds, &s.warp, ITERATIONS); + assert_same_mesh(&preview, &canonical); + assert!(s.undo()); + assert_same_mesh(&rest, &s.deformed); + assert!(s.redo()); + assert_same_mesh(&preview, &s.deformed); + // Use the engine's interpolation to isolate geometry from GPU texture sampling. + let expected = render(&s.surface, s.bounds, &s.mesh, &preview, &order, photocraft_algo::transform::Interp::Bicubic); + let layer = s.layer; + assert_eq!(app.session.active().unwrap().history.past_len(), past); + commit(&mut app); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), past + 1); + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap(), &expected); + } + } + } + + #[test] + fn drag_preview_is_independent_of_pointer_path_and_honors_release_coordinates() { + let ctx = egui::Context::default(); + let mut app = long_puppet(&ctx, PuppetMode::Normal, PuppetDensity::More); + let s = app.distort.puppet.as_mut().unwrap(); + s.warp.pins[0].rotate = Some(25.0); + s.rebind(); + let pins = s.warp.pins.clone(); + let paths = [ + vec![[690.0, 80.0]], + vec![[870.0, 170.0], [810.0, 140.0], [750.0, 110.0], [690.0, 80.0]], + vec![[500.0, 30.0], [930.0, 200.0], [1000.0, 270.0], [690.0, 80.0]], + ]; + let mut expected = None; + for path in paths { + app.distort.puppet.as_mut().unwrap().restore(pins.clone()); + pointer(&mut app, ToolEvent::Down { x: 930.0, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + for [x, y] in path { + pointer(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + } + let preview = &app.distort.puppet.as_ref().unwrap().deformed; + assert_same_mesh(expected.get_or_insert_with(|| preview.clone()), preview); + // The last pointer-up may carry a new position without a preceding move event. + pointer(&mut app, ToolEvent::Up { x: 720.0, y: 90.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins[2].dst, [720.0, 90.0]); + let (_, canonical, _) = photocraft_algo::puppet::deform(&s.surface, s.bounds, &s.warp, ITERATIONS); + assert_same_mesh(&s.deformed, &canonical); + } + } + /// #5092: a Puppet Warp session left open does not follow the user to a new document. #[test] fn session_is_dropped_when_another_document_becomes_active() { From b01f6d33b3ded211ff1a1429525e55768c1241a7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:33:54 +0200 Subject: [PATCH 0356/1452] PSD: layered TIFF byte-order conversion reads the class of Idnt/indx references (#6764) The byte-order converter read Idnt/indx reference items as a bare u32, but they are class + u32 (as descriptor.rs reads them since #4434), so a block holding such a reference desynced and was dropped (or left unconverted). Read the class first; regression test round-trips such an lfx2 block. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/psd/src/tiff.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 31c85d4885..8e93f10716 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -1284,6 +1284,7 @@ pub mod transcode { self.i32()?; } b"Idnt" | b"indx" => { + self.class()?; self.u32()?; } b"name" => { @@ -1789,6 +1790,17 @@ mod tests { assert!(little.windows(11).any(|w| w == b"longKeyName"), "string ids are not reversed"); } + #[test] + fn descriptor_references_by_identifier_and_index_keep_their_class() { + use crate::descriptor::{Class, ReferenceItem}; + let class = || Class { name: UnicodeString::new(""), class_id: Id::new("Lyr ") }; + let desc = Descriptor::new("null") + .with("null", Value::Reference(vec![ReferenceItem::Identifier { class: class(), id: 7 }, ReferenceItem::Index { class: class(), index: 2 }])); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor::new(desc).to_bytes()); + block_rt(*b"lfx2", data); + } + #[test] fn type_block_layout() { let mut data = Vec::new(); From 7b32097e2a9ef43593c997efb60b8897fe924862 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 16:40:42 -0400 Subject: [PATCH 0357/1452] Intel Iris Xe on DX12: no more red blocks in brush previews and tool icons (#6790) wgpu-hal 30 creates Iris Xe textures as committed resources (its gfx-rs/wgpu#3552 workaround) with D3D12_HEAP_FLAG_CREATE_NOT_ZEROED, and egui-wgpu fills each new texture with a single copy. On this driver the copy leaves 4x4 blocks of all-zero texels showing stale memory, so the empty corners of Brush Preset picker strokes and tool icons show freed textures' pixels (often red). ui-egui's new texel_guard plugin rewrites fully transparent texels in each frame's texture uploads as premultiplied (0, 0, 0, 1) before eframe renders them (under one 8-bit level of difference). The desktop app installs it only for DX12 on "Iris(R) Xe", the same adapter test wgpu uses; PHOTOCRAFT_TEXEL_GUARD=1/0 forces it on or off. The gpu example zero_block_probe reproduces the driver bug without the UI: 630/1500 egui-style uploads read back wrong, 0/1500 with the guard. Driving the release app (tool cycling, picker rescaled): 6/40 screenshots with red blocks before, 0/120 after. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/main.rs | 3 + crates/gpu/examples/zero_block_probe.rs | 182 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/texel_guard.rs | 185 ++++++++++++++++++++++++ docs/development.md | 1 + 5 files changed, 372 insertions(+) create mode 100644 crates/gpu/examples/zero_block_probe.rs create mode 100644 crates/ui-egui/src/texel_guard.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c0f93bd319..a866eedf42 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -544,6 +544,9 @@ fn main() -> eframe::Result { info.canvas = "cpu".into(); if let Some(rs) = cc.wgpu_render_state.clone() { app.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + // Every egui texture uploads through this adapter, whichever canvas draws: + // Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs). + photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info()); let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu; if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu { app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into()); diff --git a/crates/gpu/examples/zero_block_probe.rs b/crates/gpu/examples/zero_block_probe.rs new file mode 100644 index 0000000000..873a929f86 --- /dev/null +++ b/crates/gpu/examples/zero_block_probe.rs @@ -0,0 +1,182 @@ +//! Probe: do textures uploaded like egui-wgpu's (TEXTURE_BINDING | COPY_DST, one full +//! `write_texture`) read back exactly what was written? Uploads mostly-transparent images with a +//! few opaque texels, samples every texel in a render pass, reads the target back and counts +//! texels that differ. Between uploads it frees render targets cleared to red, so stale memory is +//! red if the driver hands it back without honouring the copy. +//! +//! cargo run --release -p photocraft-gpu --example zero_block_probe -- [dx12|vulkan|gl] [rounds] [guard] + +use wgpu::util::DeviceExt; + +const SHADER: &str = r" +@group(0) @binding(0) var t: texture_2d; +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = array(vec2(-1.0, -3.0), vec2(-1.0, 1.0), vec2(3.0, 1.0)); + return vec4(p[i], 0.0, 1.0); +} +@fragment fn fs(@builtin(position) p: vec4) -> @location(0) vec4 { + return textureLoad(t, vec2(p.xy), 0); +} +"; + +fn main() { + let args: Vec = std::env::args().collect(); + let backends = match args.get(1).map(String::as_str) { + Some("vulkan") => wgpu::Backends::VULKAN, + Some("gl") => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let rounds: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300); + // `guard`: transparent texels are uploaded as (0, 0, 0, 1), as `photocraft_ui_egui::texel_guard` does. + let guard = args.get(3).is_some_and(|a| a == "guard"); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).expect("adapter"); + let info = adapter.get_info(); + println!("adapter: {} ({:?}) driver {} {}", info.name, info.backend, info.driver, info.driver_info); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).expect("device"); + + let fmt = wgpu::TextureFormat::Rgba8Unorm; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { format: fmt, blend: None, write_mask: wgpu::ColorWrites::ALL })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + let mut bad_textures = 0; + let mut bad_texels = 0usize; + for round in 0..rounds { + // Vary the size like the brush previews and icons do. + let (w, h) = (16 + (round as u32 * 37) % 300, 16 + (round as u32 * 13) % 60); + // Free some red render targets first: their memory is what a not-zeroed heap hands back. + for _ in 0..2 { + let rt = device.create_texture(&wgpu::TextureDescriptor { + label: Some("red-rt"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = rt.create_view(&Default::default()); + let mut enc = device.create_command_encoder(&Default::default()); + enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.03, b: 0.03, a: 1.0 }), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + queue.submit([enc.finish()]); + } + // The upload: transparent except a diagonal band of opaque grey, like a stroke strip. + let mut px = if guard { [0u8, 0, 0, 1].repeat((w * h) as usize) } else { vec![0u8; (w * h * 4) as usize] }; + for y in 0..h { + for x in 0..w { + if (x as i64 * h as i64 / w as i64 - y as i64).abs() < 3 { + let i = ((y * w + x) * 4) as usize; + px[i..i + 4].copy_from_slice(&[200, 200, 200, 255]); + } + } + } + let tex = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("egui-like"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &px, + ); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("target"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) }], + }); + let tview = target.create_view(&Default::default()); + let row = (w * 4).div_ceil(256) * 256; + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &tview, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + } + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None } }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit([enc.finish()]); + buf.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).expect("poll"); + let data = buf.slice(..).get_mapped_range().expect("mapped"); + let mut bad = 0; + let mut first = None; + for y in 0..h { + for x in 0..w { + let got = &data[(y * row + x * 4) as usize..(y * row + x * 4 + 4) as usize]; + let want = &px[((y * w + x) * 4) as usize..((y * w + x) * 4 + 4) as usize]; + if got != want { + bad += 1; + first.get_or_insert((x, y, got.to_vec())); + } + } + } + if bad > 0 { + bad_textures += 1; + bad_texels += bad; + if bad_textures <= 10 { + println!("round {round}: {w}x{h} texture has {bad} wrong texels, first {first:?}"); + } + } + } + println!("{bad_textures}/{rounds} textures read back wrong ({bad_texels} texels)"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..e9eb4b0a71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -204,6 +204,7 @@ pub mod swatches_ui; pub mod symmetry_ui; mod tab_strip; pub mod targa_options_ui; +pub mod texel_guard; pub mod text_field_menu; pub mod theme; pub mod tiff_options_ui; diff --git a/crates/ui-egui/src/texel_guard.rs b/crates/ui-egui/src/texel_guard.rs new file mode 100644 index 0000000000..28bca7c55b --- /dev/null +++ b/crates/ui-egui/src/texel_guard.rs @@ -0,0 +1,185 @@ +//! Intel Iris Xe on DirectX 12: no egui texture upload carries an all-zero texel, because the +//! driver shows stale memory where it does (the red blocks in Brush Preset picker strokes and +//! around tool icons). +//! +//! wgpu creates every texture on Iris Xe as a committed resource (its workaround for +//! gfx-rs/wgpu#3552, where suballocated textures had the same symptom) and, since wgpu added it, +//! with `D3D12_HEAP_FLAG_CREATE_NOT_ZEROED`. egui-wgpu fills each new texture with one full copy, +//! so wgpu never clears it first. On this driver the copy leaves 4×4 blocks whose texels are all +//! (0, 0, 0, 0) showing whatever the memory last held, typically a freed texture's pixels. Blocks +//! with any other value upload correctly, which is why the garbage sits only in the empty corners +//! of a thumbnail, never next to what is drawn. It shows up after textures have been freed and +//! made again: scrolling the picker (stroke previews come and go), switching brush tools (the +//! options bar and toolbar icons are rasterized again). +//! +//! [`TexelGuard`] rewrites fully transparent texels in every upload as premultiplied +//! (0, 0, 0, 1) before eframe hands the upload to the renderer: alpha 1/255 black, which darkens +//! what is underneath by less than one 8-bit level. It is installed only on the affected adapter +//! ([`affected`]); `PHOTOCRAFT_TEXEL_GUARD=1` forces it on (to check it on any GPU) and `=0` off. + +use std::sync::Arc; + +use eframe::wgpu; +use egui::{Color32, FullOutput, ImageData, TexturesDelta}; + +/// What a fully transparent texel is uploaded as. +pub const NEAR_CLEAR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 1); + +/// Whether texture uploads on this adapter need the guard (`env`: `PHOTOCRAFT_TEXEL_GUARD`). +pub fn affected(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool { + match env { + Some("1") => true, + Some("0") => false, + // The same adapter test wgpu uses for its #3552 workaround. + _ => info.backend == wgpu::Backend::Dx12 && info.name.contains("Iris(R) Xe"), + } +} + +/// Rewrite the fully transparent texels of every upload in `delta` as [`NEAR_CLEAR`]; returns +/// how many changed. Images without one are not copied. +pub fn guard(delta: &mut TexturesDelta) -> usize { + let mut changed = 0; + for d in delta.set.values_mut().flatten() { + let ImageData::Color(image) = &mut d.image; + if !image.pixels.contains(&Color32::TRANSPARENT) { + continue; + } + for p in &mut Arc::make_mut(image).pixels { + if *p == Color32::TRANSPARENT { + *p = NEAR_CLEAR; + changed += 1; + } + } + } + changed +} + +/// The egui plugin that runs [`guard`] on each frame's texture uploads. +pub struct TexelGuard; + +impl egui::Plugin for TexelGuard { + fn debug_name(&self) -> &'static str { + "TexelGuard" + } + + fn output_hook(&mut self, _ctx: &egui::Context, output: &mut FullOutput) { + guard(&mut output.textures_delta); + } +} + +/// Install [`TexelGuard`] on `ctx` when the window's adapter needs it; returns whether it did. +/// Call once, with the adapter eframe renders the window with. +pub fn install(ctx: &egui::Context, info: &wgpu::AdapterInfo) -> bool { + let on = affected(info, std::env::var("PHOTOCRAFT_TEXEL_GUARD").ok().as_deref()); + if on && ctx.plugin_opt::().is_none() { + ctx.add_plugin(TexelGuard); + log::info!("texture uploads: transparent texels written as alpha 1/255 ({}, {:?}; gfx-rs/wgpu#3552)", info.name, info.backend); + } + on +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::epaint::ImageDelta; + use egui::{ColorImage, TextureId, TextureOptions}; + + fn adapter(name: &str, backend: wgpu::Backend) -> wgpu::AdapterInfo { + wgpu::AdapterInfo { name: name.into(), ..wgpu::AdapterInfo::new(wgpu::DeviceType::IntegratedGpu, backend) } + } + + #[test] + fn only_iris_xe_on_dx12_is_affected_unless_forced() { + assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), None)); + assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Vulkan), None)); + assert!(!affected(&adapter("Intel(R) UHD Graphics 620", wgpu::Backend::Dx12), None)); + assert!(!affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), None)); + assert!(affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), Some("1"))); + assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("0"))); + assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("nonsense"))); + } + + #[test] + fn transparent_texels_upload_as_near_clear_and_nothing_else_changes() { + // A stroke-strip-like image: mostly transparent, a grey texel and a faint one. + let grey = Color32::from_gray(200); + let faint = Color32::from_rgba_premultiplied(1, 1, 1, 2); + let mut px = vec![Color32::TRANSPARENT; 16]; + px[5] = grey; + px[6] = faint; + let strip = Arc::new(ColorImage::new([4, 4], px)); + let opaque = Arc::new(ColorImage::filled([2, 2], grey)); + let mut delta = TexturesDelta::default(); + delta.set.insert(TextureId::Managed(1), [ImageDelta::full(ImageData::Color(strip.clone()), TextureOptions::LINEAR)].into_iter().collect()); + delta.set.insert(TextureId::Managed(2), [ImageDelta::full(ImageData::Color(opaque.clone()), TextureOptions::LINEAR)].into_iter().collect()); + // A partial update (the font atlas grows this way) is guarded too. + let patch = Arc::new(ColorImage::new([2, 1], vec![Color32::TRANSPARENT, grey])); + delta.set.insert(TextureId::Managed(0), [ImageDelta::partial([3, 4], ImageData::Color(patch), TextureOptions::LINEAR)].into_iter().collect()); + + assert_eq!(guard(&mut delta), 14 + 1); + let image = |id| { + let ImageData::Color(i) = &delta.set[&TextureId::Managed(id)][0].image; + i.clone() + }; + let s = image(1); + assert!(!s.pixels.contains(&Color32::TRANSPARENT)); + assert_eq!((s.pixels[5], s.pixels[6], s.pixels[0]), (grey, faint, NEAR_CLEAR)); + // The caller's image is untouched (copy on write), and an image with nothing to change is + // not copied at all. + assert!(strip.pixels.contains(&Color32::TRANSPARENT)); + assert!(Arc::ptr_eq(&image(2), &opaque)); + assert_eq!(image(0).pixels, vec![NEAR_CLEAR, grey]); + // Idempotent: a second pass finds nothing. + assert_eq!(guard(&mut delta), 0); + delta.clear(); + } + + #[test] + fn near_clear_is_premultiplied_and_invisible_over_any_background() { + // Premultiplied blend: out = src + dst × (1 − a). Over every 8-bit level the result moves + // by at most one level. + let a = f32::from(NEAR_CLEAR.a()) / 255.0; + assert!(NEAR_CLEAR.r() <= NEAR_CLEAR.a() && NEAR_CLEAR.g() <= NEAR_CLEAR.a() && NEAR_CLEAR.b() <= NEAR_CLEAR.a()); + for dst in 0..=255u8 { + let out = (f32::from(NEAR_CLEAR.r()) + f32::from(dst) * (1.0 - a)).round(); + assert!((f32::from(dst) - out).abs() <= 1.0, "{dst} -> {out}"); + } + } + + #[test] + fn the_plugin_guards_a_frames_uploads() { + let ctx = egui::Context::default(); + ctx.add_plugin(TexelGuard); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let img = ColorImage::new([8, 8], vec![Color32::TRANSPARENT; 64]); + let tex = ui.ctx().load_texture("texel-guard-test", img, TextureOptions::LINEAR); + ui.image((tex.id(), egui::vec2(8.0, 8.0))); + }); + let uploaded: Vec<_> = out + .textures_delta + .set + .values() + .flatten() + .filter_map(|d| { + let ImageData::Color(i) = &d.image; + (i.size == [8, 8]).then(|| i.clone()) + }) + .collect(); + assert_eq!(uploaded.len(), 1); + assert!(uploaded[0].pixels.iter().all(|p| *p == NEAR_CLEAR)); + out.textures_delta.clear(); + } + + #[test] + fn install_adds_the_plugin_once_and_only_where_needed() { + if std::env::var_os("PHOTOCRAFT_TEXEL_GUARD").is_some() { + return; + } + let ctx = egui::Context::default(); + assert!(!install(&ctx, &adapter("Apple M2", wgpu::Backend::Metal))); + assert!(ctx.plugin_opt::().is_none()); + let iris = adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12); + assert!(install(&ctx, &iris) && install(&ctx, &iris)); + assert!(ctx.plugin_opt::().is_some()); + } +} diff --git a/docs/development.md b/docs/development.md index 67f688b6b9..0c7303760d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -119,6 +119,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | +| `PHOTOCRAFT_TEXEL_GUARD=1` (or `=0`) | Force on (off) uploading egui's fully transparent texels as alpha 1/255. On by default only for Intel Iris Xe on DX12, whose driver shows stale memory in all-zero texture blocks: red squares in brush previews and tool icons (`ui-egui/src/texel_guard.rs`; check a GPU with `cargo run --release -p photocraft-gpu --example zero_block_probe -- dx12 400`) | | `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) | | `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | From ab8105230848dc542fffbdbd2cb67372c00e0626 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:49:38 +0200 Subject: [PATCH 0358/1452] Fit Image: a width or height of 0 leaves that side unconstrained (#6766) fit_image read width/height with f64_param and clamped to 1, so a 0 or negative side (dialogs send 0 for an empty field) became a 1 px box and shrank the document to 1x1. It now uses size_param like the rest of file_cmds.rs, so such a side falls back to the document's own size. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/file_cmds.rs | 4 ++-- crates/engine/src/file_cmds/tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..518d9afbfa 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -867,8 +867,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result { fn fit_image(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (w, h) = (d.doc.size.width as f64, d.doc.size.height as f64); - let bw = f64_param(p, "width").unwrap_or(w).max(1.0); - let bh = f64_param(p, "height").unwrap_or(h).max(1.0); + let bw = size_param(p, "width").unwrap_or(w); + let bh = size_param(p, "height").unwrap_or(h); let k = (bw / w).min(bh / h); if (k > 1.0 && p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(false)) || ((w * k).round() == w && (h * k).round() == h) { return Ok(json!({"width": w, "height": h, "changed": false})); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..a5448634ee 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -541,6 +541,19 @@ fn fit_image_and_conditional_mode_change() { assert!(s.execute("file.automate.conditionalModeChange", json!({"to": "duotone"})).is_err()); } +#[test] +fn fit_image_zero_or_negative_side_is_unconstrained() { + // Dialogs send 0 for an empty field: that side is not a limit, not a 1 px box. + let mut s = session(1000, 800, 8); + s.execute("file.automate.fitImage", json!({"width": 0, "height": 400})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height), (500, 400)); + s.execute("file.automate.fitImage", json!({"width": 250, "height": -5})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height), (250, 200)); + let r = s.execute("file.automate.fitImage", json!({"width": 0, "height": 0})).unwrap(); + assert_eq!(r["changed"], false); + assert_eq!((doc(&s).size.width, doc(&s).size.height), (250, 200)); +} + #[test] fn conditional_mode_change_cannot_run_a_mode_change_the_gate_denies() { fn deny_mode(id: &str, _: &Value) -> Result<()> { From bdf44ab18c18a8be911262db91df25f1f136ef4c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:51:32 +0200 Subject: [PATCH 0359/1452] PSD: read colours stored as HSB (#6774) color_from_desc had no HSBC arm, so HSB descriptor colours fell through to the RGB fallback and came back None: solid fills vanished, strokes and effects went black. Convert HSBC (hue in degrees, saturation/brightness 0..100) with the existing channel_map::hsb_to_rgb, now pub(crate). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/blocks.rs | 29 +++++++++++++++++++++++++++++ crates/io/src/channel_map.rs | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..6ac68e4272 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -170,6 +170,11 @@ pub fn color_from_desc(d: &Descriptor) -> Option { b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0), // CIE L*a*b*: L 0..100, a / b −128..127 (stored like Lab pixels: offset by 128). b"LbCl" => Color { mode: ColorMode::Lab, c: [g("Lmnc")? / 100.0, (g("A ")? + 128.0) / 255.0, (g("B ")? + 128.0) / 255.0, 0.0], alpha: 1.0 }, + // HSB: hue an angle in degrees, saturation / brightness 0..100. + b"HSBC" => { + let [r, gr, b] = crate::channel_map::hsb_to_rgb(g("H ")?, g("Strt")? / 100.0, g("Brgh")? / 100.0); + Color::rgb(r, gr, b) + } // Newer Photoshop writes 0..1 floats. _ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?), _ => { @@ -822,6 +827,30 @@ mod tests { assert!(super::color_from_desc(&Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0))).is_none()); } + /// Colours stored as HSB (`HSBC`: `H ` an angle in degrees, `Strt`/`Brgh` 0..100), as in + /// psd-tools `stroke-color-descriptors-hsb-with-rgb-mode.psd`, used to be dropped (#6774). + #[test] + fn hsb_descriptor_colours_parse() { + use photocraft_doc::Fill; + use photocraft_psd::descriptor::{Descriptor, Value}; + let hsb = |h: f64, s: f64, b: f64| { + Descriptor::new("HSBC").with("H ", Value::UnitFloat { unit: *b"#Ang", value: h }).with("Strt", Value::Double(s)).with("Brgh", Value::Double(b)) + }; + let close = |c: [f32; 3], e: [f32; 3]| c.iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-5); + let green = super::color_from_desc(&hsb(120.0, 100.0, 100.0)).unwrap().to_rgb(); + assert!(close(green, [0.0, 1.0, 0.0]), "{green:?}"); + let white = super::color_from_desc(&hsb(0.0, 0.0, 100.0)).unwrap().to_rgb(); + assert!(close(white, [1.0, 1.0, 1.0]), "{white:?}"); + let half = super::color_from_desc(&hsb(240.0, 50.0, 50.0)).unwrap().to_rgb(); + assert!(close(half, [0.25, 0.25, 0.5]), "{half:?}"); + // A solid fill (`SoCo`) with an HSB colour is read, not lost. + let soco = Descriptor::new("null").with("Clr ", Value::Descriptor(hsb(0.0, 100.0, 100.0))); + let Some(Fill::Solid(c)) = super::fill_from_desc(b"SoCo", &soco) else { panic!("solid fill") }; + assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]), "{:?}", c.to_rgb()); + // Missing components are rejected, not defaulted. + assert!(super::color_from_desc(&Descriptor::new("HSBC").with("Strt", Value::Double(50.0))).is_none()); + } + #[test] fn blending_ranges_map_to_blend_if() { use photocraft_doc::{BlendIf, BlendRange}; diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index bafde26f30..74e42feb11 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -111,7 +111,7 @@ pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option { } /// HSB (hue in degrees, saturation and brightness 0..1) → RGB. -fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { +pub(crate) fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 }; let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); let f = h - h.floor(); From df023c803ac05fc2924f5c7eda16895ef64bafc3 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 16 Oct 2026 21:52:22 +0100 Subject: [PATCH 0360/1452] feat(ui): System Info shows the graphics card's own memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's memory caps the GPU budgets (#5234), so a huge canvas can fall back to the CPU on a card with little of it. System Info (Help and Preferences › Performance) now lists "Graphics memory" after the driver when the card reports one; integrated GPUs, which share system memory, show no such line. Translated in all 20 catalogues. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/gpu_canvas.rs | 23 ++++++++++++++++++++++- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 21 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 012bcf801c..3e9ce4bffc 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1036,6 +1036,14 @@ impl GpuInfo { let template = "Driver: {driver}"; crate::i18n::fmt(tl!(template), &[("driver", &or(&self.driver, "unknown"))]) }, + ]; + // The card's own memory caps the GPU budgets, so it explains a huge canvas falling back + // to the CPU. Only discrete cards report one (integrated GPUs share system memory). + if let Some(mb) = self.video_memory_mb { + let template = "Graphics memory: {size}"; + v.push(crate::i18n::fmt(tl!(template), &[("size", &crate::chrome_ui::fmt_bytes(mb.saturating_mul(1 << 20)))])); + } + v.extend([ { let template = "GPU backend preference: {backend}"; crate::i18n::fmt(tl!(template), &[("backend", &or(&self.preference, "auto"))]) @@ -1059,7 +1067,7 @@ impl GpuInfo { }; crate::i18n::fmt(tl!(template), &[("renderer", tl!(renderer))]) }, - ]; + ]); if let Some(f) = &self.fallback { v.push({ let template = "Fallback: {reason}"; @@ -2446,6 +2454,19 @@ mod tests { assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter"); } + /// System Info names the card's own memory, after the driver, when the card reports one; an + /// integrated GPU or an unknown card shows no such line. + #[test] + fn system_info_shows_the_cards_memory() { + let info = GpuInfo { adapter: "Card".into(), driver: "Driver 1".into(), video_memory_mb: Some(8192), ..Default::default() }; + let lines = info.lines(); + assert_eq!(lines.get(2).map(String::as_str), Some("Driver: Driver 1")); + assert_eq!(lines.get(3).map(String::as_str), Some("Graphics memory: 8.0G")); + let none = GpuInfo { video_memory_mb: None, ..info }.lines(); + assert!(none.iter().all(|l| !l.starts_with("Graphics memory")), "{none:?}"); + assert_eq!(none.len() + 1, lines.len()); + } + /// The Linux reader finds the card by its PCI ids in a `/sys/class/drm`-shaped folder and reads /// amdgpu's VRAM size; a card without one, unreadable ids or a malformed size give nothing. #[test] diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..12392ac3af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3813,6 +3813,7 @@ navigator Zoom Zvětšení GPU Memory Full Paměť GPU je plná The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU. GPU memory budget: {budget} Rozpočet paměti GPU: {budget} + Graphics memory: {size} Paměť GPU: {size} This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..4017c5de01 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3207,6 +3207,7 @@ curves Smooth Glätten GPU Memory Full GPU-Speicher voll The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen. GPU memory budget: {budget} GPU-Speicherbudget: {budget} + Graphics memory: {size} Grafikspeicher: {size} This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..84afec75bd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3905,6 +3905,7 @@ curves Smooth Εξομάλυνση GPU Memory Full Η μνήμη GPU είναι πλήρης The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU. GPU memory budget: {budget} Όριο μνήμης GPU: {budget} + Graphics memory: {size} Μνήμη γραφικών: {size} This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..98bf653f6a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4167,6 +4167,7 @@ navigator Zoom Zoom GPU Memory Full Memoria de GPU llena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU. GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget} + Graphics memory: {size} Memoria gráfica: {size} This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..a11c3f88f8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3210,6 +3210,7 @@ curves Smooth Lisser GPU Memory Full Mémoire GPU pleine The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU. GPU memory budget: {budget} Budget de mémoire GPU : {budget} + Graphics memory: {size} Mémoire graphique : {size} This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..1578544d5d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4124,6 +4124,7 @@ cameraRaw Yellow Sárga GPU Memory Full GPU-memória megtelt The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához. GPU memory budget: {budget} GPU-memóriakeret: {budget} + Graphics memory: {size} Grafikus memória: {size} This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..da3249e869 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3197,6 +3197,7 @@ curves Smooth Haluskan GPU Memory Full Memori GPU Penuh The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Layer membutuhkan {needed} memori GPU dan boleh memakai {budget}, sehingga suntingan yang menggambar ulang seluruh dokumen melakukannya di CPU. Klik untuk mengatur Memori GPU. GPU memory budget: {budget} Anggaran memori GPU: {budget} + Graphics memory: {size} Memori grafis: {size} This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..694850b58b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4326,6 +4326,7 @@ shape Appearance Aspetto GPU Memory Full Memoria GPU piena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. I livelli richiedono {needed} di memoria GPU e possono occuparne {budget}, quindi le modifiche che ridisegnano tutto il documento lo fanno sulla CPU. Fai clic per impostare Memoria GPU. GPU memory budget: {budget} Budget di memoria GPU: {budget} + Graphics memory: {size} Memoria grafica: {size} This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..43c2ac3d13 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3881,6 +3881,7 @@ curves Smooth スムーズ GPU Memory Full GPU メモリ不足 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. レイヤーには {needed} の GPU メモリが必要ですが、使えるのは {budget} のため、ドキュメント全体を再描画する編集は CPU で行われます。クリックして GPU メモリを設定します。 GPU memory budget: {budget} GPU メモリ予算: {budget} + Graphics memory: {size} グラフィックメモリ: {size} This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..4125261c1e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3383,6 +3383,7 @@ curves Smooth 매끄럽게 GPU Memory Full GPU 메모리 부족 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 레이어에 GPU 메모리 {needed}가 필요하지만 {budget}만 사용할 수 있어 문서 전체를 다시 그리는 편집은 CPU에서 처리됩니다. 클릭하여 GPU 메모리를 설정합니다. GPU memory budget: {budget} GPU 메모리 예산: {budget} + Graphics memory: {size} 그래픽 메모리: {size} This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..a7d71ad5e2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3965,6 +3965,7 @@ curves Smooth Vloeiend maken GPU Memory Full GPU-geheugen vol The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. De lagen hebben {needed} GPU-geheugen nodig en mogen {budget} gebruiken, dus bewerkingen die het hele document opnieuw tekenen doen dat op de CPU. Klik om GPU-geheugen in te stellen. GPU memory budget: {budget} GPU-geheugenbudget: {budget} + Graphics memory: {size} Grafisch geheugen: {size} This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..f7971a728a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3803,6 +3803,7 @@ curves Smooth Wygładź GPU Memory Full Pamięć GPU pełna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Warstwy potrzebują {needed} pamięci GPU i mogą zająć {budget}, więc edycje, które odświeżają cały dokument, robią to na CPU. Kliknij, aby ustawić Pamięć GPU. GPU memory budget: {budget} Budżet pamięci GPU: {budget} + Graphics memory: {size} Pamięć graficzna: {size} This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..3e05f1fa19 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3995,6 +3995,7 @@ layerLabel No Color Sem cor GPU Memory Full Memória da GPU cheia The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. As camadas precisam de {needed} de memória da GPU e podem ocupar {budget}, então as edições que redesenham o documento inteiro fazem isso na CPU. Clique para ajustar Memória da GPU. GPU memory budget: {budget} Orçamento de memória da GPU: {budget} + Graphics memory: {size} Memória gráfica: {size} This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..5c08e1cfc5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3175,6 +3175,7 @@ curves Smooth Сгладить GPU Memory Full Память GPU заполнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Слоям нужно {needed} памяти GPU, а занимать они могут {budget}, поэтому правки, перерисовывающие весь документ, выполняются на CPU. Нажмите, чтобы задать «Память GPU». GPU memory budget: {budget} Бюджет памяти GPU: {budget} + Graphics memory: {size} Видеопамять: {size} This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..48b904e82d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4278,6 +4278,7 @@ shape Appearance Izgled GPU Memory Full GPU memorija je puna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevima treba {needed} GPU memorije, a smeju da zauzmu {budget}, pa izmene koje ponovo iscrtavaju ceo dokument to rade na CPU-u. Kliknite da podesite GPU memoriju. GPU memory budget: {budget} Budžet GPU memorije: {budget} + Graphics memory: {size} Grafička memorija: {size} This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..29a50129f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3935,6 +3935,7 @@ cameraData Lens Lens GPU Memory Full GPU Belleği Dolu The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Katmanların {needed} GPU belleğine ihtiyacı var ve en fazla {budget} kullanabilirler; bu yüzden tüm belgeyi yeniden çizen düzenlemeler bunu CPU'da yapar. GPU Belleği'ni ayarlamak için tıklayın. GPU memory budget: {budget} GPU bellek bütçesi: {budget} + Graphics memory: {size} Grafik belleği: {size} This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..c1187289e2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3904,6 +3904,7 @@ curves Smooth Згладити GPU Memory Full Пам'ять GPU заповнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Шарам потрібно {needed} пам'яті GPU, а займати вони можуть {budget}, тому зміни, що перемальовують увесь документ, виконуються на CPU. Натисніть, щоб задати «Пам'ять GPU». GPU memory budget: {budget} Бюджет пам'яті GPU: {budget} + Graphics memory: {size} Відеопам'ять: {size} This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..41aaf33a62 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3691,6 +3691,7 @@ curves Smooth Làm mượt GPU Memory Full Bộ nhớ GPU đầy The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Các lớp cần {needed} bộ nhớ GPU và chỉ được dùng {budget}, nên những chỉnh sửa vẽ lại toàn bộ tài liệu sẽ làm trên CPU. Nhấp để đặt Bộ nhớ GPU. GPU memory budget: {budget} Ngân sách bộ nhớ GPU: {budget} + Graphics memory: {size} Bộ nhớ đồ họa: {size} This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..f7f32d9d4f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2821,6 +2821,7 @@ curves Smooth 平滑 GPU Memory Full GPU 内存已满 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 图层需要 {needed} GPU 内存,但只能占用 {budget},因此重绘整个文档的编辑会在 CPU 上进行。点击以设置 GPU 内存。 GPU memory budget: {budget} GPU 内存预算:{budget} + Graphics memory: {size} 显存:{size} This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..abafec0980 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2806,6 +2806,7 @@ curves Smooth 平滑 GPU Memory Full GPU 記憶體已滿 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 圖層需要 {needed} GPU 記憶體,但只能佔用 {budget},因此重繪整份文件的編輯會在 CPU 上進行。按一下以設定 GPU 記憶體。 GPU memory budget: {budget} GPU 記憶體預算:{budget} + Graphics memory: {size} 顯示記憶體:{size} This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) From af5e77b15060d1bb6d8882886b3159f42b6b3f05 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:01:14 +0200 Subject: [PATCH 0361/1452] Auto-Align: warp layer masks as masks, keeping what lies outside revealed (#6776) Auto-Align warped a moving layer's mask with the colour warp (warp_placed), which returns a grey + alpha surface defaulting to 0, so a reveal-all mask hid everything outside its painted area. Masks now go through warp_gray (planar placements) or warp_mesh_gray (cylindrical/spherical), keeping one channel and the mask's default. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/align_cmds.rs | 42 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..6f6d058789 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -24,6 +24,7 @@ use photocraft_algo::panorama::Layout; use photocraft_algo::pyramid; use photocraft_algo::transform::Interp; +use photocraft_algo::warp::warp_mesh_gray; use photocraft_doc::{Document, Layer, LayerContent, LayerId, LayerMask}; use photocraft_geom::Rect; use photocraft_raster::{Surface, to_rgba}; @@ -107,11 +108,13 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { let surf = l.surface().ok_or(EngineError::NoLayer(ids[i]))?; let b = surf.content_bounds(); let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp)? }; + // Masks warp as masks: one grey channel whose outside keeps its default (reveal-all + // stays revealed), not as colour, which would come back grey + alpha reading 0 outside. let mask = match l.mask.as_ref() { - Some(m) => { - let mb = m.surface.content_bounds(); - Some(if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear)? }) - } + Some(m) => Some(match plane_matrix(pl, (0.0, 0.0)) { + Some(h) => crate::transform_cmds::warp_gray(&m.surface, &h, Interp::Bilinear)?, + None => warp_mesh_gray(&m.surface, &|x, y| pl.forward(x, y), Interp::Bilinear), + }), None => None, }; Ok((ids[i], px, mask)) @@ -384,6 +387,37 @@ mod tests { assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err()); } + /// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its + /// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps. + #[test] + fn auto_align_warps_layer_masks_keeping_their_default() { + for (projection, reveal) in [("reposition", true), ("reposition", false), ("cylindrical", true)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap(); + let a = texture_layer(&mut s, "A", 20, 20); + let b = texture_layer(&mut s, "B", 31, 13); + let (inside, outside) = if reveal { (0.0, 1.0) } else { (1.0, 0.0) }; + s.edit("mask", |doc, _| { + let mut m = if reveal { LayerMask::reveal_all() } else { LayerMask::hide_all() }; + m.surface.fill_rect(Rect::new(100, 100, 110, 110), &[inside]); + doc.layer_mut(b).unwrap().mask = Some(m); + Ok(()) + }) + .unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap(); + let doc = &s.active().unwrap().doc; + let m = doc.layer(b).unwrap().mask.as_ref().unwrap(); + assert_eq!(m.surface.format().channels(), 1, "{projection}: {r}"); + assert_eq!(m.value(250, 200), outside, "{projection} reveal={reveal}: {r}"); + assert_eq!(m.value(5, 5), outside, "{projection} reveal={reveal}: {r}"); + if projection == "reposition" { + // The square moved with the layer (by about (-11, +7)). + assert!((m.value(94, 112) - inside).abs() < 0.01, "{r}"); + } + } + } + #[test] fn auto_blend_panorama_masks_and_seamless_layer() { for depth in [8, 16, 32] { From 9ac2c088759ad78129af0d8ffc45e0bc6c8e1651 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:53:13 +0200 Subject: [PATCH 0362/1452] Actions: renaming an action updates the Conditional steps that call it (#6772) actions.rename only rewrote the "action" param of actions.play steps, so an actions.conditional step whose "then"/"else" named the renamed action failed with "no action named ..." on the next play. The rename loop now also rewrites matching then/else branches, in saved actions and the pending recording. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/actions_cmds.rs | 39 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..a02163c623 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -519,8 +519,9 @@ fn toggle(s: &mut Session, p: &Value) -> Result { /// Prefix of the persisted shortcut override that plays a named action (`actions.play:`). pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:"; -/// Rename an action in place. Nested `actions.play` steps that call it by name, and its -/// function-key binding, follow the new name so nothing silently stops working. +/// Rename an action in place. Nested `actions.play` steps and `actions.conditional` branches +/// that call it by name, and its function-key binding, follow the new name so nothing silently +/// stops working. fn rename(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.rename")?; let idx = resolve(&s.actions.list, p, "actions.rename")?; @@ -540,8 +541,15 @@ fn rename(s: &mut Session, p: &Value) -> Result { let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start); let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); for (id, params) in calls { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(old.as_str()) { - params["action"] = json!(name); + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if params.get(*key).and_then(Value::as_str) == Some(old.as_str()) { + params[*key] = json!(name); + } } } if let Some(action) = s.actions.list.get_mut(idx) { @@ -769,6 +777,29 @@ mod tests { assert_eq!(s.actions.list[0].name, "Sepia"); } + #[test] + fn rename_follows_conditional_branches() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for name in ["B", "Other"] { + s.actions.list.push(Action { name: name.into(), steps: vec![("layer.new.layer".into(), json!({}))], ..Default::default() }); + } + s.actions.list.push(Action { + name: "Main".into(), + steps: vec![ + ("actions.conditional".into(), json!({"if": "always", "then": "B", "else": "Other"})), + ("actions.conditional".into(), json!({"if": "never", "then": "Other", "else": "B"})), + ], + ..Default::default() + }); + s.execute("actions.rename", json!({"action": "B", "name": "C"})).unwrap(); + let steps = &s.actions.list[2].steps; + assert_eq!(steps[0].1, json!({"if": "always", "then": "C", "else": "Other"})); + assert_eq!(steps[1].1, json!({"if": "never", "then": "Other", "else": "C"})); + let r = s.execute("actions.play", json!({"action": "Main"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + } + fn record_red(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); s.execute("actions.record", json!({"name": "Red"})).unwrap(); From 9b6a85824d3ccd0bddd29a3a0b71a542c9bd1c30 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Fri, 16 Oct 2026 16:13:44 -0500 Subject: [PATCH 0363/1452] Mesh warp: tiles encoded in parallel and in-bounds taps read directly, about twice as fast with identical pixels --- crates/algo/src/warp.rs | 60 ++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 188de07d47..54a6c7c868 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -14,8 +14,9 @@ //! later (lower-right) cell wins. Work is split per destination tile (parallel on native). use photocraft_color::PixelFormat; -use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_geom::{Rect, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -207,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let n = fmt.channels(); let a = n - 1; - let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec)> { + let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> { if tris.is_empty() { return None; } @@ -296,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) } }; + // Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so + // the samplers can index `px` directly instead of bounds-checking every tap. + let base = |x: i32, y: i32, taps: i32| -> Option { + (x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n) + }; let mut outp = vec![0.0f32; w * h * n]; let mut any = false; let mut acc = [0.0f64; 8]; @@ -313,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 Interp::Bilinear => { let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (fx, fy) = (u - f64::from(ix), v - f64::from(iy)); + if let Some(o) = base(ix, iy, 2) { + for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { + for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { + let p = &px[o + (dy * fw + dx) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * wx * wy; + } + } + } + return; + } for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { @@ -327,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let (u, v) = (snap(u), snap(v)); let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy))); + if let Some(o) = base(ix - 1, iy - 1, 4) { + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let k = wxi * wyj; + if k == 0.0 { + continue; + } + let p = &px[o + (j * fw + i) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * k; + } + } + } + return; + } for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; @@ -378,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } outp[b + a] = al as f32; } - any.then_some((*t, outp)) + if !any { + return None; + } + // Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at + // a time once all are done. `t` lies in one tile. + let mut one = Surface::new(fmt); + one.write_region(*t, &outp); + one.prune(); + if one.tile_count() == 0 { + return None; + } + one.take_tiles(*t).pop() }; #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { + let done: Vec<(TileCoord, Arc)> = { use rayon::prelude::*; tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect() }; #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); - } - out.prune(); + let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + out.put_tiles(done); out } From 031ab7d5b2ab0d920e933fe60f50ac37eab354d2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:18:53 +0200 Subject: [PATCH 0364/1452] Apply Image: write into the targeted layer mask (#6780) With the layer mask targeted, apply_image took the layer branch and fetched the layer's pixel surface with the target dropped, so the mask stayed unchanged and the pixels were overwritten. A mask target now goes through the single-plane path and is written back over the canvas, keeping the mask's default outside it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/channel_cmds.rs | 16 +++++++++++++--- crates/engine/src/channel_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 468db2698b..9c052290ab 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1669,14 +1669,24 @@ fn apply_image(s: &mut Session, p: &Value) -> Result { let area = doc.bounds(); let selection = doc.selection.as_ref().map(|m| sel::mask_from_surface(Some(m), area)); let weight = |i: usize| opacity * mask.as_ref().map_or(1.0, |m| m[i]) * selection.as_ref().map_or(1.0, |m| m[i]); - if is_channel_target(p) { + let mask_target = target_of(p) == Target::Mask; + if is_channel_target(p) || mask_target { // A grayscale target gets the luminosity of a colour source (one plane). let srcp = if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::() / src.len() as f32).collect() }; - let (surf, _) = target_surface(doc, None, p)?; + let (surf, _) = target_surface(doc, layer, p)?; let base = read_plane(surf, area); let out: Vec = base.iter().enumerate().map(|(i, &b)| b + (blending.apply(b, srcp[i]) - b) * weight(i)).collect(); - *surf = plane_surface(&out, area, surf.format()); + if mask_target { + // A layer mask keeps its default (reveal or hide) outside the canvas. + if surf.channels() != 1 { + return Err(EngineError::Other("the layer mask is not a grayscale surface".into())); + } + surf.write_region(area, &out); + surf.prune(); + } else { + *surf = plane_surface(&out, area, surf.format()); + } return Ok(()); } let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index d63dc5fd0d..3c23862326 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -592,6 +592,25 @@ fn apply_image_into_alpha_channel() { assert!(chan(&s, 0, 3, 3) < 0.01); } +#[test] +fn apply_image_into_targeted_layer_mask_leaves_the_pixels() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + // The green channel of the red composite is black: it lands in the mask, not the layer. + s.execute("image.applyImage", json!({"source": {"channel": "green"}, "blending": "normal"})).unwrap(); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let m = &l.mask.as_ref().unwrap().surface; + assert!(m.sample_channel(3, 3, 0) < 0.01, "mask {}", m.sample_channel(3, 3, 0)); + // Outside the canvas the mask keeps revealing. + assert!(m.sample_channel(-5, -5, 0) > 0.99, "mask outside {}", m.sample_channel(-5, -5, 0)); + let p = px(&s, 3, 3); + assert!(p[0] > 0.99 && p[1] < 0.01 && p[2] < 0.01 && p[3] > 0.99, "layer pixels {p:?}"); +} + #[test] fn calculations_results() { let mut s = session(); From 0fc5660f6021366e95c76d1566ea3be8ef0e444c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:19:40 +0200 Subject: [PATCH 0365/1452] Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map_fill multiplied the offset by the full linear part of the canvas transform, so Image Size (and Perspective Crop) scaled it: 200% moved a 25% offset to 50%. Each axis of the linear part is now normalised, so scale drops out while flips and 90° turns still mirror and turn it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/canvas_geom.rs | 10 ++++++-- crates/engine/src/canvas_geom/tests.rs | 33 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0722c6bd10..76142f9a75 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -55,9 +55,15 @@ fn map_fill(a: &Affine, f: &mut Fill) { if let Fill::Gradient { angle, offset, .. } = f { *angle = map_angle(a, *angle); // The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns - // and flips, where the frame's sides swap with the axes. + // and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the + // frame too, so only each axis's direction applies. + let unit = |x: f64, y: f64| { + let n = x.hypot(y); + if n < EPS { (x, y) } else { (x / n, y / n) } + }; + let ((m0, m1), (m2, m3)) = (unit(a.m[0], a.m[1]), unit(a.m[2], a.m[3])); let (ox, oy) = (f64::from(offset.0), f64::from(offset.1)); - *offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32); + *offset = ((m0 * ox + m2 * oy) as f32, (m1 * ox + m3 * oy) as f32); } } diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 804a18b665..f27d9756fd 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -188,6 +188,39 @@ fn gradient_fill_angle_follows_the_canvas() { assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0); } +/// The gradient fill's centre offset after `cmd`, as `gradient.fill.get` reports it (% of the frame). +fn fill_offset_after(cmd: &str, p: serde_json::Value) -> (f64, f64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("gradient.fill.create", json!({"from": [10, 50], "to": [90, 50], "style": "radial"})).unwrap(); + s.execute("gradient.fill.set", json!({"angle": 0, "offset": [25, 0]})).unwrap(); + s.execute(cmd, p).unwrap(); + let g = s.execute("gradient.fill.get", json!({})).unwrap(); + (g["offset"][0].as_f64().unwrap(), g["offset"][1].as_f64().unwrap()) +} + +#[test] +fn gradient_fill_offset_stays_a_fraction_of_the_frame() { + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + // Image Size scales the frame, so the offset (a fraction of it) stays put. + for (w, h) in [(200, 200), (50, 50), (200, 50)] { + let o = fill_offset_after("image.imageSize", json!({"width": w, "height": h})); + assert!(near(o, (25.0, 0.0)), "{w}x{h}: {o:?}"); + } + // Flips and 90° turns still mirror and turn it. + assert!(near(fill_offset_after("image.imageRotation.flipCanvasHorizontal", json!({})), (-25.0, 0.0))); + assert!(near(fill_offset_after("image.imageRotation.90cw", json!({})), (0.0, 25.0))); + assert!(near(fill_offset_after("image.imageRotation.180", json!({})), (-25.0, 0.0))); + // Non-uniform scale with a turn: only the axis swap applies. + let mut f = Fill::gradient(Vec::new(), 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); + if let Fill::Gradient { offset, .. } = &mut f { + *offset = (0.25, -0.1); + } + map_fill(&Affine { m: [0.0, 3.0, -0.5, 0.0, 7.0, 1.0] }, &mut f); + let Fill::Gradient { offset, .. } = f else { panic!("not a gradient") }; + assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}"); +} + fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) { let mut t = None; let mut k = None; From ec83a1117f9fb3c0522670e8642766b26ba79fe1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:20:35 +0200 Subject: [PATCH 0366/1452] Presets: refuse renaming a group to a name another group has (#6768) renameGroup in the shared preset group editor renamed without checking that the new name was free, so two groups could share a name and a later replace import of that name removed both. It now refuses like the Swatches sibling; renaming a group to its own name stays fine. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/presets/mod.rs | 3 +++ crates/engine/src/presets/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 6c722c62ef..236c2a1328 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -343,6 +343,9 @@ pub(crate) fn edit_groups(groups: &mut Vec>, action: &str, p: let g = req_str(p, "group", cmd)?; let to = req_str(p, "name", cmd)?.to_string(); let i = groups.iter().position(|x| x.name == g).ok_or_else(|| bad(cmd, format!("no group \"{g}\"")))?; + if g != to && groups.iter().any(|x| x.name == to) { + return Err(bad(cmd, format!("a group named \"{to}\" already exists"))); + } groups[i].name = to.clone(); Ok(json!({"group": to})) } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..8d95cdb51e 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -317,6 +317,23 @@ fn gradient_new_rename_move_delete_groups() { assert!(s.execute("gradient.presets.edit", json!({"action": "explode"})).is_err()); } +#[test] +fn gradient_rename_group_refuses_a_name_another_group_has() { + let mut s = Session::new(); + s.execute("gradient.presets.edit", json!({"action": "newGroup", "name": "Pastels"})).unwrap(); + let names = |s: &mut Session| -> Vec { + s.execute("gradient.presets.list", json!({})).unwrap()["groups"].as_array().unwrap().iter().map(|g| g["name"].clone()).collect() + }; + let before = names(&mut s); + assert!(before.contains(&json!("Blues"))); + let err = s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Blues"})).unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(names(&mut s), before, "a refused rename leaves the groups unchanged"); + // Renaming a group to its own name is fine. + s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Pastels"})).unwrap(); + assert_eq!(names(&mut s), before); +} + #[test] fn presets_persist_through_preferences_json() { let mut s = Session::new(); From 8c6412bbb21c0abb7593dd5b13b4a39fed4925c4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:49:26 +0200 Subject: [PATCH 0367/1452] Plug-ins: running a filter plug-in respects layer locks (#6770) plugin.run wrote into raster and smart object layers without checking locks. It now refuses layers with locked pixels or Lock All like the built-in filters, and with Lock Transparency it keeps the layer's alpha (as built-in filters do, via filters::keep_alpha). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/filters.rs | 2 +- crates/engine/src/plugin_cmds.rs | 7 ++++ crates/engine/src/plugin_cmds/tests.rs | 50 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index e40be7b6ac..221709080e 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -313,7 +313,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } /// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed. -fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { +pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { if old.format() != new.format() || !new.format().alpha { return; } diff --git a/crates/engine/src/plugin_cmds.rs b/crates/engine/src/plugin_cmds.rs index 5c7d2dd71a..a2044f5e8c 100644 --- a/crates/engine/src/plugin_cmds.rs +++ b/crates/engine/src/plugin_cmds.rs @@ -77,10 +77,17 @@ fn run(s: &mut Session, p: &Value) -> Result { *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; return Ok(()); } + crate::commands::check_pixels_unlocked(doc, layer)?; + let keep_alpha = doc.effective_locks(layer).transparency; let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; match &mut l.content { LayerContent::Raster(surf) => { + // Lock Transparency: the plug-in may change colour but not alpha (as built-in filters). + let before = keep_alpha.then(|| surf.clone()); *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + if let Some(before) = &before { + crate::filters::keep_alpha(before, surf); + } Ok(()) } LayerContent::Smart(_) => { diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs index 3d178fada9..39bb1acc7d 100644 --- a/crates/engine/src/plugin_cmds/tests.rs +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -155,6 +155,56 @@ fn params_are_validated_and_passed() { Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap(); } +#[test] +fn locked_layers_refuse_plugin_filters() { + // Like built-in filters: locked pixels (or Lock All) refuse the run with no undo step. + let id = "test.cmds.locks"; + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", GREY))})).unwrap(); + for lock_all in [false, true] { + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + let l = doc.layer_mut(layer).unwrap(); + if lock_all { + l.locks.all = true; + } else { + l.locks.pixels = true; + } + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + let r = s.execute("plugin.run", json!({"id": id})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("is locked")), "lock_all {lock_all}: {r:?}"); + assert_eq!(pixels(&s), before, "lock_all {lock_all}"); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!l.locks.pixels && !l.locks.all, "the refused run left no undo step"); + // Unlocked, the plug-in runs. + s.execute("plugin.run", json!({"id": id})).unwrap(); + assert_ne!(pixels(&s), before); + } + // Lock Transparency: the run changes colour but keeps every pixel's alpha (GREY sets alpha too). + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(layer).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + s.execute("plugin.run", json!({"id": id})).unwrap(); + let after = pixels(&s); + assert_ne!(after, before); + for (a, b) in after.as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "alpha kept"); + if b[3] > 0.0 { + assert!((a[0] - 0.5).abs() < 1e-3, "colour changed: {a:?}"); + } + } + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); +} + #[test] fn hostile_plugins_fail_without_touching_the_document() { for (id, body) in [ From 2e8d20ca9cd0b9cd2cd4f13151728d20c2ac5879 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 00:24:53 +0300 Subject: [PATCH 0368/1452] Fix rustfmt on snap_ui smart-target assignment. CI Format step failed on a line break rustc-fmt does not want. Co-authored-by: Cursor --- crates/ui-egui/src/snap_ui.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index b0cb95e2fc..ccdd4fea88 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -228,8 +228,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } - let smart = - if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; + let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } From 606b2dadbabaf8ea4efd026e6caddf4880a2f37a Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 17:26:04 -0400 Subject: [PATCH 0369/1452] Actions: a Deselect step with nothing selected plays on instead of stopping (#6744) Photoshop treats Deselect with no selection as a silent no-op, so imported .atn actions that start with Deselect stopped at step 1 here. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/commands.rs | 4 ++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..b87d78c21c 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -307,6 +307,12 @@ fn stop(s: &mut Session, _p: &Value) -> Result { Ok(json!({"action": action.name, "steps": action.steps.len()})) } +/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an +/// open document and nothing selected (actions often start with it to clear any selection, #6744). +fn noop_step(s: &Session, id: &str) -> bool { + id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none()) +} + fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { let mut ran = 0u64; let mut failed = None; @@ -317,6 +323,10 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option { if let Some(error) = nested_failure(id, &value) { @@ -692,6 +702,23 @@ mod tests { s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y) } + #[test] + fn deselect_with_nothing_selected_is_a_noop_in_playback() { + // #6744: imported actions often start with Deselect; with no selection Photoshop plays on. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let before = s.active().unwrap().history.past_len(); + let deselect = crate::commands::find("select.deselect").unwrap(); + assert_eq!((deselect.run)(&mut s, &json!({})).unwrap(), Value::Null); + assert_eq!(s.active().unwrap().history.past_len(), before, "no history step"); + + let steps = vec![("select.deselect".to_string(), json!({})), ("layer.new.layer".to_string(), json!({"name": "A"}))]; + s.actions.list.push(Action::new("Clear", steps)); + let played = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(played["ran"], 2, "{played}"); + assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "A"); + } + #[test] fn unchecked_steps_are_skipped_and_follow_move_and_delete() { let mut s = Session::new(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..f74388af5d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -555,6 +555,10 @@ fn build() -> Vec { Ok(Value::Null) }), cmd!("select.deselect", "Deselect", ["Select"], Some("Cmd+D"), "{}", has_selection, |s, _| { + // Nothing selected: nothing to do and no history step, as in Photoshop (#6744). + if s.active().is_none_or(|d| d.doc.selection.is_none()) { + return Ok(Value::Null); + } s.edit("Deselect", |doc, _| { doc.selection = None; Ok(()) From 3afb0b1283b2226993bcd71f1035fea4d430c115 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:33:20 +0200 Subject: [PATCH 0370/1452] PSD: layered TIFF byte-order conversion swaps every smart filter section (#6802) Tx::filter_effects converted only the first u64-length section of FEid/FXid and copied the rest as stored, so a second smart object's section kept the source byte order and FilterEffects::parse lost the whole block. It now mirrors FilterEffects::parse: one section per smart object, each with its 4-byte padding, until only trailing zero padding remains. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/psd/src/tiff.rs | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 31c85d4885..ead66e7439 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -1410,9 +1410,27 @@ pub mod transcode { }) } - /// `FEid` / `FXid`: version, u64 length, items with per-channel planes (4-byte RLE counts). + /// `FEid` / `FXid`: version, then one section per smart object (u64 length, items with + /// per-channel planes and 4-byte RLE counts), each padded to 4 bytes. fn filter_effects(&mut self) -> Result<()> { self.u32()?; + loop { + let len = self.r.clone().len_field(true)?; + self.filter_effects_section()?; + // Mirrors `FilterEffects::parse`: the last section's padding may be cut short. + let pad = usize::try_from((4 - len % 4) % 4).unwrap_or(0).min(self.remaining()); + self.raw(pad)?; + let rest = self.r.peek_rest(); + if rest.len() <= 3 && rest.iter().all(|&b| b == 0) { + return Ok(()); + } + if rest.len() < 8 { + return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len()))); + } + } + } + + fn filter_effects_section(&mut self) -> Result<()> { self.sized_region(true, |t| { while !t.r.is_empty() { t.pascal(1)?; @@ -2052,6 +2070,39 @@ mod tests { assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); } + /// Photoshop writes one `FEid` section per smart object: every section's lengths and + /// numbers are swapped, not only the first one's. + #[test] + fn filter_effects_block_swaps_every_section() { + use crate::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; + let rect = crate::layer::Rect { top: 0, left: 0, bottom: 2, right: 3 }; + let item = |id: &str, v: u8| FilterEffectsItem { + id: id.into(), + version: 1, + rect, + depth: 8, + max_channels: 1, + slots: vec![Some(EffectsPlane::encode(Compression::Raw, &[v; 6], 3, 2, 8).unwrap()), None, None], + mask: None, + }; + let fx = FilterEffects { version: 1, items: vec![item("first", 1), item("second-object", 2)] }; + let data = fx.to_bytes(); + let len1 = u64::from_be_bytes(data[4..12].try_into().unwrap()) as usize; + let second = 12 + len1 + (4 - len1 % 4) % 4; + let len2 = &data[second..second + 8]; + block_rt(*b"FEid", data.clone()); + let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"FEid", data.clone())], ..Default::default() }; + let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); + // 36-byte signature, then `MIB8`, the reversed key and a u32 length: the block data. + let body = &little[36 + 12..]; + let mut swapped = len2.to_vec(); + swapped.reverse(); + assert_eq!(&body[second..second + 8], &swapped[..], "second section's length is little-endian"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); + } + #[test] fn metadata_setting_items() { let desc = VersionedDescriptor::new(Descriptor::new("null")).to_bytes(); From 549b4387b15fbb18dddb22cdc8a76c02635fb058 Mon Sep 17 00:00:00 2001 From: Ahmed Tammam Date: Sat, 17 Oct 2026 00:34:36 +0300 Subject: [PATCH 0371/1452] docs(contributors): add Gotix-eg to people.toml --- contributors/people.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..eab377f81f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,9 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.Gotix-eg] +real_name = "Ahmed Tammam" +display_name = "Gotix" +sync_github_name = true + From 257f0a0d313aa4b3f8b9de017817926d0225680d Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 17:34:59 -0400 Subject: [PATCH 0372/1452] GPU canvas: a layer that doesn't fit GPU memory evicts and retries instead of breaking the GPU (#6563) Compositor::sync created each pc_compose_layer page texture with a bare create_texture: nothing checked the new bytes against the resident budget (eviction ran only after each cell) and an Out of Memory error went uncaptured, invalidating every later texture in the pass. Now least recently used pages are evicted to admit the new page's bytes before creating it; creation runs in error scopes (first_error) on native, and on Out of Memory every page the current cell doesn't use is dropped and creation is retried once. If it still fails the refresh returns Unsupported(OVER_BUDGET) so that refresh draws on the CPU and the next one tries the GPU again; the captured error no longer reaches the health callbacks. The LRU admission logic is a pure function with a unit test. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 122 ++++++++++++++++++++++++++----- crates/ui-egui/src/gpu_canvas.rs | 5 +- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 690d144d68..a7699d8dd3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -461,6 +461,8 @@ pub struct Compositor { staged: u64, /// Copies and render passes recorded since the last submit. recorded: u32, + /// Why a layer page texture couldn't be created this refresh (it then ends on the CPU). + layer_alloc: Option, acc_format: wgpu::TextureFormat, /// Whether the effect-map pipelines could be built (else documents with layer effects are /// [`Unsupported`] and use the CPU compositor). @@ -569,6 +571,25 @@ fn field_reaches(progs: &[fx::MapProgram]) -> HashMap { want } +/// Why a refresh whose layer pages don't fit GPU memory is drawn on the CPU. +pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU"; + +/// Which of `pages` (LRU clock, key, bytes) to drop, least recently used first, so `incoming` +/// more bytes fit `budget` on top of the `total` held now. Callers leave out pages that must stay. +fn lru_victims(mut pages: Vec<(u64, K, u64)>, total: u64, incoming: u64, budget: u64) -> Vec { + pages.sort_unstable_by_key(|p| p.0); + let mut total = total; + let mut out = Vec::new(); + for (_, k, bytes) in pages { + if total.saturating_add(incoming) <= budget { + break; + } + total = total.saturating_sub(bytes); + out.push(k); + } + out +} + /// Runs `f` inside GPU error scopes and returns the first error raised. /// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none /// is reported. @@ -700,6 +721,7 @@ impl Compositor { stamp: 0, staged: 0, recorded: 0, + layer_alloc: None, acc_format, effect_maps: map_error.is_none(), cmyk: 0, @@ -1067,6 +1089,7 @@ impl Compositor { self.stamp += 1; self.staged = 0; self.recorded = 0; + self.layer_alloc = None; // Only render owns submission. A caller may discard encode's command buffer, so it // cannot establish (or consume) a checkpoint whose GPU copy might never execute. @@ -1120,6 +1143,11 @@ impl Compositor { if let Some(f) = self.fault() { return Err(Unsupported(f.to_string())); } + // A page didn't fit even after evicting: this refresh goes to the CPU, the next one + // tries the GPU again (#6563). + if let Some(e) = self.layer_alloc.take() { + return Err(Unsupported(e)); + } let (evicted, bytes) = self.evict(); stats.evicted += evicted; freed += bytes; @@ -1175,22 +1203,7 @@ impl Compositor { /// whole-region effect maps drawn this frame. Returns how many textures were dropped and /// their bytes. fn evict(&mut self) -> (usize, u64) { - let (mut n, mut freed) = (0, 0u64); - let mut total = self.resident_bytes(); - if total > self.resident_budget { - let mut lru: Vec<(u64, ResKey, u64)> = - self.residents.iter().filter(|(_, r)| r.stamp != self.stamp).map(|(k, r)| (r.stamp, *k, r.bytes())).collect(); - lru.sort_unstable_by_key(|c| c.0); - for (_, k, bytes) in lru { - if total <= self.resident_budget { - break; - } - self.residents.remove(&k); - total = total.saturating_sub(bytes); - n += 1; - freed += bytes; - } - } + let (mut n, mut freed) = self.evict_residents(self.resident_budget, 0, None); let mut total = self.fx_cache_bytes(); let fx_budget = usize::try_from(self.fx_budget).unwrap_or(usize::MAX); if total > fx_budget { @@ -1216,6 +1229,55 @@ impl Compositor { (n, freed) } + /// Drop least recently used resident pages until `incoming` more bytes fit in `budget`. + /// Never drops what the current cell uses, nor `keep`. Returns how many pages were dropped + /// and their bytes. + fn evict_residents(&mut self, budget: u64, incoming: u64, keep: Option) -> (usize, u64) { + let stamp = self.stamp; + let pages: Vec<(u64, ResKey, u64)> = + self.residents.iter().filter(|(k, r)| r.stamp != stamp && Some(**k) != keep).map(|(k, r)| (r.stamp, *k, r.bytes())).collect(); + let (mut n, mut freed) = (0, 0u64); + for k in lru_victims(pages, self.resident_bytes(), incoming, budget) { + if let Some(r) = self.residents.remove(&k) { + n += 1; + freed += r.bytes(); + } + } + (n, freed) + } + + /// Create the page texture for `key`, `bytes` large. Least recently used pages make room in + /// the budget first; when the device is still out of memory, every page the current cell + /// doesn't use is dropped and creation is tried once more (#6563). `None` (with + /// `layer_alloc` set) when it still fails: the refresh ends and the CPU draws it. + fn create_layer_texture(&mut self, device: &wgpu::Device, desc: &wgpu::TextureDescriptor<'_>, key: ResKey, bytes: u64) -> Option { + let replaced = self.residents.get(&key).map_or(0, Resident::bytes); + self.evict_residents(self.resident_budget, bytes.saturating_sub(replaced), Some(key)); + // WebGPU reports errors asynchronously: its failures reach the health callbacks. + if cfg!(target_arch = "wasm32") { + return Some(device.create_texture(desc)); + } + for retry in [false, true] { + let (texture, error) = first_error(device, || device.create_texture(desc)); + match error { + None => return Some(texture), + Some(wgpu::Error::OutOfMemory { .. }) if !retry => { + self.evict_residents(0, 0, Some(key)); + } + Some(wgpu::Error::OutOfMemory { .. }) => { + self.layer_alloc = Some(OVER_BUDGET.into()); + return None; + } + Some(e) => { + self.layer_alloc = Some(format!("GPU layer texture failed: {e}")); + return None; + } + } + } + self.layer_alloc = Some(OVER_BUDGET.into()); + None + } + /// Per-frame resources of `plan`: the tile area of each pass's surfaces, its LUT and pattern. fn frame_res(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, canvas: Rect, plan: &Plan<'_>) -> FrameRes { let grid = tile_grid(canvas); @@ -1314,6 +1376,10 @@ impl Compositor { .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); + // A page couldn't be created: draw nothing with the pages missing (the caller ends the refresh). + if self.layer_alloc.is_some() { + return; + } // Chunk pool. while self.pool.len() < plan.slots as usize { @@ -1503,7 +1569,8 @@ impl Compositor { region: Rect, stats: &mut Stats, ) -> Option<(ResKey, [i32; 4])> { - if region.is_empty() { + // An earlier page of this cell didn't fit: the refresh ends after the cell anyway. + if region.is_empty() || self.layer_alloc.is_some() { return None; } let format = surface.format(); @@ -1516,7 +1583,7 @@ impl Compositor { .get(&key) .is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk || r.default_bits != default_bits); if stale { - let texture = device.create_texture(&wgpu::TextureDescriptor { + let desc = wgpu::TextureDescriptor { label: Some("pc_compose_layer"), size: wgpu::Extent3d { width: region.width(), height: region.height(), depth_or_array_layers: 1 }, mip_level_count: 1, @@ -1525,7 +1592,9 @@ impl Compositor { format: kind.format(), usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], - }); + }; + let bytes = region.width() as u64 * region.height() as u64 * kind.bytes_per_pixel() as u64; + let texture = self.create_layer_texture(device, &desc, key, bytes)?; let view = texture.create_view(&Default::default()); let default_nonzero = default_pixel.iter().any(|v| *v != 0.0); let mut r = Resident { @@ -2440,6 +2509,21 @@ pub fn render_to_vec_stats( mod tests { use super::*; + #[test] + fn layer_pages_are_admitted_by_evicting_least_recently_used_first() { + let pages = || vec![(5, 'c', 40), (1, 'a', 30), (3, 'b', 20)]; + // Fits already: nothing dropped. + assert!(lru_victims(pages(), 90, 10, 100).is_empty()); + // 90 held + 40 incoming over 100: the oldest page goes first, then the next. + assert_eq!(lru_victims(pages(), 90, 40, 100), vec!['a']); + assert_eq!(lru_victims(pages(), 90, 60, 100), vec!['a', 'b']); + // Out of memory: a zero budget drops every candidate, and an impossible request stops + // when none are left instead of looping or overflowing. + assert_eq!(lru_victims(pages(), 90, 0, 0), vec!['a', 'b', 'c']); + assert_eq!(lru_victims(pages(), u64::MAX, u64::MAX, 1), vec!['a', 'b', 'c']); + assert!(lru_victims(Vec::<(u64, char, u64)>::new(), 90, 40, 100).is_empty()); + } + #[test] fn uniform_buffer_sizes_are_checked_before_allocation() { assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 012bcf801c..2f21b4b9cb 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1261,8 +1261,9 @@ impl LayerFit { /// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers. pub const MIN_GPU_BUDGET: u64 = 512 << 20; -/// Why a full refresh whose layer pages don't fit [`memory_budget`] goes to the CPU compositor. -pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU"; +/// Why a full refresh whose layer pages don't fit [`memory_budget`] (or GPU memory) goes to the +/// CPU compositor. +pub const OVER_BUDGET: &str = photocraft_gpu::OVER_BUDGET; /// GPU memory the wgpu compositor may hold for layer pages and effect maps. /// From cd75fa78efe47736d90542006f52e922eaeef416 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 17:38:42 -0400 Subject: [PATCH 0373/1452] PSD/PSB: a 16/32-bit file's "Transparency" channel stays the merged transparency on re-save (#4654) Photoshop's 16-bit PSBs can mark the merged transparency only by naming it "Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16). The importer read it as a user alpha channel, and the next save wrote a second one. merged_has_alpha now also accepts that name when the layers sit in a global Lr16/Lr32 block and the names cover every extra channel. Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/psd16_merged_transparency.rs | Bin 0 -> 3511 bytes crates/io/tests/psd32_export.rs | 4 +-- crates/psd/src/file.rs | 36 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 crates/io/tests/psd16_merged_transparency.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs new file mode 100644 index 0000000000000000000000000000000000000000..3f04970626e7f21e776f84241be0ee3a46d66a99 GIT binary patch literal 3511 zcmb7HZExC05Wdg+iV2lc(^3OTiYm9X(!EqV>8e&q@4n=av)D^)#oqOK*G;&P|9;Qx z+CWIs>QJP>!o2LvGtV;~jSlE@B~`92NvM=+`O1iI?h}2KRLP2RL#3#%aI96aqQ$B=LV(;FQlgY}C1qvEW|ERjC?yL@ zbfawKj{0ABJoPuffJPd8>Z6V@3jd1DE8LfSRV zVNFIR8h5ohQVO1(OkbZ2LiXc)ZElS~lc~G(m$RQKSI%24D_vX4grTKw>Mooa!mZ0M zN1#HikdM0>+gJj|3Y}cR42ftZ_p;||4e5SGLOR_v2c5XCwUy`x z;$mQ#;>ISmSlT$zg|4IM_80#DNvDW)d9#v4C9ITPlY-6EJ8aI&1?B=+%4r zOD6BmYz?5lr`sOkRY*%`x|BgGtQgSDK|SoEm=M^hgF(2=RT`}8HPvCB(ro6#hoz|8 z>0upuds(3Px0ol})QZDJk&@TJfKb#fT!}(XEgR_*BQ?qPF~Upy_*ro z2@9#<)8C+Tt6-rI*)j-=mj0k!z63el?jdpzAnj{V6~smd2&9U4=FjH z%;$qa2qYE&PB7R;Ms32XFLFgs@i0RZBgj2&~uBIdzF5T>};6JWec zfSX<{bxotPA?Zj zf3ZirE=z_h{DxJ2Ba7IlwA)dR&)++SG`_i>nS#E_L?sa(UWTB$#QGd2L&D$dA)O$T z<015>k-OdOt^_hLh~5r)2R4WNLwUoj9ULO`&=q$`hrhYA&y~%!GEtPurDzJkZxG_w z48*>->JLIUKihMNoss)h6$Tn)WBJcPz?p=Z6sfE+2~Ovr4<~5iE%)j!0x1_P9xor$ zP7bqegWzxxUdh#v7X44QR()E#cav%N?)a#`k0Rf~gWv!W z+BBf^u}dWY_%`js;*s%+#5`aiHMEj;9}ip>CX1Fd-!NyMj~h(X*!@k8=OL4C-0BK| zf;-$({Ga+EmMREf>?n`Lrw|tGQw5GrVce1Xhr66D1O0ke(#>V3*isZy} zZ?sX4tqaG;I|~HpDT;nC#icxL1-Ttts50P)3OrTjA61#r%JyxUx8S|Eay}RrF1*G@ ziyd@EIhIBYKJy?(9uc`_juW=fb88{Mu)(4?0v16R?V`h#>|Pvz7f#>Vm>cWkZHB$u z72Hw)@k>I!2ZJ7J#0>L@*0J{vfb2cYP3%^hJ>l)YSyNu~?_CH7y4-ilBCU)l^FlU?8_sJ9cI~3V%dQ5jR<;ROBo-7`l$~K6Ta^@`V zjR_9h<2~$mExBjZ^%r|3FNJHhrWaVcW_t@e_Zd$&*a@($LBn?VgVLrd@R-+k&q S&+Y|+i0|IO?>(); file.image_data.data = [plane([0.0, 1.0]), plane([0.0, 1.0]), plane([0.0, 1.0]), plane([1.0, 0.0])].concat(); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); - file.resources.retain(|r| r.id != 1045); - file.resources.push(photocraft_psd::ImageResource::new(1045, name)); + file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); assert!(!file.merged_has_alpha(), "a positive layer count alone"); file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); assert!(file.merged_has_alpha()); + file.resources.push(photocraft_psd::ImageResource::new(1045, name)); let merged = merged_composite(&file).unwrap(); assert_eq!(merged.iter().map(|p| p[3]).collect::>(), vec![1.0, 0.0]); let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index e14bc45aaf..fab262732b 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -354,7 +354,10 @@ impl PsdFile { /// `true` if the merged image carries a transparency channel: its first extra channel, when /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive - /// layer count (psd-tools transparentbg.psd). + /// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency + /// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit + /// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that + /// name with one name per extra channel marks it too. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -364,11 +367,40 @@ impl PsdFile { } let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); match &self.layer_info { - Some(li) => li.merged_alpha || marked, + Some(li) => { + let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); + li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color)) + } None => true, } } + /// `true` when the alpha channel names (resource 1045, else 1006) hold exactly `extra` names + /// and the first is "Transparency", Photoshop's name for the merged transparency. + fn names_merged_transparency(&self, extra: u16) -> bool { + const NAME: &str = "Transparency"; + let mut names = Vec::new(); + if let Some(r) = self.resource(1045) { + let mut at = 0usize; + while let Some(len) = r.data.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes) { + at = at.saturating_add(4); + let end = (len as usize).checked_mul(2).and_then(|n| at.checked_add(n)); + let Some(units) = end.and_then(|end| r.data.get(at..end)) else { break }; + let units: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + names.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); + at = at.saturating_add(units.len() * 2); + } + } else if let Some(r) = self.resource(1006) { + let mut at = 0usize; + while let Some(&len) = r.data.get(at) { + let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break }; + names.push(String::from_utf8_lossy(name).into_owned()); + at += 1 + usize::from(len); + } + } + names.len() == usize::from(extra) && names.first().is_some_and(|n| n == NAME) + } + /// Checks structural invariants that the writer relies on. pub fn validate(&self) -> Result<()> { self.header.validate()?; From 1338d78b454d2004e5f4cf88bcf0465f46a5ab9b Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:42:34 +0200 Subject: [PATCH 0374/1452] Transform: drop a fill layer's cached render when its frame changes (#6808) Free Transform and Warp moved a fill layer's linked masks (and so the frame its gradient is laid out in) but kept the cached render, which the compositor draws as-is while the Fill is unchanged. Drop the cache in apply_transform and warp_layer, as transform_layer_geometry and Perspective Crop already do. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/transform_cmds.rs | 54 +++++++++++++++++++++++++++++ crates/engine/src/warp_cmds.rs | 3 ++ 2 files changed, 57 insertions(+) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..e3725f4f3e 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -375,6 +375,9 @@ fn apply_transform( // With a selection only the selected pixels move, and so only the same region of a linked // mask (#205). Groups, type, shapes and smart objects move whole. let mask_sel = doc_sel.filter(|_| !moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model (as `transform_layer_geometry` does). + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -971,6 +974,57 @@ mod tests { assert!(!active_bounds(&s).is_empty()); } + /// A gradient fill layer whose mask (a selection when it was made) frames the gradient over + /// 10..40, with the cached render a PSD import gives it: exactly the pixels the fill renders. + fn cached_gradient_fill() -> Session { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.edit("cache", |doc, active| { + let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let LayerContent::Fill(f) = &l.content else { panic!("a fill layer") }; + let buf = photocraft_compose::render_fill_content(l, f, canvas, &[]); + let mut surface = Surface::new(fmt); + let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + surface.write_region(canvas, &vals); + l.fill_cache = Some(photocraft_doc::FillCache { fill: f.clone(), surface }); + Ok(()) + }) + .unwrap(); + s + } + + /// How far the composite is from the composite with the fill re-rendered from its model + /// (cache dropped): the largest channel difference. + fn off_from_fresh(s: &Session) -> f32 { + let st = s.active().unwrap(); + let mut fresh: Document = (*st.doc).clone(); + fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) + } + + #[test] + fn transforming_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the cache is the fill's own render: {off}"); + // The mask (and so the gradient's frame) moves 50 px right and doubles in height. + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 2, 50, 0]})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + + #[test] + fn warping_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + #[test] fn type_moves_with_the_move_command() { let mut s = session(); diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..8e2ab3c4fd 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -121,6 +121,9 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model. + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { From 89339396ff50ffaff388337e01e2d0e38dc1466c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:42:36 +0200 Subject: [PATCH 0375/1452] Droplets: carry the actions that Conditional steps call (#6804) called_actions only followed actions.play steps, so a droplet whose action used actions.conditional saved neither branch and failed with "no action named ..." when run standalone. It now also queues the then/else targets. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/actions_cmds.rs | 33 +++++++++++++++++------- crates/engine/src/automate_cmds.rs | 2 +- crates/engine/src/automate_cmds/tests.rs | 32 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..640810ee1b 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -151,18 +151,33 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } -/// The actions that `steps` call through Play Action steps, followed transitively, in list order, -/// so a droplet can run without the Actions panel that made it (#2794). Index references in -/// `steps` and in the copies become names, which resolve without the rest of the list. A target -/// that doesn't resolve is left alone: it fails at playback, as it would have before. +/// The actions that `steps` call through Play Action steps and the branches of Conditional steps, +/// followed transitively, in list order, so a droplet can run without the Actions panel that made +/// it (#2794, #6804). Index references in `steps` and in the copies become names, which resolve +/// without the rest of the list. A target that doesn't resolve is left alone: it fails at +/// playback, as it would have before. pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec { fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut Vec<(usize, usize)>) { - for (_, params) in steps.iter_mut().filter(|(id, _)| id == "actions.play") { - let Ok(i) = resolve(list, params, "actions.play") else { continue }; - if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { - obj.insert("action".into(), json!(action.name)); + for (id, params) in steps.iter_mut() { + match id.as_str() { + "actions.play" => { + let Ok(i) = resolve(list, params, "actions.play") else { continue }; + if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { + obj.insert("action".into(), json!(action.name)); + } + queue.push((i, depth)); + } + // Conditional branches only take names, so there is nothing to rewrite. + "actions.conditional" => { + for key in ["then", "else"] { + let Some(name) = params.get(key).and_then(Value::as_str) else { continue }; + if let Some(i) = list.iter().position(|a| a.name == name) { + queue.push((i, depth)); + } + } + } + _ => {} } - queue.push((i, depth)); } } let mut queue = Vec::new(); diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 4c78f90b34..38ad1f55d0 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -558,7 +558,7 @@ pub fn specs() -> Vec { "file.automate.createDroplet", "Create Droplet…", &["File", "Automate"], - r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action steps call are saved in the droplet)"##, + r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action and Conditional steps call are saved in the droplet)"##, native, create_droplet ), diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 06d0e88f36..322e6fb5b3 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -252,6 +252,38 @@ fn droplets_carry_the_actions_their_play_steps_call() { assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); } +#[test] +fn droplets_carry_the_actions_their_conditional_steps_call() { + use crate::actions_cmds::Action; + let dir = tmp("droplet-conditional"); + let inputs = images(&dir, 2, 40, 20); + let mut s = Session::new(); + let act = |name: &str, steps: Value| -> Action { serde_json::from_value(json!({"name": name, "steps": steps})).unwrap() }; + s.actions.list = vec![ + act("Unrelated", json!([["image.imageRotation.180", {}]])), + act("Rotate", json!([["image.imageRotation.90cw", {}]])), + // The then branch calls Rotate by index, as the panel records it. + act("Wide", json!([["actions.play", {"action": 1}]])), + act("Tall", json!([["image.imageRotation.180", {}]])), + ]; + let steps = json!([["actions.conditional", {"if": "landscape", "then": "Wide", "else": "Tall"}]]); + let out = format!("{dir}/out"); + let r = s + .execute("file.automate.createDroplet", json!({"path": format!("{dir}/Cond"), "steps": steps, "format": "png", "output": out, "shim": false})) + .unwrap(); + let dp = r["path"].as_str().unwrap().to_string(); + let v: Value = serde_json::from_slice(&std::fs::read(&dp).unwrap()).unwrap(); + let names: Vec<&str> = v["calledActions"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Rotate", "Wide", "Tall"], "both branches and what they call"); + + // A fresh session, as `photocraft-cli droplet` and the droplet shim use. + let mut fresh = Session::new(); + let r = fresh.execute("file.automate.runDroplet", json!({"droplet": dp, "input": [dir.clone()]})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); + let img = photocraft_codecs::decode(&std::fs::read(format!("{out}/img0.png")).unwrap()).unwrap(); + assert_eq!(img.dimensions(), (20, 40), "the landscape input took the then branch"); +} + #[test] fn batch_and_droplets_skip_recorded_view_steps() { let dir = tmp("view-steps"); From 6511eed63db71ebd37aa2d4106925918c24ad9ae Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:03:31 +0200 Subject: [PATCH 0376/1452] Actions: Shift-click layer select in .atn imports as range select and exports back (#6784) --- crates/engine/src/atn_cmds.rs | 3 +++ crates/engine/src/atn_cmds/tests.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..504e53ecf8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1406,6 +1406,8 @@ fn select_from(d: &Descriptor) -> Mapped { match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), + // Shift-click: select everything between the active layer and this one. + Some("addToSelectionContinuous") => p["mode"] = json!("range"), _ => {} } step("layer.select", p) @@ -1449,6 +1451,7 @@ fn select_to(id: &str, p: &Value) -> Option { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")), + Some("range") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelectionContinuous")), Some(_) => return None, } d.with("MkVs", D::Boolean(false)) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..059efe78a3 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -237,6 +237,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setProps", json!({"layer": "Shadow", "visible": false})), ("layer.setLabelColor", json!({"color": "red"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), + ("layer.select", json!({"layer": "Shadow", "mode": "range"})), ("layer.selectTop", json!({})), ("layer.selectBelow", json!({})), ("layer.addBelowToSelection", json!({})), From 76f19e6e2e48193bd13105e3da3e891ed4610f32 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:06:32 +0200 Subject: [PATCH 0377/1452] Title bar: only the primary button starts a window drag (#6729) --- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/titlebar.rs | 37 ++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4793554150..c097961e5e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -595,7 +595,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range()); ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap)); let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag()); - if drag.drag_started() { + if drag.drag_started_by(egui::PointerButton::Primary) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } if drag.double_clicked() { diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 25b23ee0f6..0330bbc7d4 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -67,7 +67,7 @@ pub(crate) fn modal_drag_overlay(ctx: &egui::Context) { } egui::Area::new(Id::new("titlebar-modal-drag-overlay")).order(Order::Tooltip).fixed_pos(area.min).movable(false).sense(Sense::hover()).show(ctx, |ui| { let response = ui.allocate_rect(area, Sense::click_and_drag()); - if response.drag_started() { + if response.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::StartDrag); } if response.double_clicked() { @@ -608,9 +608,13 @@ mod tests { } fn gesture_with_modal(&mut self, path: &[Pos2], modal: bool) -> Vec { - let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]]; + self.gesture_by(path, modal, egui::PointerButton::Primary) + } + + fn gesture_by(&mut self, path: &[Pos2], modal: bool, btn: egui::PointerButton) -> Vec { + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true, btn)]]; events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); - events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]); + events.push(vec![button(*path.last().unwrap_or(&path[0]), false, btn)]); events .into_iter() .flat_map(|e| self.frame_with_modal(e, modal).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()) @@ -654,8 +658,8 @@ mod tests { v } - fn button(p: Pos2, pressed: bool) -> Event { - Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + fn button(p: Pos2, pressed: bool, button: egui::PointerButton) -> Event { + Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() } } #[test] @@ -758,6 +762,23 @@ mod tests { assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); } + #[test] + fn middle_button_drag_in_the_gap_does_not_start_a_window_drag() { + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(980.0, 16.0); + let path = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]; + let cmds = w.gesture_by(&path, false, egui::PointerButton::Middle); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}"); + // An ordinary left drag afterwards still moves the window. + let cmds = w.gesture(&path); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + // Same with a modal open (the overlay over the gap). + w.frame_with_modal(vec![], true); + let cmds = w.gesture_by(&path, true, egui::PointerButton::Middle); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}"); + } + #[test] fn modal_drag_overlay_does_not_cover_a_dialog_moved_into_the_gap() { let mut w = Win::new(1440.0); @@ -780,7 +801,7 @@ mod tests { let p = pos2(3.0, 300.0); // Press and drag; the compositor takes over, so no release ever arrives. let mut cmds = vec![]; - for e in [vec![Event::PointerMoved(p)], vec![button(p, true)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] { + for e in [vec![Event::PointerMoved(p)], vec![button(p, true, egui::PointerButton::Primary)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] { cmds.extend(w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()); } assert!(cmds.contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "{cmds:?}"); @@ -803,10 +824,10 @@ mod tests { w.frame(vec![]); let p = pos2(3.0, 300.0); w.frame(vec![Event::PointerMoved(p)]); - w.frame(vec![button(p, true)]); + w.frame(vec![button(p, true, egui::PointerButton::Primary)]); w.frame(vec![Event::PointerMoved(p + vec2(6.0, 0.0))]); // The release arrives with the next input. - let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false)], ..Default::default() }; + let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false, egui::PointerButton::Primary)], ..Default::default() }; release_after_os_resize(&w.ctx, &mut raw); assert_eq!(raw.events.len(), 1, "{:?}", raw.events); // Or it already arrived in an earlier input: egui has the button up, nothing to end. From 156b27d65b8c157535d744ce391ad7b465bd156b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:03:35 +0200 Subject: [PATCH 0378/1452] Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) --- crates/algo/src/segment/grabcut.rs | 8 +++-- crates/algo/src/segment/tests.rs | 58 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..e1c1d89de0 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u } let (fg, bg) = grabcut(&img, &mut trimap, 8)?; let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); - let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); + let low = super::clean_mask(&low, lw, lh, 0.1, 0.0); + // Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } @@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let mut seeds = vec![false; w * h]; seeds[sy * w + sx] = true; let low = super::keep_seeded(&low, &seeds, w, h); - let low = super::clean_mask(&low, w, h, 0.1, 0.01); + let low = super::clean_mask(&low, w, h, 0.1, 0.0); + // As in the rectangle mode: enclosed holes fill only when they look like the object. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); if !low.iter().any(|v| *v) { return None; } diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..e060913c9b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -912,3 +912,61 @@ fn select_subject_dump_pictures() { write("ring", &img, None); write("ring-selected", &img, reg.as_ref()); } + +/// A dark square on a light background with a square hole that shows the background colour. +fn square_with_hole(hole: usize) -> RgbImage { + let (w, h) = (400usize, 300usize); + let mut rng = Rng::new(11); + let (x0, y0) = (100usize, 50usize); + RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200; + let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole; + let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }) +} + +#[test] +fn object_select_keeps_small_background_hole_open() { + let img = square_with_hole(8); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn object_at_keeps_small_background_hole_open() { + let img = square_with_hole(16); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn hole_fill_without_specks_fills_object_coloured_holes_only() { + // The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does. + let (w, h) = (40usize, 40usize); + let mut rng = Rng::new(5); + let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y); + let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y); + let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y); + let img = RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }); + let mask: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect(); + // The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour. + let dark: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect(); + let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m); + let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 3, gmm::DEFAULT_REG).unwrap(); + let (fg, bg) = (fit(fs), fit(bs)); + let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05); + assert!(out[25 * w + 25], "a speck of the object's colour fills"); + assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); +} From 4c730d3889cfe4499077aa21e6a64bde1dc2e1a5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:06:52 +0200 Subject: [PATCH 0379/1452] Object Finder: hovering with an empty active layer shows the status-bar hint (#6549) --- crates/engine/src/smartselect_cmds.rs | 18 +++++++++---- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/object_finder.rs | 38 +++++++++++++++++++++++++-- docs/ui-parity.md | 2 +- 23 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..a4152abfdb 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -221,12 +221,20 @@ impl ObjectFinderCache { /// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer /// above the photo first). fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { - let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; - if surf.content_bounds().intersect(&area).is_empty() { - let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; - return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + match empty_sampled_area(doc, layer, all_layers, area) { + Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))), + None => Ok(()), } - Ok(()) +} + +/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there. +/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint. +pub fn empty_sampled_area(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Option<&'static str> { + let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?; + if !surf.content_bounds().intersect(&area).is_empty() { + return None; + } + Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }) } /// `select.object` with `"point"`: the Object Finder's object under the point (cached). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..ab954fdf5f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3720,6 +3720,7 @@ navigator Zoom Zvětšení Medium Blue Středně modrá Object Finder Hledání objektů No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivní vrstva tu nemá žádné pixely — zapněte Vzorkovat všechny vrstvy nebo vyberte vrstvu s pixely Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..ff7028d50c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3110,6 +3110,7 @@ curves Smooth Glätten Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Die aktive Ebene hat hier keine Pixel — aktivieren Sie „Alle Ebenen einbeziehen“ oder wählen Sie eine Ebene mit Pixeln Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..b5542f7861 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3810,6 +3810,7 @@ curves Smooth Εξομάλυνση Light Gray Ανοιχτό γκρι Object Finder Εύρεση αντικειμένων No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Το ενεργό επίπεδο δεν έχει εικονοστοιχεία εδώ — ενεργοποιήστε τη Δειγματοληψία όλων των επιπέδων ή επιλέξτε ένα επίπεδο με εικονοστοιχεία Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..674e777ed3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2798,6 +2798,7 @@ curves Smooth Suavizar Light Gray Gris claro Object Finder Buscador de objetos No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels La capa activa no tiene píxeles aquí; activa Muestrear todas las capas o selecciona una capa con píxeles Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..080b15a7c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3115,6 +3115,7 @@ curves Smooth Lisser Light Gray Gris clair Object Finder Outil de recherche d’objets No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Le calque actif n’a pas de pixels ici — activez Échantillonner tous les calques ou sélectionnez un calque contenant des pixels Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..7a1bfdaa8a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4047,6 +4047,7 @@ cameraRaw Yellow Sárga @plural {n} action|{n} actions {n} művelet Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Az aktív rétegnek itt nincsenek képpontjai – kapcsold be a Mintavétel minden rétegből beállítást, vagy válassz képpontokat tartalmazó réteget Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..be7899aeb9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3102,6 +3102,7 @@ curves Smooth Haluskan Light Gray Abu-abu Muda Object Finder Pencari Objek No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Layer aktif tidak memiliki piksel di sini — aktifkan Sampel Semua Layer atau pilih layer yang berisi piksel Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..a2b316e0a1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4076,6 +4076,7 @@ navigator Zoom Zoom {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. Object Finder Ricerca oggetti No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Il livello attivo non ha pixel qui: attiva Campiona tutti i livelli o seleziona un livello con pixel Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..c17f887a04 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3723,6 +3723,7 @@ curves Smooth スムーズ Light Gray ライトグレー Object Finder オブジェクトファインダー No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels アクティブなレイヤーのここにはピクセルがありません — 「すべてのレイヤーをサンプル」をオンにするか、ピクセルのあるレイヤーを選択してください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..94df2a9df8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3287,6 +3287,7 @@ curves Smooth 매끄럽게 Light Gray 연한 회색 Object Finder 개체 찾기 No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 활성 레이어의 이 위치에는 픽셀이 없습니다 — 모든 레이어 샘플링을 켜거나 픽셀이 있는 레이어를 선택하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..7bac8575cf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3872,6 +3872,7 @@ curves Smooth Vloeiend maken Light Gray Lichtgrijs Object Finder Objectzoeker No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels De actieve laag heeft hier geen pixels — zet Alle lagen samplen aan of selecteer een laag met pixels Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..2edeffd236 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3710,6 +3710,7 @@ curves Smooth Wygładź Light Gray Jasnoszary Object Finder Wyszukiwanie obiektów No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktywna warstwa nie ma tu pikseli — włącz Próbkuj wszystkie warstwy albo wybierz warstwę z pikselami Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..d932d08e09 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3902,6 +3902,7 @@ layerLabel No Color Sem cor Light Gray Cinza-claro Object Finder Localizador de objetos No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels A camada ativa não tem pixels aqui — ative Usar amostra de todas as camadas ou selecione uma camada com pixels Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..cfe75a9e39 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3060,6 +3060,7 @@ curves Smooth Сгладить Light Gray Светло-серый Object Finder Поиск объектов No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активном слое здесь нет пикселей — включите «По всем слоям» или выберите слой с пикселями Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..6cf369deef 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4181,6 +4181,7 @@ shape Appearance Izgled Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje Object Finder Pronalazač objekata No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovde nema piksela — uključite Uzorkuj sve slojeve ili izaberite sloj sa pikselima Clear Brush Controls Obriši kontrole četkice Reset All Locked Settings Vrati sva zaključana podešavanja Copy Texture to Other Tools Kopiraj teksturu u druge alatke diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..05297f933a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3858,6 +3858,7 @@ cameraData Lens Lens @plural {n} action|{n} actions {n} eylem Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Etkin katmanda burada piksel yok — Tüm Katmanları Örnekle seçeneğini açın veya piksel içeren bir katman seçin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın Gradient Preset Options Gradyan Ön Ayarı Seçenekleri Import .GRD .GRD İçe Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..e42e6450e3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3811,6 +3811,7 @@ curves Smooth Згладити Light Gray Світло-сірий Object Finder Пошук об’єктів No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активному шарі тут немає пікселів — увімкніть «Зразок з усіх шарів» або виберіть шар із пікселями Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..a0a1cd7679 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3594,6 +3594,7 @@ curves Smooth Làm mượt Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Lớp đang hoạt động không có điểm ảnh ở đây — hãy bật Lấy mẫu mọi lớp hoặc chọn một lớp có điểm ảnh Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..3074fb8627 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2723,6 +2723,7 @@ curves Smooth 平滑 Light Gray 浅灰色 Object Finder 对象查找程序 No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 当前图层在此处没有像素 — 请打开“对所有图层采样”或选择有像素的图层 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..5261e93b53 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2708,6 +2708,7 @@ curves Smooth 平滑 Light Gray 淺灰色 Object Finder 物件尋找器 No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 作用中圖層在此處沒有像素 — 請開啟「取樣全部圖層」或選取有像素的圖層 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index e219b0ddff..bc2b47048e 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use egui::{Color32, TextureOptions, pos2}; use photocraft_algo::selection::Region; use photocraft_doc::{DocId, Document, LayerId}; -use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; +use photocraft_engine::smartselect_cmds::{empty_sampled_area, find_object, sampled_target}; use crate::PhotocraftApp; use crate::canvas::ViewXform; @@ -57,6 +57,13 @@ pub(crate) struct Update { pub shown: Option>, /// A lookup is pending or running: repaint soon to pick it up. pub busy: bool, + /// Why nothing can be found here: [`empty_layer_hint`] when the sampled layer has no pixels under the pointer. + pub hint: Option<&'static str>, +} + +/// The status-bar hint while hovering where the sampled layer has no pixels (the click reports the same as an error). +fn empty_layer_hint() -> &'static str { + tl!("The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels") } /// Whether the preview applies now (`pressed`: a pointer button is down). @@ -87,6 +94,11 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); } let doc_arc = Arc::clone(&st.doc); + // The click reports an empty sampled layer; the hover says so too instead of showing nothing. + let here = photocraft_geom::Rect::from_xywh(px[0], px[1], 1, 1); + if doc_arc.bounds().contains(px[0], px[1]) && empty_sampled_area(&doc_arc, layer, all_layers, here).is_some() { + return Update { shown: None, busy: false, hint: Some(empty_layer_hint()) }; + } let Some(f) = app.object_finder.as_mut() else { return Update::default() }; if f.at != px { f.at = px; @@ -121,7 +133,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); } let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); - Update { shown: f.shown.clone(), busy } + Update { shown: f.shown.clone(), busy, hint: None } } fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { @@ -193,6 +205,15 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf if up.busy { ctx.request_repaint_after(std::time::Duration::from_millis(40)); } + match up.hint { + Some(h) if app.ui.status != h => { + app.ui.status = h.to_owned(); + app.ui.status_error = false; + } + // The pointer moved on: drop our hint, never another message. + None if app.ui.status == empty_layer_hint() => app.ui.status.clear(), + _ => {} + } let Some(r) = up.shown else { return }; let Some(f) = app.object_finder.as_mut() else { return }; let key = Arc::as_ptr(&r) as usize; @@ -271,6 +292,19 @@ mod tests { assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); } + #[test] + fn hovering_an_empty_active_layer_gives_a_hint_not_silence() { + let mut app = disc_app(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 100.0); + assert!(u.shown.is_none() && !u.busy); + assert_eq!(u.hint, Some(empty_layer_hint())); + // Sample All Layers on: no hint, the object is looked up. + app.ui.tool_options.sample_all_layers = true; + assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + } + #[test] fn preview_clears_on_leave_tool_switch_button_option_and_edits() { let mut app = disc_app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..ee51b1e45a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Where the active layer has no pixels under the pointer (Sample All Layers off), the status bar says so instead of showing nothing (#6549). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | From c354a25a6b1449ab04d5d97f13bf0c6f581a615f Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 00:18:19 +0200 Subject: [PATCH 0380/1452] Type: paragraph box handles are picked at the unsnapped press (#6819) With View > Snap on, the canvas snapped the Type tool's press (a point gesture) before the tool's handle hit test, with the edited text's own glyph bounds and centre lines among the targets. The snapped press could leave the 6 pt handle grab area, so the press placed the caret or committed the edit instead of resizing the box. - The Type tools pick the box handle or glyph at the raw press, as Free Transform already does. - While editing, only a press on a box handle starts snapping, and the resize drag snaps to guides and other layers, not to the text being resized; caret drags are no longer snapped. Canvas-level test drags all eight handles (at the handle and inside its grab area) at 25-200 % zoom, 1x and 2x HiDPI, plain/scaled/rotated layer. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 9 +++- crates/ui-egui/src/snap_ui.rs | 6 +++ crates/ui-egui/src/type_tool_tests.rs | 71 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..bf93f23590 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5445,7 +5445,14 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) polygon_click(app, x, y, mods); return; } - Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if crate::type_tool::pointer_down(app, x, y, mods) => { + // The Type tools pick the box handle or glyph under the press where the user + // clicked: the snapped press can land off an edge handle's grab area (#6819). + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask + if { + let [px, py] = if let ToolEvent::Down { x, y, .. } = raw { [x, y] } else { [x, y] }; + crate::type_tool::pointer_down(app, px, py, mods) + } => + { return; } _ => {} diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..892bd4ca5b 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -235,6 +235,12 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // doesn't snap to its own layer. let exclude = if cut { app.session.active().map(|s| s.selected_layers()).unwrap_or_default() } else { Vec::new() }; selection_rect(app).map(|rect| (Gesture::SelectionMove { rect }, exclude)) + } else if tool.is_type() + && let Some(layer) = app.ui.text_edit.as_ref().map(|e| LayerId(e.layer)) + { + // Editing type: a paragraph-box handle drag snaps to guides and other layers, not to the + // text being resized; a press in the text places the caret where it is (#6819). + crate::type_tool::box_handle_at(app, layer, p[0], p[1]).map(|_| (Gesture::Point, vec![layer])) } else if is_point_tool(tool) { Some((Gesture::Point, Vec::new())) } else { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 7a23c9ccc3..763a92a2dd 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1928,6 +1928,77 @@ fn multi_click_selects_word_line_paragraph_and_all_in_point_text() { assert_eq!(selection(&h), (0, edited.chars().count()), "⌘A selects all text"); } +/// #6819: all eight paragraph-box handles resize the box while editing, with View › Snap on (the +/// default), at 25–200 % zoom, 1× and 2× HiDPI, for a plain, a scaled and a rotated layer. A press +/// on a handle (anywhere in its grab area) never moves the caret, and the edited text's own glyph +/// bounds don't pull the press off the handle. +#[test] +fn every_paragraph_box_handle_resizes_the_box_with_snapping_on() { + let scaled = Affine { m: [1.5, 0.0, 0.0, 0.75, 300.0, 300.0] }; + let rotated = Affine { m: [1.2 * 0.94, 1.2 * 0.342, -1.2 * 0.342, 1.2 * 0.94, 300.0, 300.0] }; + for ppp in [1.0, 2.0] { + for zoom in [0.25f32, 0.5, 1.0, 2.0] { + for (name, tf) in [("plain", None), ("scaled", Some(scaled)), ("rotated", Some(rotated))] { + let mut app = new_app(); + assert!(app.ui.extras.snap, "snapping is on by default"); + let size = 24.0 / zoom; + let p = json!({"text": "HOHOHO HOHO HOHOHO HOHO HOHOHO", "size": size, "x": 300, "y": 300, "box": [300.0, 300.0, size * 8.0, size * 4.0]}); + let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap()); + if let Some(a) = tf { + app.run("type.edit", json!({"layer": id.0, "transform": a.m})).unwrap(); + } + let mut h = harness(ppp, app); + let (bx, by, w, bh) = super::box_shape(h.state(), id).unwrap(); + let aff = layout(h.state_mut(), id).unwrap().1; + let m = aff.apply(Point::new(f64::from(bx + w / 2.0), f64::from(by + bh / 2.0))); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (zoom, [m.x as f32, m.y as f32], false); + h.run_steps(2); + let ctx = format!("ppp {ppp} zoom {zoom} {name}"); + let p = glyph(&mut h, id, 2, 0.2); + click(&mut h, p); + assert_eq!(selection(&h), (2, 2), "{ctx}: editing"); + for i in 0..8u8 { + // Press at the handle and inside its grab area, towards the text. + for off in [0.0f32, 4.0] { + let (bx, by, w, bh) = super::box_shape(h.state(), id).unwrap(); + let (l, r, t, b) = super::handle_sides(i); + let hx = if l { + bx + } else if r { + bx + w + } else { + bx + w / 2.0 + }; + let hy = if t { + by + } else if b { + by + bh + } else { + by + bh / 2.0 + }; + let dir = vec2(f32::from(u8::from(r)) - f32::from(u8::from(l)), f32::from(u8::from(b)) - f32::from(u8::from(t))); + let at = screen(&mut h, id, hx, hy); + let out = (screen(&mut h, id, hx + dir.x * 10.0, hy + dir.y * 10.0) - at).normalized(); + let a = at - out * off; + drag(&mut h, a, a + out * 30.0); + let ctx = format!("{ctx} handle {i} offset {off}"); + assert_eq!(selection(&h), (2, 2), "{ctx}: the caret stays put"); + let (_, _, w2, h2) = super::box_shape(h.state(), id).unwrap(); + // 30 points outward (a corner's diagonal: about 21 per side) is that over + // zoom × layer scale in text units; snapping may shift the release by up to + // its 8 point reach. + let min = 6.0 / (zoom / h.state().canvas_ppp()) / 1.5; + let grew = |was: f32, now: f32, moved: bool| if moved { now - was >= min } else { (now - was).abs() < 1e-3 }; + assert!(grew(w, w2, l || r), "{ctx}: width {w} -> {w2}"); + assert!(grew(bh, h2, t || b), "{ctx}: height {bh} -> {h2}"); + } + } + } + } + } +} + /// Paragraph text: three clicks select only the visual (soft-wrapped) line, four the whole /// paragraph; vertical type selects the column the same way. #[test] From 10fc2a3839d532a62a10d4cb36e95395e54f0fbe Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:24:09 +0200 Subject: [PATCH 0381/1452] Hue/Saturation: hue tracks and spectrum bars use full-strength sRGB hues, one shared hue_color (#6540) --- crates/ui-egui/src/adjust_editors.rs | 9 ++------ crates/ui-egui/src/camera_raw_ui.rs | 16 +------------- crates/ui-egui/src/widgets.rs | 33 ++++++++++++++++++++-------- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 87a81b8c35..d6f93726fb 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -21,7 +21,7 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::theme::Tokens; use crate::tone::{self, HistSource, Histograms}; -use crate::widgets; +use crate::widgets::{self, hue_color}; /// Kinds edited here (Selective Color and Color Lookup have their own editors; Invert has none). pub const KINDS: [&str; 13] = [ @@ -848,11 +848,6 @@ fn threshold(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { const RANGE_LABELS: [&str; 6] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"]; -fn hue_color(deg: f32) -> Color32 { - let rgb = egui::ecolor::Hsva::new(deg.rem_euclid(360.0) / 360.0, 1.0, 1.0, 1.0).to_srgb(); - Color32::from_rgb(rgb[0], rgb[1], rgb[2]) -} - /// The Saturation slider's track: grey to the hue at full saturation (UI-217-16). fn saturation_track(hue_deg: f32) -> [Color32; 2] { [Color32::from_gray(128), hue_color(hue_deg)] @@ -885,7 +880,7 @@ fn adjusted_spectrum(v: &Value, n: usize) -> Vec { let rect = photocraft_geom::Rect::new(0, 0, n as i32, 1); let px = (0..n) .map(|i| { - let c = egui::ecolor::Hsva::new(i as f32 / (n - 1).max(1) as f32, 1.0, 1.0, 1.0).to_rgb(); + let c = crate::color_picker_ui::hsv_to_rgb(i as f32 / (n - 1).max(1) as f32 * 360.0, 1.0, 1.0); [c[0], c[1], c[2], 1.0] }) .collect(); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index d2f78a35c2..562d3d5dd3 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -1215,7 +1215,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { }; for (k, name) in BANDS.iter().enumerate() { let c = photocraft_algo::camera_raw::HSL_BANDS[k]; - let base = hue_color(c); + let base = widgets::hue_color(c); let grad = [Color32::from_gray(128), base]; row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad)); } @@ -1308,20 +1308,6 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } -fn hue_color(h: f32) -> Color32 { - let h6 = (h.rem_euclid(360.0)) / 60.0; - let x = 1.0 - ((h6 % 2.0) - 1.0).abs(); - let (r, g, b) = match h6 as u32 { - 0 => (1.0, x, 0.0), - 1 => (x, 1.0, 0.0), - 2 => (0.0, 1.0, x), - 3 => (0.0, x, 1.0), - 4 => (x, 0.0, 1.0), - _ => (1.0, 0.0, x), - }; - Color32::from_rgb((r * 220.0) as u8, (g * 220.0) as u8, (b * 220.0) as u8) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2acbdf3c8b..051fa76015 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1332,15 +1332,17 @@ pub fn vline(ui: &mut Ui, height: f32) { ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); } -/// Hue spectrum stops for colour sliders. +/// The fully saturated colour at hue `deg` (degrees), built on sRGB values as Photoshop's hue +/// tracks are: 0 is (255,0,0), 60 is (255,255,0), 120 is (0,255,0) and so on (#6540). +pub fn hue_color(deg: f32) -> Color32 { + let [r, g, b] = crate::color_picker_ui::hsv_to_rgb(deg, 1.0, 1.0); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) +} + +/// Hue spectrum stops for colour sliders: red, yellow, green, cyan, blue, magenta, red. pub fn hue_stops() -> Vec { - (0..=12) - .map(|i| { - let h = i as f32 / 12.0; - let rgb = egui::ecolor::Hsva::new(h, 0.85, 1.0, 1.0).to_srgb(); - Color32::from_rgb(rgb[0], rgb[1], rgb[2]) - }) - .collect() + (0..=12).map(|i| hue_color(i as f32 * 30.0)).collect() } /// A compact labelled dropdown in the studio style. @@ -1775,7 +1777,20 @@ fn clean(text: &str) -> String { #[cfg(test)] mod tests { - use egui::{Key, Modifiers}; + use super::{hue_color, hue_stops}; + use egui::{Color32, Key, Modifiers}; + + #[test] + fn hue_stops_are_pure_and_evenly_spaced() { + let c = |r, g, b| Color32::from_rgb(r, g, b); + let want = [c(255, 0, 0), c(255, 255, 0), c(0, 255, 0), c(0, 255, 255), c(0, 0, 255), c(255, 0, 255), c(255, 0, 0)]; + let stops = hue_stops(); + assert_eq!(stops.len(), 13); + for (k, w) in want.iter().enumerate() { + assert_eq!(stops[k * 2], *w, "stop at {} degrees", k * 60); + assert_eq!(hue_color(k as f32 * 60.0), *w); + } + } #[test] fn checkbox_label_is_a_button_target_not_selectable_text() { From d0ea5f23cb5a0e1a2b21acf786a7c9341a181c6d Mon Sep 17 00:00:00 2001 From: rudrakshagarwal4200-ai Date: Sat, 17 Oct 2026 03:59:34 +0530 Subject: [PATCH 0382/1452] Change fit_image to use size_param for dimensions Updated fit_image function to use size_param for width and height. --- crates/engine/src/file_cmds.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..518d9afbfa 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -867,8 +867,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result { fn fit_image(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (w, h) = (d.doc.size.width as f64, d.doc.size.height as f64); - let bw = f64_param(p, "width").unwrap_or(w).max(1.0); - let bh = f64_param(p, "height").unwrap_or(h).max(1.0); + let bw = size_param(p, "width").unwrap_or(w); + let bh = size_param(p, "height").unwrap_or(h); let k = (bw / w).min(bh / h); if (k > 1.0 && p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(false)) || ((w * k).round() == w && (h * k).round() == h) { return Ok(json!({"width": w, "height": h, "changed": false})); From d24d25f5e74e605eaca333c1fa3ac979798df4e1 Mon Sep 17 00:00:00 2001 From: rudrakshagarwal4200-ai Date: Sat, 17 Oct 2026 04:01:18 +0530 Subject: [PATCH 0383/1452] Enhance fitImage tests for dimension constraints Added tests for handling zero or negative dimensions in fitImage. --- crates/engine/src/file_cmds/tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..60de40d912 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -532,6 +532,13 @@ fn fit_image_and_conditional_mode_change() { assert_eq!(r["changed"], false); s.execute("file.automate.fitImage", json!({"width": 1000, "height": 1000})).unwrap(); assert_eq!(doc(&s).size.width, 1000); + + // Zero or negative width or height leaves that side unconstrained. + let mut s2 = session(1000, 800, 8); + s2.execute("file.automate.fitImage", json!({"width": 0, "height": 400})).unwrap(); + assert_eq!((doc(&s2).size.width, doc(&s2).size.height), (500, 400)); + s2.execute("file.automate.fitImage", json!({"width": 250, "height": -10})).unwrap(); + assert_eq!((doc(&s2).size.width, doc(&s2).size.height), (250, 200)); let r = s.execute("file.automate.conditionalModeChange", json!({"from": ["cmyk"], "to": "grayscale"})).unwrap(); assert_eq!(r["changed"], false); s.execute("file.automate.conditionalModeChange", json!({"from": ["rgb", "lab"], "to": "grayscale"})).unwrap(); From b0484eb72cbdf87d3edffafad68d79b755ad8259 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:24:29 +0800 Subject: [PATCH 0384/1452] Remove extra blank line at merged test boundary --- crates/text/src/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index aa19405ce6..f9206e7faa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2086,7 +2086,6 @@ fn font_metadata_append_reorder_and_duplicate_indices() { } } - /// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694); /// a normal break opportunity still wins, and a box that fits the word lays out exactly as before. #[test] From 41bc07d8f577f823becad28d84aa47eb0a042a64 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:24:47 -0400 Subject: [PATCH 0385/1452] HEIF: read the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752) The colr nclx primaries and transfer were dropped, so a 10-bit BT.2020 HLG HEIC opened as if its samples were sRGB. photocraft-heif now returns the nclx (inherited from grid tiles like the ICC profile), codecs decodes PQ and HLG to linear light in 32-bit float (BT.2100 inverse OETF + OOTF at 1000 cd/m2, 203 cd/m2 diffuse white = 1.0), and io tags the document with the matching profile from the new photocraft_cms::builtin::cicp_rgb. BT.709/sRGB SDR files are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/cms/src/builtin.rs | 87 +++++++++++++-- crates/codecs/src/cicp.rs | 182 +++++++++++++++++++++++++++++++ crates/codecs/src/codecs/heif.rs | 12 +- crates/codecs/src/image.rs | 8 +- crates/codecs/src/lib.rs | 2 + crates/heif/src/lib.rs | 118 +++++++++++++++++--- crates/io/src/flat.rs | 55 ++++++++++ 7 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 crates/codecs/src/cicp.rs diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 8f3dc88a15..30a31aceb5 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -261,13 +261,60 @@ fn lab() -> Profile { /// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`). pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc"); +/// Rec. 709 / sRGB primaries (xy). +const BT709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]]; +/// DCI-P3 primaries (xy), used with D65 by Display P3. +const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; +/// Rec. 2020 primaries (xy). +const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. +fn rec709_trc() -> Curve { + let a = 1.099; + Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] } +} + +/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video +/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB, +/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the +/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are +/// held, since no ICC curve represents those transfers. `None` for anything else. +pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option { + let (prim, name) = match primaries { + 1 => (BT709, "BT.709"), + 9 => (BT2020, "Rec. ITU-R BT.2020"), + 12 => (P3, "Display P3"), + _ => return None, + }; + let transfer = if linear { 8 } else { transfer }; + let builtin = match (primaries, transfer) { + (1, 13) => Some(Builtin::Srgb), + (1, 8) => Some(Builtin::LinearSrgb), + (12, 13) => Some(Builtin::DisplayP3), + (9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020), + _ => None, + }; + if let Some(b) = builtin { + return Some(b.profile().clone()); + } + let (trc, curve) = match transfer { + 8 => (Curve::Gamma(1.0), "linear"), + 13 => (srgb_trc(), "sRGB curve"), + 1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"), + 4 => (Curve::Gamma(2.2), "gamma 2.2"), + 5 => (Curve::Gamma(2.8), "gamma 2.8"), + _ => return None, + }; + Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, d65(), trc)) +} + fn build(b: Builtin) -> Profile { let (srgb_p, p3, adobe, romm, bt2020, ap1) = ( - [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], - [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + BT709, + P3, [[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], [[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], - [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + BT2020, [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], ); match b { @@ -279,12 +326,7 @@ fn build(b: Builtin) -> Profile { // ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60, // xy 0.32168/0.33767, the ACES 2065-1 neutral). Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)), - Builtin::Rec2020 => { - // Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. - let a = 1.099; - let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }; - rgb(b.description(), bt2020, d65(), trc) - } + Builtin::Rec2020 => rgb(b.description(), bt2020, d65(), rec709_trc()), Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)), Builtin::SGray => gray(b.description(), srgb_trc()), Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)), @@ -294,3 +336,30 @@ fn build(b: Builtin) -> Profile { Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cicp_maps_onto_the_builtins_and_linear_bt2020() { + let desc = |p: Option| p.map(|p| p.description); + assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string())); + assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string())); + assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string())); + assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string())); + + // An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve. + let hlg = cicp_rgb(9, 18, true).unwrap(); + assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)])); + assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix); + assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description); + assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile"); + + // HLG and PQ have no ICC curve; unknown primaries have no profile. + assert!(cicp_rgb(9, 18, false).is_none()); + assert!(cicp_rgb(9, 16, false).is_none()); + assert!(cicp_rgb(2, 13, false).is_none()); + assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none()); + } +} diff --git a/crates/codecs/src/cicp.rs b/crates/codecs/src/cicp.rs new file mode 100644 index 0000000000..fc148837cc --- /dev/null +++ b/crates/codecs/src/cicp.rs @@ -0,0 +1,182 @@ +//! ITU-T H.273 colour descriptions (CICP: coding-independent code points), as HEIF `colr` +//! `nclx` boxes and video streams declare them, and the HDR transfer functions of ITU-R BT.2100. +//! +//! PQ and HLG can't be written as an ICC tone curve, so an image using them is decoded here to +//! linear light (32-bit float) instead: diffuse white (BT.2408's 203 cd/m²) becomes 1.0 and the +//! highlights above it are kept as values over 1.0, as Photoshop holds HDR in a 32-bit document. +//! The caller tags the result with a linear profile on the declared primaries. + +use crate::error::CodecError; +use crate::image::{Image, SampleType}; + +/// A colour description: the four H.273 code points as written in the file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cicp { + /// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65… + pub primaries: u16, + /// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 8 linear, 16 PQ, 18 HLG… + pub transfer: u16, + /// Matrix coefficients. + pub matrix: u16, + /// Full-range samples rather than studio swing. + pub full_range: bool, +} + +impl Cicp { + /// SMPTE ST 2084 / BT.2100 perceptual quantizer. + pub const TRANSFER_PQ: u16 = 16; + /// ARIB STD-B67 / BT.2100 hybrid log-gamma. + pub const TRANSFER_HLG: u16 = 18; + + /// PQ or HLG: samples that [`linearize`] decodes to linear light. + pub fn is_hdr(self) -> bool { + matches!(self.transfer, Cicp::TRANSFER_PQ | Cicp::TRANSFER_HLG) + } +} + +/// The luminance that becomes 1.0: BT.2408's HDR reference (diffuse) white. +pub const REFERENCE_WHITE_NITS: f32 = 203.0; +/// The nominal display peak HLG is rendered for (BT.2100's reference display). +pub const HLG_PEAK_NITS: f32 = 1000.0; +/// PQ's absolute peak. +pub const PQ_PEAK_NITS: f32 = 10_000.0; + +/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1). +pub fn hlg_inverse_oetf(e: f32) -> f32 { + const A: f32 = 0.178_832_77; + const B: f32 = 1.0 - 4.0 * A; + const C: f32 = 0.559_910_7; + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + if e <= 0.5 { e * e / 3.0 } else { (((e - C) / A).exp() + B) / 12.0 } +} + +/// BT.2100 HLG signal to display light relative to [`REFERENCE_WHITE_NITS`]: the inverse OETF, +/// then the OOTF for a [`HLG_PEAK_NITS`] display (system gamma 1.2, zero black level), whose +/// luminance uses the weights of the `luma` primaries. +pub fn hlg_to_linear(rgb: [f32; 3], luma: [f32; 3]) -> [f32; 3] { + let s = rgb.map(hlg_inverse_oetf); + let ys = luma[0] * s[0] + luma[1] * s[1] + luma[2] * s[2]; + // γ = 1.2 + 0.42·log10(Lw / 1000) is exactly 1.2 at 1000 cd/m². + let gain = if ys > 0.0 { HLG_PEAK_NITS * ys.powf(0.2) / REFERENCE_WHITE_NITS } else { 0.0 }; + s.map(|v| v * gain) +} + +/// SMPTE ST 2084 (PQ) signal `E'` (0..1) to display light relative to [`REFERENCE_WHITE_NITS`]. +pub fn pq_to_linear(e: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let p = e.powf(1.0 / M2); + let y = ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1); + y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS +} + +/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the +/// space BT.2100 defines HLG in). +fn luma_weights(code: u16) -> [f32; 3] { + match code { + 1 => [0.2126, 0.7152, 0.0722], + 12 => [0.2290, 0.6917, 0.0793], + _ => [0.2627, 0.6780, 0.0593], + } +} + +/// `img` (RGB or RGBA, decoded with `cicp`'s PQ or HLG transfer) as linear light in 32-bit float, +/// alpha unchanged. Its ICC profile is dropped: whatever it said described the encoded samples, +/// not linear light. Anything else comes back unchanged. +pub fn linearize(img: Image, cicp: Cicp) -> Result { + let layout = img.layout(); + if !cicp.is_hdr() || !layout.is_rgb() { + return Ok(img); + } + let (w, h) = img.dimensions(); + let ch = layout.channels(); + let mut v = img.to_normalized(); + let luma = luma_weights(cicp.primaries); + for px in v.chunks_exact_mut(ch) { + let [r, g, b, ..] = *px else { continue }; + let out = if cicp.transfer == Cicp::TRANSFER_HLG { hlg_to_linear([r, g, b], luma) } else { [r, g, b].map(pq_to_linear) }; + px[..3].copy_from_slice(&out); + } + let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?; + out.meta = img.meta; + out.warnings = img.warnings; + out.cicp = Some(cicp); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image::ChannelLayout; + + const BT2020: [f32; 3] = [0.2627, 0.6780, 0.0593]; + + fn close(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol + } + + #[test] + fn hlg_inverse_oetf_matches_bt2100() { + assert_eq!(hlg_inverse_oetf(0.0), 0.0); + // The two segments meet at E' = 0.5, E = 1/12. + assert!(close(hlg_inverse_oetf(0.5), 1.0 / 12.0, 1e-6)); + assert!(close(hlg_inverse_oetf(0.500_001), 1.0 / 12.0, 1e-5)); + assert!(close(hlg_inverse_oetf(1.0), 1.0, 1e-4)); + // BT.2408's HLG reference white, 75 % signal, is E ≈ 0.265. + assert!(close(hlg_inverse_oetf(0.75), 0.2650, 1e-3)); + for bad in [f32::NAN, f32::INFINITY, -1.0] { + assert!(hlg_inverse_oetf(bad).is_finite()); + } + } + + #[test] + fn hlg_reference_white_is_one_and_highlights_are_kept() { + // BT.2408: a 75 % HLG grey on a 1000 cd/m² display is 203 cd/m², our 1.0. + let white = hlg_to_linear([0.75; 3], BT2020); + assert!(white.iter().all(|&v| close(v, 1.0, 0.005)), "{white:?}"); + // Peak signal is the display peak: 1000 / 203. + let peak = hlg_to_linear([1.0; 3], BT2020); + assert!(peak.iter().all(|&v| close(v, 1000.0 / 203.0, 0.01)), "{peak:?}"); + // 50 % grey: 1000 · (1/12)^1.2 ≈ 50.7 cd/m². + let mid = hlg_to_linear([0.5; 3], BT2020); + assert!(mid.iter().all(|&v| close(v * 203.0, 50.71, 0.1)), "{mid:?}"); + assert_eq!(hlg_to_linear([0.0; 3], BT2020), [0.0; 3]); + // A saturated colour keeps its hue: only red lit stays only red. + let red = hlg_to_linear([0.75, 0.0, 0.0], BT2020); + assert!(red[0] > 0.0 && red[1] == 0.0 && red[2] == 0.0, "{red:?}"); + } + + #[test] + fn pq_matches_st2084() { + assert_eq!(pq_to_linear(0.0), 0.0); + assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05)); + // 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408). + assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807)); + assert!(pq_to_linear(f32::NAN).is_finite()); + } + + #[test] + fn linearize_decodes_hlg_to_float_and_keeps_alpha() { + let hlg = Cicp { primaries: 9, transfer: Cicp::TRANSFER_HLG, matrix: 9, full_range: true }; + let three_quarters = (0.75f32 * 65535.0).round() as u16; + let img = Image::from_u16(2, 1, ChannelLayout::Rgba, &[three_quarters, three_quarters, three_quarters, 65535, 0, 0, 0, 32768]) + .unwrap() + .with_icc(Some(b"encoded-profile".to_vec())); + let out = linearize(img, hlg).unwrap(); + assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg))); + let v = out.to_normalized(); + assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}"); + assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}"); + } + + #[test] + fn linearize_leaves_sdr_untouched() { + let srgb = Cicp { primaries: 1, transfer: 13, matrix: 6, full_range: false }; + let img = Image::from_u16(1, 1, ChannelLayout::Rgb, &[1, 2, 3]).unwrap(); + assert_eq!(linearize(img.clone(), srgb).unwrap(), img); + } +} diff --git a/crates/codecs/src/codecs/heif.rs b/crates/codecs/src/codecs/heif.rs index 7f26133b1d..31da309ca3 100644 --- a/crates/codecs/src/codecs/heif.rs +++ b/crates/codecs/src/codecs/heif.rs @@ -36,6 +36,7 @@ fn err(e: photocraft_heif::Error) -> CodecError { #[cfg(feature = "heif")] pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result { + use crate::cicp::Cicp; use crate::image::{ChannelLayout, Metadata, SampleType}; // The container alone: the declared size is checked before any pixel is decoded. @@ -51,12 +52,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 }; let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?; img.icc = decoded.icc; + img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range }); img.meta = Metadata { exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }), xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }), ..Default::default() }; - Ok(img) + // A 10-bit HDR HEIC (BT.2100 HLG or PQ) opens as linear light in 32-bit float (#6752): no + // ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights. + match img.cicp.filter(|c| c.is_hdr()) { + Some(c) => { + limits.check(decoded.width, decoded.height, layout, SampleType::F32)?; + crate::cicp::linearize(img, c) + } + None => Ok(img), + } } #[cfg(test)] diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 98168d59d3..8acc2a5eba 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -399,6 +399,9 @@ pub struct Image { data: Vec, /// Embedded ICC profile (must describe `layout`'s colour model). pub icc: Option>, + /// The file's CICP colour description (a HEIF `nclx`), when it has one. Only an image whose + /// `icc` is `None` relies on it. + pub cicp: Option, pub meta: Metadata, /// What the decoder noticed about the file: empty for a complete, single image and for /// images built in memory. Encoders ignore it. @@ -413,7 +416,7 @@ impl Image { /// A zero-filled image, or an error if the byte size overflows `usize`. pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result { let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; - Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() }) + Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() }) } /// Wrap raw interleaved, native-endian bytes. @@ -422,7 +425,7 @@ impl Image { if data.len() != expected { return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len()))); } - Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() }) + Ok(Image { width, height, layout, sample, data, icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() }) } pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec) -> Result { @@ -591,6 +594,7 @@ impl Image { sample, data, icc: if layout.same_model(self.layout) { self.icc.clone() } else { None }, + cicp: if layout.same_model(self.layout) { self.cicp } else { None }, meta: self.meta.clone(), warnings: self.warnings.clone(), } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..fb7e70c33c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -13,6 +13,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod cicp; mod codecs; mod error; pub mod exif_color; @@ -31,6 +32,7 @@ pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; +pub use crate::cicp::Cicp; pub use crate::error::CodecError; pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 7c88fc1b9c..218ccde5eb 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -64,6 +64,10 @@ pub struct Decoded { pub data: Vec, /// The primary image's ICC profile. pub icc: Option>, + /// The primary image's `nclx` colour description (inherited from its tiles like [`Self::icc`]). + /// The decoder has already applied its matrix and range; its primaries and transfer + /// characteristics describe the RGB samples returned. + pub nclx: Option, /// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors /// what the container says; it is returned verbatim. pub exif: Option>, @@ -71,6 +75,19 @@ pub struct Decoded { pub xmp: Option, } +/// A `colr` box of type `nclx`: ITU-T H.273 code points (CICP), as written. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Nclx { + /// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65… + pub primaries: u16, + /// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 16 PQ, 18 HLG… + pub transfer: u16, + /// Matrix coefficients (already applied by the decoder). + pub matrix: u16, + /// Full-range samples (already applied by the decoder). + pub full_range: bool, +} + /// Why a file could not be decoded. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { @@ -155,24 +172,37 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { .with_transforms(options.apply_transforms) .with_alpha(info.has_alpha); let decoded = heic_rs::decode(bytes, &raw).map_err(err)?; - let (icc, exif, xmp) = metadata(bytes); - Ok(Decoded { width: decoded.width, height: decoded.height, has_alpha: info.has_alpha, sixteen_bit: info.sixteen_bit, data: decoded.data, icc, exif, xmp }) + let (icc, nclx, exif, xmp) = metadata(bytes); + Ok(Decoded { + width: decoded.width, + height: decoded.height, + has_alpha: info.has_alpha, + sixteen_bit: info.sixteen_bit, + data: decoded.data, + icc, + nclx, + exif, + xmp, + }) } -/// The primary image's ICC profile, EXIF (TIFF structure) and XMP packet. Metadata is a +type Metadata = (Option>, Option, Option>, Option); + +/// The primary image's ICC profile, `nclx`, EXIF (TIFF structure) and XMP packet. Metadata is a /// courtesy: a broken metadata item never fails a decode whose pixels came out fine. -fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) { +fn metadata(bytes: &[u8]) -> Metadata { let Ok(ctx) = heic_rs::context::Context::open(bytes) else { - return (None, None, None); + return (None, None, None, None); }; let id = ctx.meta.primary; let icc = icc_for_item(&ctx, id); + let nclx = nclx_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), Cow::Owned(b) => String::from_utf8(b).ok(), }); - (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) + (icc, nclx, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } /// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries @@ -188,13 +218,26 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) /// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no /// override for it, so that half has to be fixed upstream. fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { - let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + inherited(ctx, item, |p| ctx.icc(p).map(<[u8]>::to_vec)) +} + +/// The `nclx` colour description of `item`, inherited from its tiles as [`icc_for_item`] does. +/// A 10-bit HDR HEIC declares its BT.2020 primaries and HLG or PQ transfer only here (#6752). +fn nclx_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option { + inherited(ctx, item, |p| { + p.nclx.map(|n| Nclx { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix_code, full_range: n.range == heic_rs::Range::Full }) + }) +} + +/// `get` of `item`'s properties, or of the first of its grid tiles that has it. +fn inherited(ctx: &heic_rs::context::Context<'_>, item: u32, get: impl Fn(&heic_rs::props::ItemProps<'_>) -> Option) -> Option { + let own = ctx.props(item).ok().and_then(|p| get(&p)); if own.is_some() { return own; } // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. let (_, tiles) = ctx.grid(item).ok()??; - tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| get(&p))) } #[cfg(test)] @@ -297,6 +340,17 @@ mod tests { bx(b"colr", &p) } + /// A `colr` box of type `nclx`, laid out as ISO/IEC 23008-12 says: three big-endian `u16` + /// code points, then the full-range flag in the top bit of a byte. + fn colr_nclx(primaries: u16, transfer: u16, matrix: u16, full_range: bool) -> Vec { + let mut p = b"nclx".to_vec(); + for v in [primaries, transfer, matrix] { + p.extend_from_slice(&v.to_be_bytes()); + } + p.push(if full_range { 0x80 } else { 0 }); + bx(b"colr", &p) + } + fn ispe(w: u32, h: u32) -> Vec { let mut p = w.to_be_bytes().to_vec(); p.extend_from_slice(&h.to_be_bytes()); @@ -340,16 +394,21 @@ mod tests { /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the /// property essential, as macOS writes it. fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + grid_file_colr(grid_icc.map(colr_prof), tile_icc.map(colr_prof)) + } + + /// [`grid_file`] with whole `colr` boxes. + fn grid_file_colr(grid_colr: Option>, tile_colr: Option>) -> Vec { let mut ipco = ispe(64, 64); let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); - if let Some(icc) = grid_icc { + if let Some(colr) = grid_colr { index += 1; - ipco.extend_from_slice(&colr_prof(icc)); + ipco.extend_from_slice(&colr); grid_assoc.push(0x80 | index); } - if let Some(icc) = tile_icc { + if let Some(colr) = tile_colr { index += 1; - ipco.extend_from_slice(&colr_prof(icc)); + ipco.extend_from_slice(&colr); tile_assoc.push(0x80 | index); } let mut iprp = bx(b"ipco", &ipco); @@ -374,10 +433,15 @@ mod tests { /// One coded picture rather than a derivation, with `icc` on the item itself. fn picture_file(icc: Option<&[u8]>) -> Vec { + picture_file_colr(icc.map(colr_prof)) + } + + /// [`picture_file`] with a whole `colr` box. + fn picture_file_colr(colr: Option>) -> Vec { let mut ipco = ispe(64, 64); let mut assoc = vec![0x81]; - if let Some(icc) = icc { - ipco.extend_from_slice(&colr_prof(icc)); + if let Some(colr) = colr { + ipco.extend_from_slice(&colr); assoc.push(0x82); } let mut iprp = bx(b"ipco", &ipco); @@ -436,4 +500,30 @@ mod tests { let ctx = ctx_of(&bare); assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); } + /// An iPhone 10-bit HDR HEIC's description (#6752): BT.2020 primaries, ARIB STD-B67 (HLG) + /// transfer, BT.2020 non-constant-luminance matrix, full range. + const HLG: Nclx = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: true }; + + #[test] + fn a_coded_pictures_nclx_is_read() { + let bytes = picture_file_colr(Some(colr_nclx(9, 18, 9, true))); + let ctx = ctx_of(&bytes); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); + + let sdr = picture_file_colr(Some(colr_nclx(1, 13, 6, false))); + let ctx = ctx_of(&sdr); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(Nclx { primaries: 1, transfer: 13, matrix: 6, full_range: false })); + + let bare = picture_file(Some(TILE_ICC)); + let ctx = ctx_of(&bare); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), None, "an ICC profile is not an nclx"); + } + + #[test] + fn a_grid_inherits_its_tiles_nclx() { + let bytes = grid_file_colr(None, Some(colr_nclx(9, 18, 9, true))); + let ctx = ctx_of(&bytes); + assert!(ctx.props(ctx.meta.primary).unwrap().nclx.is_none(), "the grid item carries no colr of its own"); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); + } } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index f61c28a40b..fde58d4657 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -51,9 +51,27 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { _ => None, }; } + if d.icc_profile.is_none() && d.mode == ColorMode::Rgb { + d.icc_profile = cicp_profile(&img); + } Ok(r) } +/// The profile a HEIF's `nclx` colour description stands for, when the file has no ICC profile +/// (#6752): a BT.2020 or P3 image is tagged so, and a PQ or HLG one, which the decoder turned into +/// linear light, gets a linear profile on its primaries. BT.709 SDR stays untagged, read as sRGB +/// as before. +fn cicp_profile(img: &Image) -> Option>> { + let c = img.cicp?; + let linear = img.sample_type().is_float(); + if c.primaries == 1 && !linear { + return None; + } + // Unspecified primaries on an HDR image: BT.2100's, which PQ and HLG are defined with. + let primaries = if c.primaries == 2 && c.is_hdr() { 9 } else { c.primaries }; + photocraft_cms::builtin::cicp_rgb(primaries, c.transfer, linear).map(|p| p.to_bytes()) +} + /// Every frame of a GIF as a single-layer document, handed to `each` in order with its delay in /// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller /// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. @@ -842,6 +860,43 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> } } +#[cfg(test)] +mod cicp_tests { + use super::*; + use photocraft_cms::{Builtin, Profile}; + + fn tagged(sample: CSample, cicp: codecs::Cicp) -> Image { + let mut img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap(); + img.cicp = Some(cicp); + img + } + + fn description(p: Option>>) -> Option { + p.map(|b| Profile::parse(&b).unwrap().description) + } + + #[test] + fn hlg_bt2020_is_tagged_linear_bt2020() { + let hlg = codecs::Cicp { primaries: 9, transfer: codecs::Cicp::TRANSFER_HLG, matrix: 9, full_range: true }; + let d = description(cicp_profile(&tagged(CSample::F32, hlg))).unwrap(); + assert!(d.contains("BT.2020") && d.contains("linear"), "{d}"); + // Unspecified primaries on HLG are BT.2100's. + let d = description(cicp_profile(&tagged(CSample::F32, codecs::Cicp { primaries: 2, ..hlg }))).unwrap(); + assert!(d.contains("BT.2020"), "{d}"); + } + + #[test] + fn sdr_nclx_is_unchanged_or_tagged_with_its_builtin() { + // BT.709 / sRGB: untagged, as before. + for transfer in [1, 2, 13] { + assert_eq!(cicp_profile(&tagged(CSample::U16, codecs::Cicp { primaries: 1, transfer, matrix: 6, full_range: false })), None); + } + let p3 = codecs::Cicp { primaries: 12, transfer: 13, matrix: 6, full_range: true }; + assert_eq!(description(cicp_profile(&tagged(CSample::U8, p3))).as_deref(), Some(Builtin::DisplayP3.description())); + assert_eq!(cicp_profile(&Image::new(1, 1, ChannelLayout::Rgb, CSample::U8).unwrap()), None, "no nclx, no profile"); + } +} + #[cfg(test)] mod cmyk_band_tests { use super::*; From 921637322cc623303d40dd902aaa6da11ebac3a0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 01:30:16 +0200 Subject: [PATCH 0386/1452] Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862) A number past f32's range (e.g. opacity 1e39) decoded as infinity, so the document opened but could never be saved again; read_manifest now refuses it as corrupt, naming the field. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/format/src/store.rs | 4 ++++ crates/format/tests/corruption.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..7da5dd6b53 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -163,6 +163,10 @@ pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result< let layers = take_array(&mut v, "/document/layers"); let mut m: Manifest = serde_json::from_value(v)?; m.document.layers = layers_from_json(layers)?; + // A number past f32's range decodes as infinity, which no save could write back (#6862). + if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { + return Err(FormatError::corrupt(format!("`{path}` is out of range"))); + } Ok(m) } diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index d293abed1e..727b37cb70 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -94,6 +94,24 @@ fn bad_manifest_json() { assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_)))); } +/// A number past f32's range would load as infinity, and every later save would then fail +/// (#6862): it is refused when the file is opened, naming the value. +#[test] +fn out_of_range_number_refused_on_open() { + let b = rebuild(&sample(), |n, d| { + Some(if n == "manifest.json" { + let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap(); + v["document"]["layers"][0]["opacity"] = serde_json::json!(1e39); + serde_json::to_vec(&v).unwrap() + } else { + d + }) + }); + let Err(e) = load_from_bytes(&b) else { panic!("a manifest with opacity 1e39 opened") }; + assert!(matches!(e, FormatError::Corrupt(_)), "{e}"); + assert!(e.to_string().contains("document.layers[0].opacity"), "{e}"); +} + /// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the /// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for /// lacking a format version. From 22217e6ab21cd40ad4a7f4bf82018d42182548c4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 01:32:48 +0200 Subject: [PATCH 0387/1452] CLI batch: skip shell-only Save and view steps inside played action sets (#6860) Batch strips Save/Save As/zoom steps from every action of a loaded set, called actions included, and remaps their unchecked/dialog step indices. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/photocraft-cli/src/lib.rs | 56 +++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 03001ab02d..8c6deeeb2b 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -444,11 +444,32 @@ fn is_input(p: &Path) -> bool { p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) } +/// Drops the Save and view steps the desktop records from `action` (Batch saves each result +/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). +fn strip_shell_steps(action: &mut Action) { + use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; + let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); + for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { + if shell_save_command(&id) || shell_view_command(&id) { + continue; + } + if action.disabled.contains(&i) { + disabled.insert(steps.len()); + } + if action.dialogs.contains(&i) { + dialogs.insert(steps.len()); + } + steps.push((id, p)); + } + (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); +} + fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + set.iter_mut().for_each(strip_shell_steps); for w in &warnings { let _ = writeln!(err, "warning: {w}"); } @@ -751,4 +772,37 @@ mod batch_action_set_tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// Save and view steps recorded inside a batched action set are skipped, also in a called + /// action, and unchecked steps stay unchecked once they are gone (#6860). + #[test] + fn batch_skips_save_and_view_steps_inside_an_action_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, + {"name": "Rotate", "steps": [ + ["file.save", {}], + ["image.imageRotation.90cw", {}], + ["view.fitOnScreen", {}], + ["image.imageRotation.90cw", {}], + ["file.saveAs", {}], + ], "disabled": [3], "dialogs": [1]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + for action in ["Outer", "Rotate"] { + let (code, stdout, stderr) = + invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + assert_eq!(code, 0, "{stdout}{stderr}"); + let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); + } + let _ = std::fs::remove_dir_all(&dir); + } } From e1db181435f5824a36d008108422c28978805ed6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 01:35:33 +0200 Subject: [PATCH 0388/1452] Guides: ruler guide snapping rebuilds its targets when snap options change (#6858) The cached guide targets were keyed only by document and revision, so showing the grid or turning Snap To Layers/Document Bounds off kept the stale targets; the key now includes the Snap To options. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/prefs_ui.rs | 4 ++-- crates/ui-egui/src/snap_ui.rs | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index e9282e44c1..dd8c951df5 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -15,7 +15,7 @@ use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started}; #[cfg(test)] use photocraft_engine::prefs::Theme; use photocraft_engine::prefs::{self, AppearanceMode, DarkTheme, LightTheme, SECTIONS, Theme as SavedTheme}; -use photocraft_engine::snap::{SnapLine, SnapTargets}; +use photocraft_engine::snap::{SnapLine, SnapOptions, SnapTargets}; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -43,7 +43,7 @@ pub struct Runtime { /// Snapping state of the drag in progress (see `snap_ui`). pub(crate) snap: Option, pub(crate) snap_lines: Vec, - pub(crate) guide_targets: Option<((DocId, u64), SnapTargets)>, + pub(crate) guide_targets: Option<((DocId, u64, SnapOptions), SnapTargets)>, /// Checkerboard colours the cached CPU checker texture was built with. pub(crate) checker_key: Option<[[u8; 3]; 2]>, /// Style last sent to the GPU canvas. diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..c6a65a1c21 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -485,7 +485,8 @@ pub fn snap_guide(app: &mut PhotocraftApp, vertical: bool, pos: f64) -> f64 { return pos; } let Some(st) = app.session.active() else { return pos }; - let key = (st.doc.id, st.revision); + // The Snap To options (grid, layers, document bounds, grid step) change the targets too. + let key = (st.doc.id, st.revision, options(app)); if app.prefs_rt.guide_targets.as_ref().is_none_or(|(k, _)| *k != key) { let t = build(app, &[], false).filtered(|k| k != SnapKind::Guide); app.prefs_rt.guide_targets = Some((key, t)); @@ -1145,6 +1146,25 @@ mod tests { assert_eq!(snap_guide(&mut app, true, 297.0), 297.0); } + /// The cached ruler-guide targets follow the Snap To options, not just document edits. + #[test] + fn guide_targets_follow_snap_to_options() { + let mut app = app_with_box(); + app.run( + "prefs.set", + json!({"values": {"guidesGridAndSlices.gridlineEvery": 100, "guidesGridAndSlices.gridUnit": "pixels", "guidesGridAndSlices.subdivisions": 4}}), + ) + .unwrap(); + app.ui.extras.grid = false; + assert_eq!(snap_guide(&mut app, true, 23.0), 23.0, "grid hidden"); + app.ui.extras.grid = true; + app.ui.view.snap_to.grid = true; + assert_eq!(snap_guide(&mut app, true, 23.0), 25.0, "grid shown"); + app.ui.extras.grid = false; + app.ui.view.snap_to.layers = false; + assert_eq!(snap_guide(&mut app, true, 297.0), 297.0, "Snap To Layers off"); + } + #[test] fn percentage_grid_snaps_at_separate_axis_intervals() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From c9334c46619ae14762b95eae0ff70215b88280b3 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:39:39 -0400 Subject: [PATCH 0389/1452] Windows: a maximized custom-title-bar window lets an auto-hidden taskbar show (#4845) An auto-hidden taskbar reserves no work area, so the maximize guard left the caption-less window covering the whole monitor and the shell treated it as a full-screen app, never revealing the taskbar. On each monitor edge with an auto-hidden taskbar (found via Shell_TrayWnd / Shell_SecondaryTrayWnd through safe winsafe calls) the window now stops 1 px short of the monitor. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/window_frame.rs | 121 +++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs index e418049d07..56c9be1b6a 100644 --- a/apps/photocraft/src/window_frame.rs +++ b/apps/photocraft/src/window_frame.rs @@ -20,6 +20,11 @@ //! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work //! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move. //! +//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the +//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar +//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard +//! instead stops the window one pixel short of the monitor, as other custom-frame apps do. +//! //! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and //! winsafe can't wrap a raw handle without it, so the window is found by its handle instead. @@ -29,7 +34,20 @@ pub use platform::service; mod platform { use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; use photocraft_ui_egui::WindowFrameFn; - use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + use winsafe::prelude::Handle; + use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co}; + + /// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive. + type Rect = (i32, i32, i32, i32); + + /// A monitor edge. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Edge { + Left, + Top, + Right, + Bottom, + } /// A guard that keeps the window borderless each frame, or `None` when the app uses the /// system title bar (the window is decorated) or its handle can't be found. @@ -99,8 +117,11 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some((left, top, right, bottom)) = - clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) + let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)); + // The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window + // covers the whole monitor, which a trimmed maximize no longer does. + let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work) + .or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten()) else { return; }; @@ -153,6 +174,69 @@ mod platform { (corrected != rect).then_some(corrected) } + /// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app. + fn covers(rect: Rect, monitor: Rect) -> bool { + rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3 + } + + /// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary + /// monitor's `Shell_SecondaryTrayWnd`), shown or hidden. + fn taskbar_edges(monitor: Rect) -> Vec { + let mut edges = Vec::new(); + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let mut after: Option = None; + // One taskbar per monitor; the bound only guards against a misbehaving shell. + for _ in 0..64 { + let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else { + break; + }; + if let Ok(r) = tray.GetWindowRect() { + edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor)); + } + after = Some(tray); + } + } + edges + } + + /// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre, + /// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its + /// edge with a sliver left on screen, so its centre may lie outside the monitor. + fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option { + let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3)); + let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3)); + if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb { + return None; + } + // Doubled centre and distances, so nothing rounds. + let (cx, cy) = (tl + tr, tt + tb); + [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)] + .into_iter() + .min_by_key(|&(_, distance)| distance) + .map(|(edge, _)| edge) + } + + /// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a + /// taskbar reserves no work area (it auto-hides), so the shell still reveals it there. + /// `None` when there is nothing to trim. + fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option { + if !covers(rect, monitor) { + return None; + } + let (mut l, mut t, mut r, mut b) = rect; + for edge in taskbars { + match edge { + Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?, + Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?, + Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?, + Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?, + _ => {} + } + } + let trimmed = (l, t, r, b); + (l < r && t < b && trimmed != rect).then_some(trimmed) + } + /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes /// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap /// keep working, as they did with winit 0.27's undecorated windows. @@ -162,7 +246,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_rect}; + use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge}; use winsafe::co; #[test] @@ -221,6 +305,35 @@ mod platform { assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); } + + #[test] + fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() { + // #4845: an auto-hidden taskbar reserves no work area. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (edge, expected) in + [(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))] + { + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected)); + assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed"); + } + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar"); + // A taskbar that reserves its work area is the clamp's job. + assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None); + assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen"); + } + + #[test] + fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown"); + assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden"); + assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top)); + assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left)); + assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right)); + assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor"); + assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow"); + } } } From 3e373ddef8524953793d57bd088b2f35db32e67e Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:43:00 -0400 Subject: [PATCH 0390/1452] GPU canvas: count pages evicted to admit a layer texture in render stats (#6563) Admission now evicts before creating a page, so the post-cell evict found nothing over budget and Stats::evicted stayed 0 (pages_are_evicted_under_the_budget). Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a7699d8dd3..ff069ad1ed 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1250,9 +1250,16 @@ impl Compositor { /// the budget first; when the device is still out of memory, every page the current cell /// doesn't use is dropped and creation is tried once more (#6563). `None` (with /// `layer_alloc` set) when it still fails: the refresh ends and the CPU draws it. - fn create_layer_texture(&mut self, device: &wgpu::Device, desc: &wgpu::TextureDescriptor<'_>, key: ResKey, bytes: u64) -> Option { + fn create_layer_texture( + &mut self, + device: &wgpu::Device, + desc: &wgpu::TextureDescriptor<'_>, + key: ResKey, + bytes: u64, + stats: &mut Stats, + ) -> Option { let replaced = self.residents.get(&key).map_or(0, Resident::bytes); - self.evict_residents(self.resident_budget, bytes.saturating_sub(replaced), Some(key)); + stats.evicted += self.evict_residents(self.resident_budget, bytes.saturating_sub(replaced), Some(key)).0; // WebGPU reports errors asynchronously: its failures reach the health callbacks. if cfg!(target_arch = "wasm32") { return Some(device.create_texture(desc)); @@ -1262,7 +1269,7 @@ impl Compositor { match error { None => return Some(texture), Some(wgpu::Error::OutOfMemory { .. }) if !retry => { - self.evict_residents(0, 0, Some(key)); + stats.evicted += self.evict_residents(0, 0, Some(key)).0; } Some(wgpu::Error::OutOfMemory { .. }) => { self.layer_alloc = Some(OVER_BUDGET.into()); @@ -1594,7 +1601,7 @@ impl Compositor { view_formats: &[], }; let bytes = region.width() as u64 * region.height() as u64 * kind.bytes_per_pixel() as u64; - let texture = self.create_layer_texture(device, &desc, key, bytes)?; + let texture = self.create_layer_texture(device, &desc, key, bytes, stats)?; let view = texture.create_view(&Default::default()); let default_nonzero = default_pixel.iter().any(|v| *v != 0.0); let mut r = Resident { From 2466f5c3a74b62b5926951d137965be513318a4f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:52:06 +0200 Subject: [PATCH 0391/1452] Disable Convert to Smart Object for layers that are already smart objects Fixes #6730 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/tests/mcp.rs | 3 ++- crates/engine/src/smart_cmds.rs | 9 ++++++++- crates/engine/src/smart_cmds/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 7386f3d58c..6236c5acdd 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -731,11 +731,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/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..9f99d37922 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1177,6 +1177,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 } @@ -1193,7 +1200,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 1caaff6ce2..f1929166cc 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] { From 0769fa22ff1ac030ae139a990c62dec0b47fed51 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:55:09 -0400 Subject: [PATCH 0392/1452] Actions: map Select Subject, Select and Mask, Color Range, clipping masks, layer masks and colours from .atn (part of #6745) autoCutout -> select.subject, smartBrushWorkspace -> select.refineEdge, colorRange presets -> select.colorRange, groupEvent/ungroup -> create/release clipping mask, make of a layer mask -> layer.layerMask.*, set of the foreground/background colour -> tools.setColors. Selective Color's yellows range is Photoshop's Ylws (legacy Yllw still reads). Steps whose params can't be translated stay unsupported. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 213 +++++++++++++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 104 +++++++++++++- 2 files changed, 314 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..6c837ac006 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -697,9 +697,11 @@ fn channel_mixer_to(p: &Value) -> Option { Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) } +/// Photoshop's `Clrs` colour-range enumeration. Yellows is `Ylws` (`Yllw` is the yellow ink +/// key); earlier PhotoCraft builds wrote `Yllw`, which import still reads (#6745). const SELECTIVE_RANGES: &[(&str, &str)] = &[ ("Rds", "reds"), - ("Yllw", "yellows"), + ("Ylws", "yellows"), ("Grns", "greens"), ("Cyns", "cyans"), ("Bls", "blues"), @@ -716,7 +718,8 @@ fn selective_from(d: &Descriptor) -> Option { } for e in get_list(d, "ClrC").unwrap_or(&[]) { let D::Descriptor(e) = e else { return None }; - let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?; + let range = get_enum(e, "Clrs")?; + let name = lookup(SELECTIVE_RANGES, if range == "Yllw" { "Ylws" } else { &range })?; let v = |k: &str| get_num(e, k).unwrap_or(0.0); out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")])); } @@ -982,6 +985,14 @@ fn pattern_desc(p: &Value) -> Option { Some(t) } +/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command. +const MASK_USING: &[(&str, &str)] = &[ + ("RvlA", "layer.layerMask.revealAll"), + ("HdAl", "layer.layerMask.hideAll"), + ("RvlS", "layer.layerMask.revealSelection"), + ("HdSl", "layer.layerMask.hideSelection"), +]; + fn make_from(d: &Descriptor) -> Mapped { // Duplicate layer/group into a new document: `Mk` with a document class, a layer // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` @@ -1003,6 +1014,16 @@ fn make_from(d: &Descriptor) -> Mapped { } return step("layer.duplicate", Value::Object(p)); } + // Add Layer Mask: a `Chnl` class made at the mask channel, `Usng` what it reveals. + if let Some(D::Class(c)) = d.get("Nw ") + && trimmed(&c.class_id) == "Chnl" + { + let at_mask = get_ref(d, "At ").is_some_and(|r| ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk")); + return match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) { + Some(id) if at_mask => none(id), + _ => Mapped::Unsupported { harmless: false }, + }; + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1152,6 +1173,14 @@ fn make_to(id: &str, p: &Value) -> Option { } d } + "layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection" => { + // Photoshop adds the mask to the target layer only. + if p.get("layer").is_some() { + return None; + } + let at = D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]); + desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?)) + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1229,6 +1258,15 @@ fn set_from(d: &Descriptor) -> Mapped { } _ => unsupported, }, + // The foreground or background colour (Color Picker, Swatches). Colours in other models + // (Lab, CMYK, a colour book) stay unsupported. + (Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) { + Some(c) => { + let which = if p == "FrgC" { "foreground" } else { "background" }; + step("tools.setColors", json!({ which: c })) + } + None => unsupported, + }, // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), // Layer › New › Layer From Background (Photoshop records the New Layer dialog's values @@ -1373,6 +1411,18 @@ fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) } + "tools.setColors" => { + let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) { + (Some(f), None) => ("FrgC", f), + (None, Some(b)) => ("BckC", b), + _ => return None, + }; + if p.as_object()?.len() != 1 { + return None; + } + let r = D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new(key) }]); + desc("null").with("null", r).with("T ", D::Descriptor(color_desc(hex)?)) + } "layer.new.layerFromBackground" => { let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml")))) @@ -2543,6 +2593,141 @@ fn gallery_to(id: &str, p: &Value) -> Option { fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code))) } +/// Select and Mask as Photoshop CC 2015.5 and later record it (`smartBrushWorkspace`); the +/// older Refine Edge event is [`REFINE_EDGE`], which is also what Select and Mask exports as. +const SMART_BRUSH: &[F] = &[ + F("smartBrushRadius", "radius", Kind::Px), + F("smartBrushUseSmartRadius", "smartRadius", Kind::Bool), + F("smartBrushSmooth", "smooth", Kind::Long), + F("smartBrushFeather", "feather", Kind::Px), + F("smartBrushContrast", "contrast", Kind::Pct), + F("smartBrushShiftEdge", "shiftEdge", Kind::Pct), + F("smartBrushDecontaminate", "decontaminate", Kind::Bool), + F("smartBrushDeconAmount", "amount", Kind::Pct), + F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)), + F("sampleAllLayers", "sampleAllLayers", Kind::Bool), +]; + +/// Select › Color Range's `Clrs` presets ↔ `select.colorRange`'s `select`. +const COLOR_RANGE: &[(&str, &str)] = &[ + ("Rds", "reds"), + ("Ylws", "yellows"), + ("Grns", "greens"), + ("Cyns", "cyans"), + ("Bls", "blues"), + ("Mgnt", "magentas"), + ("Hghl", "highlights"), + ("Mdtn", "midtones"), + ("Shdw", "shadows"), + ("OtOf", "outOfGamut"), + ("skinTone", "skinTones"), +]; + +/// The Color Range keys the converter reads (or that don't change a preset's result: +/// `colorModel` is the model of sampled colours). +const COLOR_RANGE_KEYS: &[&str] = &[ + "Clrs", + "Fzns", + "Invr", + "colorModel", + "shadowsFuzziness", + "shadowsUpperLimit", + "midtonesFuzziness", + "midtonesLowerLimit", + "midtonesUpperLimit", + "highlightsFuzziness", + "highlightsLowerLimit", +]; + +/// Color Range (`ClrR`) with a preset. Sampled Colors records Lab minimum and maximum colours, +/// which `select.colorRange` (eyedropper points on the image) can't reproduce, and an unknown +/// key (Detect Faces, Localized Color Clusters…) could change the result: both stay +/// unsupported. +fn color_range_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if d.items.iter().any(|(k, _)| !COLOR_RANGE_KEYS.contains(&trimmed(k).as_str())) { + return unsupported; + } + let Some(preset) = get_enum(d, "Clrs").and_then(|c| lookup(COLOR_RANGE, &c)) else { return unsupported }; + let mut p = Map::new(); + p.insert("select".into(), preset.into()); + let key = |suffix: &str| get_num(d, &format!("{preset}{suffix}")); + match preset { + "skinTones" => { + if let Some(f) = get_num(d, "Fzns") { + p.insert("fuzziness".into(), f.into()); + } + } + "shadows" | "midtones" | "highlights" => { + if let Some(f) = key("Fuzziness") { + p.insert("fuzziness".into(), f.into()); + } + let range = match (preset, key("LowerLimit"), key("UpperLimit")) { + ("shadows", _, hi) => hi.map(Value::from), + ("highlights", lo, _) => lo.map(Value::from), + (_, Some(lo), Some(hi)) => Some(json!([lo, hi])), + (_, None, None) => None, + _ => return unsupported, + }; + if let Some(r) = range { + p.insert("tonalRange".into(), r); + } + } + _ => {} + } + if get_bool(d, "Invr") == Some(true) { + p.insert("invert".into(), true.into()); + } + step("select.colorRange", Value::Object(p)) +} + +fn color_range_to(_: &str, p: &Value) -> Option { + let preset = jstr(p, "select")?; + if p.as_object()?.keys().any(|k| !matches!(k.as_str(), "select" | "fuzziness" | "tonalRange" | "invert")) { + return None; + } + let mut d = desc("null").with("Clrs", enm("Clrs", lookup_back(COLOR_RANGE, preset)?)); + let tonal = matches!(preset, "shadows" | "midtones" | "highlights"); + if let Some(f) = jnum(p, "fuzziness") { + match preset { + "skinTones" => d = d.with("Fzns", long(f)), + _ if tonal => d = d.with(&format!("{preset}Fuzziness"), long(f)), + _ => {} + } + } + if let Some(r) = p.get("tonalRange") { + match preset { + "shadows" => d = d.with("shadowsUpperLimit", long(r.as_f64()?)), + "highlights" => d = d.with("highlightsLowerLimit", long(r.as_f64()?)), + "midtones" => { + let a = r.as_array()?; + d = d.with("midtonesLowerLimit", long(a.first()?.as_f64()?)).with("midtonesUpperLimit", long(a.get(1)?.as_f64()?)); + } + _ => return None, + } + } + if jbool(p, "invert") == Some(true) { + d = d.with("Invr", D::Boolean(true)); + } + Some(d) +} + +/// Create / Release Clipping Mask (`groupEvent` / `ungroup`) on the target or a named layer. +fn clipping_from(d: &Descriptor, id: &str) -> Mapped { + match get_ref(d, "null") { + None => none(id), + Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) { + Some(p) => step(id, Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + }, + Some(_) => Mapped::Unsupported { harmless: false }, + } +} + +fn clipping_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", layer_ref(p))) +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. const EVENTS: &[Event] = &[ // Filters. @@ -2760,6 +2945,10 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.channelMixer", "layer.newAdjustmentLayer.selectiveColor", "layer.newAdjustmentLayer.gradientMap", + "layer.layerMask.revealAll", + "layer.layerMask.hideAll", + "layer.layerMask.revealSelection", + "layer.layerMask.hideSelection", "file.new", "file.newFromClipboard", ], @@ -2784,6 +2973,7 @@ const EVENTS: &[Event] = &[ "layer.setAdjustment", "layer.fill.pattern.set", "layer.new.layerFromBackground", + "tools.setColors", ], from: set_from, to: set_to, @@ -2841,6 +3031,22 @@ const EVENTS: &[Event] = &[ to: merge_visible_to, }, ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]), + Event { + code: "GrpL", + name: "groupEvent", + label: "Create Clipping Mask", + ids: &["layer.createClippingMask"], + from: |d| clipping_from(d, "layer.createClippingMask"), + to: clipping_to, + }, + Event { + code: "Ungr", + name: "ungroup", + label: "Release Clipping Mask", + ids: &["layer.releaseClippingMask"], + from: |d| clipping_from(d, "layer.releaseClippingMask"), + to: clipping_to, + }, ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]), ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]), ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), @@ -2872,6 +3078,9 @@ const EVENTS: &[Event] = &[ ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE), + ev!("", "smartBrushWorkspace", "Select and Mask", "select.refineEdge", SMART_BRUSH), + ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]), + Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to }, // Edit. Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..fa8f9032b0 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -198,7 +198,20 @@ fn every_mapped_command_exports_and_imports_back() { json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}), ), ("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})), - ("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "neutrals": [0, 0, 0, 3]})), + ("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "yellows": [0, 5, 0, 0], "neutrals": [0, 0, 0, 3]})), + ("select.subject", json!({"sampleAllLayers": false})), + ("select.colorRange", json!({"select": "midtones", "fuzziness": 30, "tonalRange": [90, 160], "invert": true})), + ("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})), + ("select.colorRange", json!({"select": "highlights", "tonalRange": 200})), + ("select.colorRange", json!({"select": "reds"})), + ("layer.createClippingMask", json!({})), + ("layer.releaseClippingMask", json!({"layer": "Shadow"})), + ("layer.layerMask.revealAll", json!({})), + ("layer.layerMask.hideAll", json!({})), + ("layer.layerMask.revealSelection", json!({})), + ("layer.layerMask.hideSelection", json!({})), + ("tools.setColors", json!({"foreground": "#336699"})), + ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), @@ -479,6 +492,95 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } +/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening, +/// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them. +#[test] +fn retouching_set_events_map_to_their_commands() { + let smart_brush = desc("null") + .with("smartBrushRadius", px(3.0)) + .with("smartBrushSmooth", long(2.0)) + .with("smartBrushFeather", px(0.5)) + .with("smartBrushContrast", pct(10.0)) + .with("smartBrushShiftEdge", pct(-20.0)) + .with("sampleAllLayers", D::Boolean(false)) + .with("smartBrushUseSmartRadius", D::Boolean(true)) + .with("smartBrushDecontaminate", D::Boolean(false)) + .with("smartBrushDeconAmount", pct(100.0)) + .with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask")); + let selective = desc("null").with("Mthd", enm("CrcM", "Rltv")).with( + "ClrC", + D::List(vec![ + D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", "Ylws")).with("Cyn ", pct(-30.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(-40.0)).with("Blck", pct(0.0)), + ), + D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", "Whts")).with("Cyn ", pct(0.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(0.0)).with("Blck", pct(-10.0)), + ), + ]), + ); + let lab = |l: f64| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(l)).with("A ", D::Double(0.0)).with("B ", D::Double(0.0))); + let sampled = desc("null").with("Fzns", long(40.0)).with("Mnm ", lab(80.0)).with("Mxm ", lab(95.0)).with("colorModel", long(0.0)); + let mask = desc("null") + .with("Nw ", D::Class(class("Chnl"))) + .with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }])) + .with("Usng", enm("UsrM", "RvlS")); + let fg = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }])) + .with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0)))); + let lab_fg = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("BckC") }])).with("T ", lab(50.0)); + let items = vec![ + item("autoCutout", "Select Subject", Some(desc("null").with("sampleAllLayers", D::Boolean(false)))), + item("smartBrushWorkspace", "Select and Mask", Some(smart_brush)), + item("selectiveColor", "Selective Color", Some(selective)), + item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))), + item("colorRange", "Color Range", Some(sampled)), + item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))), + item("make", "Make", Some(mask)), + item("set", "Set", Some(fg)), + item("set", "Set", Some(lab_fg)), + item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))), + item("someFutureEvent", "Something New", Some(desc("null").with("Amnt", long(5.0)))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Retouch"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let expected = [ + ("select.subject", json!({"sampleAllLayers": false})), + ( + "select.refineEdge", + json!({"radius": 3, "smartRadius": true, "smooth": 2, "feather": 0.5, "contrast": 10, "shiftEdge": -20, "decontaminate": false, "amount": 100, "output": "layerMask", "sampleAllLayers": false}), + ), + ("image.adjustments.selectiveColor", json!({"method": "relative", "yellows": [-30, 0, -40, 0], "whites": [0, 0, 0, -10]})), + ("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})), + (PHOTOSHOP_STEP, Value::Null), + (PHOTOSHOP_STEP, Value::Null), + ("layer.layerMask.revealSelection", json!({})), + ("tools.setColors", json!({"foreground": "#0000ff"})), + (PHOTOSHOP_STEP, Value::Null), + ("layer.createClippingMask", json!({})), + (PHOTOSHOP_STEP, Value::Null), + ]; + assert_eq!(steps.len(), expected.len(), "{steps:?}"); + for ((id, params), (want_id, want)) in steps.iter().zip(&expected) { + assert_eq!(id, want_id, "{steps:?}"); + if *want_id != PHOTOSHOP_STEP { + assert_eq!(params, want, "{id}"); + } + } + // Sampled Colors (Lab range), an unknown Color Range key, a Lab colour and an unknown + // event stay unsupported rather than play with other values. + let unsupported: Vec<(&str, usize)> = imp.unsupported.iter().map(|u| (u.event.as_str(), u.step)).collect(); + assert_eq!(unsupported, [("colorRange", 4), ("colorRange", 5), ("set", 8), ("someFutureEvent", 10)]); + // A legacy PhotoCraft export's `Yllw` yellows still read. + let legacy = desc("null").with("ClrC", D::List(vec![D::Descriptor(desc("ClrC").with("Clrs", enm("Clrs", "Yllw")).with("Cyn ", pct(5.0)))])); + assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]}))); +} + #[test] fn step_commands_and_bad_params_fail_gracefully() { let mut s = Session::new(); From 7072965705d1a45cf4374aad0424176d3d07da9e Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:31:17 -0400 Subject: [PATCH 0393/1452] Remove duplicate Cicp import from lib.rs --- crates/codecs/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index fb7e70c33c..2706ad2c8e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -26,13 +26,12 @@ pub mod orientation; pub mod resolution; pub mod source_settings; pub mod web; - +pub use crate::cicp::Cicp; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; -pub use crate::cicp::Cicp; pub use crate::error::CodecError; pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; From 1a98515ab10228e5f53bd008830bd5bade37bbbe Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 11:37:00 +1100 Subject: [PATCH 0394/1452] Allow layer comps to be reordered without changing the applied state. --- crates/engine/src/comps_cmds.rs | 22 +++++++ crates/engine/src/comps_cmds/tests.rs | 40 +++++++++++++ crates/ui-egui/src/comps_ui.rs | 84 ++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index fe5708387c..0b6252e782 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -234,6 +234,27 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": id})) } +fn reorder(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layerComp.reorder"; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let id = comp_param(doc, p, CMD)?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|i| *i < doc.layer_comps.len()) + .ok_or_else(|| bad(CMD, "index must name a position in the comp list"))?; + let from = index_of(doc, id); + if from != index { + s.edit("Reorder Layer Comp", |doc, _| { + let comp = doc.layer_comps.remove(from); + doc.layer_comps.insert(index, comp); + Ok(()) + })?; + } + Ok(json!({"comp": id, "index": index})) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let id = comp_param(&d.doc, p, "layerComp.duplicate")?; @@ -427,6 +448,7 @@ pub fn specs() -> Vec { spec!("layerComp.previous", "Apply Previous Layer Comp", &[], "{}", has_comps, |s, _| step(s, -1)), spec!("layerComp.next", "Apply Next Layer Comp", &[], "{}", has_comps, |s, _| step(s, 1)), spec!("layerComp.delete", "Delete Layer Comp", &[], r##"{"comp":id|name?}"##, has_comps, delete), + spec!("layerComp.reorder", "Reorder Layer Comp", &[], r##"{"comp":id|name?,"index":integer (zero-based final position)}"##, has_comps, reorder), spec!("layerComp.duplicate", "Duplicate Layer Comp", &[], r##"{"comp":id|name?} → {comp}"##, has_comps, duplicate), spec!("layerComp.rename", "Rename Layer Comp", &[], r##"{"comp":id|name?,"name":str}"##, has_comps, rename), spec!("layerComp.setComment", "Layer Comp Comment", &[], r##"{"comp":id|name?,"comment":str}"##, has_comps, set_comment), diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 5458523bcf..74df3ecd62 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -347,3 +347,43 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { assert_eq!(doc(&s).layer_comps, before); assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); } + +#[test] +fn reorder_comps_preserves_capture_and_applied_state_and_undoes() { + let (mut s, _, _) = session(8); + for name in ["One", "Two", "Three"] { + s.execute("layerComp.new", json!({"name":name})).unwrap(); + } + let before = doc(&s).layer_comps.clone(); + let applied = doc(&s).last_applied_comp; + let steps = s.active().unwrap().history.past_len(); + s.execute("layerComp.reorder", json!({"comp":"One", "index":2})).unwrap(); + assert_eq!(doc(&s).layer_comps, vec![before[1].clone(), before[2].clone(), before[0].clone()]); + assert_eq!(doc(&s).last_applied_comp, applied); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + assert!(s.undo()); + assert_eq!(doc(&s).layer_comps, before); + assert!(s.redo()); + s.execute("layerComp.reorder", json!({"comp":before[0].id, "index":0})).unwrap(); + assert_eq!(doc(&s).layer_comps, before); + let steps = s.active().unwrap().history.past_len(); + s.execute("layerComp.reorder", json!({"comp":"One", "index":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps); +} + +#[test] +fn reorder_comps_rejects_bad_indices_without_changes() { + let mut s = Session::new(); + assert!(!s.is_enabled("layerComp.reorder")); + s.execute("file.new", json!({"width":8, "height":8})).unwrap(); + assert!(!s.is_enabled("layerComp.reorder")); + s.execute("layerComp.new", json!({"name":"Only"})).unwrap(); + let before = doc(&s).layer_comps.clone(); + let steps = s.active().unwrap().history.past_len(); + for index in [Value::Null, json!(-1), json!(1), json!(1.5), json!("0"), json!(u64::MAX)] { + assert!(s.execute("layerComp.reorder", json!({"index":index})).is_err()); + } + assert!(s.execute("layerComp.reorder", json!({"comp":"Missing", "index":0})).is_err()); + assert_eq!(doc(&s).layer_comps, before); + assert_eq!(s.active().unwrap().history.past_len(), steps); +} diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index 30304f6c82..75908b8283 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -31,6 +31,17 @@ fn run(app: &mut PhotocraftApp, id: &str, p: Value) { } } +#[derive(Clone, Copy)] +struct DragComp { + document: photocraft_doc::DocId, + comp: u32, +} + +fn drop_index(from: usize, target: usize, after: bool) -> usize { + let slot = target + usize::from(after); + if from < slot { slot - 1 } else { slot } +} + pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { @@ -77,8 +88,35 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .size(11.5), ); } - for c in &doc.layer_comps { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + for (index, c) in doc.layer_comps.iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected == Some(c.id), &c.name)); + resp.dnd_set_drag_payload(DragComp { document: doc.id, comp: c.id }); + if let Some(source) = resp.dnd_hover_payload::() + && source.document == doc.id + && let Some(from) = doc.layer_comps.iter().position(|c| c.id == source.comp) + { + let after = ui.input(|i| i.pointer.hover_pos().is_some_and(|p| p.y >= rect.center().y)); + let to = drop_index(from, index, after); + let y = if after { rect.bottom() } else { rect.top() }; + ui.painter().line_segment([pos2(rect.left(), y), pos2(rect.right(), y)], Stroke::new(2.0, t.accent)); + if resp.dnd_release_payload::().is_some() && from != to { + select = Some(source.comp); + action = Some(("layerComp.reorder", json!({"comp":source.comp, "index":to}))); + } + } + resp.context_menu(|ui| { + if ui.add_enabled(index > 0, egui::Button::new(tl!("Move Up"))).clicked() { + action = Some(("layerComp.reorder", json!({"comp":c.id, "index":index.saturating_sub(1)}))); + select = Some(c.id); + ui.close(); + } + if ui.add_enabled(index + 1 < doc.layer_comps.len(), egui::Button::new(tl!("Move Down"))).clicked() { + action = Some(("layerComp.reorder", json!({"comp":c.id, "index":index + 1}))); + select = Some(c.id); + ui.close(); + } + }); if selected == Some(c.id) { ui.painter().rect_filled(rect, 0.0, t.row_selected); } else if resp.hovered() { @@ -197,6 +235,48 @@ mod tests { use super::*; use photocraft_engine::Session; + #[test] + fn dragging_a_comp_row_reorders_without_applying_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + for name in ["First", "Second", "Third"] { + app.run("layerComp.new", json!({"name":name})).unwrap(); + } + let applied = app.session.active().unwrap().doc.last_applied_comp; + let first = app.session.active().unwrap().doc.layer_comps[0].id; + let mut h = Harness::builder().with_size(vec2(360.0, 300.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + let from = h.get_by_label("First").rect().center(); + let row = h.get_by_label("Third").rect(); + let to = pos2(row.center().x, row.bottom() - 2.0); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + vec2(10.0, 0.0))); + h.run_steps(2); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let app = h.state(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layer_comps.iter().map(|c| c.name.as_str()).collect::>(), ["Second", "Third", "First"]); + assert_eq!(doc.last_applied_comp, applied); + assert_eq!(app.ui.layer_comp_selected, Some(first)); + } + + #[test] + fn comp_drop_slots_account_for_the_removed_source_row() { + assert_eq!(drop_index(0, 2, false), 1); + assert_eq!(drop_index(0, 2, true), 2); + assert_eq!(drop_index(2, 0, false), 0); + assert_eq!(drop_index(2, 0, true), 1); + assert_eq!(drop_index(1, 1, false), 1); + assert_eq!(drop_index(1, 1, true), 1); + } + #[test] fn warning_icon_clears_only_its_own_comp() { // Regression for #4197. From d7fabb52404c6bddb39a5fd438f97327ad180435 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:03:19 +0900 Subject: [PATCH 0395/1452] wip: fix Object Finder foreground classification (#6548) Use canvas-boundary coverage instead of a total-area cutoff so dominant enclosed foregrounds reach GrabCut while their surrounding backdrop stays unselected. Verify independent masks, 8/16/32-bit commands and undo, and 24 MP release timing. Closes #6548 Co-Authored-By: OpenAI Codex --- crates/algo/src/segment/grabcut.rs | 9 ++++--- crates/algo/src/segment/tests.rs | 32 +++++++++++++++++++++++ crates/algo/src/segment/workflow_tests.rs | 24 +++++++++++++++++ crates/engine/src/smartselect_cmds.rs | 16 ++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..41ee22fcd3 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -139,8 +139,8 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the /// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both /// by the distance to the seed's running mean and by the local edge step), so it stops at the -/// object's outline. A region that grows over most of the canvas is the background: nothing is -/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// object's outline. A region covering most of the canvas boundary is treated as background. +/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. @@ -164,8 +164,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let sy = (((py - canvas.y0) as usize) / step).min(h - 1); let grown = grow_similar(&lab, w, h, sx, sy); let count = grown.iter().filter(|g| **g).count(); - // Background (or a flat image): no single object under the cursor. - if count * 10 > w * h * 6 || count < 4 { + let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count(); + // shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788). + if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 { return None; } let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..19726e118d 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() { + let (w, h) = (400, 300); + for margin in [30, 20, 8] { + let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin; + let img = RgbImage::from_fn(w, h, |x, y| { + let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0; + if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] } + }); + let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let sampler = ImageSampler { img: &img, origin: (-40, 25) }; + let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25); + let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX) + .unwrap_or_else(|| panic!("missing large foreground with margin {margin}")); + let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask }; + assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}"); + assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}"); + } +} + +#[test] +fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() { + let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap(); + assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9); + assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1); + let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]); + assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + #[test] fn object_at_on_the_background_or_off_canvas_finds_nothing() { let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs index ca3be5b40f..96c6caeb8f 100644 --- a/crates/algo/src/segment/workflow_tests.rs +++ b/crates/algo/src/segment/workflow_tests.rs @@ -1,4 +1,28 @@ use super::*; + +#[test] +#[ignore = "release Object Finder timing on a 24 MP document"] +#[allow(clippy::assertions_on_constants)] +fn object_finder_large_foreground_release_timing() { + assert!(!cfg!(debug_assertions), "run with --release"); + let (w, h) = (6000, 4000); + let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] { + let start = std::time::Instant::now(); + let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX); + println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + if name == "foreground" { + let region = region.unwrap(); + assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9); + assert!(region.at(10, 10) < 0.1); + } else { + assert!(region.is_none()); + } + } +} + #[test] #[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..a7b55131e2 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -696,6 +696,22 @@ mod tests { assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); } + #[test] + fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() { + for depth in [8, 16, 32] { + let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y); + let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap(); + assert_eq!(r["changed"], false, "background at depth {depth}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + } + #[test] fn object_selection_point_honours_the_modes() { let (mut s, _) = disc(8); From aaf86e2a9cc64223fbb8a192b3f1b49aa76d5ea5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 16 Oct 2026 07:39:12 +0700 Subject: [PATCH 0396/1452] fix(psd): read the split ("quilt") warp of placed layers Photoshop writes a split custom warp as `quiltWarp` in the placed layer's SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a single 4x4 `warp` mesh was read, so such smart objects rendered unwarped and in the wrong place. Read the quilt grid into a multi-patch BezierMesh; anything inconsistent is still refused. On two real mockups (a sneaker and a V-neck jersey), the mean difference from another PSD renderer over the design area drops from 14.4 to 0.66/255 and from 13.0 to 0.64/255. Refs #4398. --- crates/io/src/blocks.rs | 201 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 187 insertions(+), 14 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..c03d7a6b25 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -525,21 +525,24 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, ) } -/// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, -/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend, +/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4 +/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the +/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` /// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - warp_of(get_desc(&d, "warp")?).flatten() + // A split (quilt) warp is stored under `quiltWarp`; `warp` then holds no usable mesh. + warp_of(get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp"))?).flatten() } -/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) -/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it -/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` +/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves +/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is +/// neither one 4 × 4 patch nor a consistent quilt grid). fn warp_of(w: &Descriptor) -> Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; @@ -561,16 +564,76 @@ fn warp_of(w: &Descriptor) -> Option> { _ => None, }; let (xs, ys) = (vals("Hrzn")?, vals("Vrtc")?); - if xs.len() != 16 || ys.len() != 16 { + if xs.len() != ys.len() { return None; } - let mesh = BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect() }; + let points: Vec<[f64; 2]> = xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect(); + let mesh = if points.len() == 16 && env.get("quiltSliceX").is_none() { + BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points } + } else { + quilt_mesh(w, env, points)? + }; warp.mesh = mesh.is_valid().then_some(mesh); warp.mesh.as_ref()?; } Some((!warp.is_identity()).then_some(warp)) } +/// The control-point grid of a split ("quilt") custom warp. `deformNumCols` × `deformNumRows` +/// control points, three per patch side plus one, in row-major order; `quiltSliceX` and +/// `quiltSliceY` are the patch boundaries in the mesh's own units, first and last at its edges, +/// which become the knots on the unit square. `None` for anything inconsistent. +fn quilt_mesh(w: &Descriptor, env: &Descriptor, points: Vec<[f64; 2]>) -> Option { + // Photoshop's split grid tops out far below this; it only bounds what a file can claim. + const MAX_SIDE: usize = 3 * 64 + 1; + let count = |k: &str| -> Option { + let n = num(w.get(k))?; + (n.is_finite() && n >= 4.0 && n <= MAX_SIDE as f64 && n.fract() == 0.0).then_some(n as usize) + }; + let (cols, rows) = (count("deformNumCols")?, count("deformNumRows")?); + if (cols - 1) % 3 != 0 || (rows - 1) % 3 != 0 || points.len() != cols.checked_mul(rows)? { + return None; + } + // Stored as an object array holding one unit-float list under the same key; a plain list + // of numbers is taken too. + let slices = |k: &str| -> Option> { + match env.get(k)? { + Value::ObjectArray(arr) => match arr.body.get(k)? { + Value::UnitFloats { values, .. } => Some(values.clone()), + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + }, + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + } + }; + let knots = |s: Vec, patches: usize| -> Option> { + let (first, last) = (*s.first()?, *s.last()?); + let span = last - first; + if s.len() != patches + 1 || !(span.is_finite() && span > 0.0) || !s.windows(2).all(|p| p[1] > p[0]) { + return None; + } + let n = s.len() - 1; + Some( + s.iter() + .enumerate() + .map(|(i, v)| { + if i == 0 { + 0.0 + } else if i == n { + 1.0 + } else { + (v - first) / span + } + }) + .collect(), + ) + }; + let us = knots(slices("quiltSliceX")?, (cols - 1) / 3)?; + let vs = knots(slices("quiltSliceY")?, (rows - 1) / 3)?; + Some(photocraft_geom::warp::BezierMesh { us, vs, points }) +} + /// Text content and transform from a `TySh` block (best effort). pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); @@ -593,13 +656,12 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// single-patch custom warp's 16 control points, which may spill past the source, else the source -/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the -/// placement. A degenerate or non-finite box falls back to the source rect. +/// custom warp's control points (one patch or a split grid), which may spill past the source, +/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice +/// and stretches the placement. A degenerate or non-finite box falls back to the source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) - else { + let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { return rect; }; let inf = f64::INFINITY; @@ -1212,6 +1274,117 @@ mod more_tests { data } + /// `SoLd` data with a split (quilt) custom warp, as Photoshop writes it: a `quiltWarp` + /// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries + /// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it. + fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec { + use photocraft_psd::descriptor::ObjectArray; + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = || { + Descriptor::new("Rctn") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(25.0)) + .with("Rght", Value::Double(50.0)) + }; + let slices = |k: &str, v: &[f64]| { + let body = Descriptor::new("null").with(k, Value::UnitFloats { unit: *b"#Pxl", values: v.to_vec() }); + Value::ObjectArray(ObjectArray { prefix: v.len() as u32, body }) + }; + let mesh = Descriptor::new("null") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() }); + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", sx)) + .with("quiltSliceY", slices("quiltSliceY", sy)) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh })); + let quilt = Descriptor::new("quiltWarp") + .with("warpStyle", e("warpStyle", "warpCustom")) + .with("bounds", Value::Descriptor(bounds())) + .with("deformNumRows", Value::Integer(rows as i32)) + .with("deformNumCols", Value::Double(cols)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)); + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(trnf.map(Value::Double).to_vec())) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + if plain_warp { + d = d.with( + "warp", + Value::Descriptor(Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpNone")).with("bounds", Value::Descriptor(bounds()))), + ); + } + d = d.with("quiltWarp", Value::Descriptor(quilt)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A 3 × 2 patch identity grid over the 50 × 25 source with one interior control point pulled. + fn quilt_points() -> Vec<[f64; 2]> { + let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2).points; + if let Some(p) = pts.get_mut(10 * 3 + 4) { + p[1] += 3.0; + } + pts + } + + /// Photoshop's split warp (`quiltWarp`, mockups' usual warp) reads as a multi-patch mesh: the + /// patch boundaries become the knots, the control points stay in order, and the quilt wins over + /// the `warpNone` `warp` key written next to it. Before, any mesh but 4 × 4 was dropped and the + /// layer re-rendered flat. + #[test] + fn quilt_warp_is_read_as_a_multi_patch_mesh() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + // Photoshop pads the outer boundaries a little past the grid's edges. + let data = sold_with_quilt(quad, 10.0, 7.0, &[-0.5, 12.5, 37.5, 50.5], &[-0.5, 12.5, 25.5], &pts, true); + let warp = parse_placed_warp(b"SoLd", &data).expect("a quilt warp"); + let mesh = warp.mesh.as_ref().expect("its mesh"); + assert_eq!(mesh.points, pts); + assert_eq!(mesh.us.len(), 4); + assert_eq!(mesh.vs.len(), 3); + assert_eq!((mesh.us[0], mesh.us[3], mesh.vs[0], mesh.vs[2]), (0.0, 1.0, 0.0, 1.0)); + assert!((mesh.us[1] - 13.0 / 51.0).abs() < 1e-12 && (mesh.vs[1] - 13.0 / 26.0).abs() < 1e-12); + assert!(mesh.is_valid()); + } + + /// A quilt's control points frame the placed quad as a single patch's do (#3677): a grid + /// spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. + #[test] + fn quilt_warp_control_box_frames_the_quad() { + let mut pts = quilt_points(); + pts[0] = [-10.0, -5.0]; + if let Some(last) = pts.last_mut() { + *last = [60.0, 25.0]; + } + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let data = sold_with_quilt(quad, 10.0, 7.0, &[0.0, 12.5, 37.5, 50.0], &[0.0, 12.5, 25.0], &pts, false); + assert_eq!(parse_smart(b"SoLd", &data).1.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]); + } + + /// An inconsistent quilt reads as no warp rather than a wrong one, and never panics. + #[test] + fn inconsistent_quilt_warps_are_not_read() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + let (sx, sy) = ([0.0, 12.5, 37.5, 50.0], [0.0, 12.5, 25.0]); + let read = + |cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]]| parse_placed_warp(b"SoLd", &sold_with_quilt(quad, cols, rows, sx, sy, pts, false)); + assert!(read(10.0, 7.0, &sx, &sy, &pts).is_some()); + assert!(read(9.0, 7.0, &sx, &sy, &pts).is_none(), "not three points per patch side plus one"); + assert!(read(13.0, 7.0, &sx, &sy, &pts).is_none(), "more points claimed than stored"); + assert!(read(10.0, 7.0, &[0.0, 12.5, 50.0], &sy, &pts).is_none(), "one slice per patch boundary"); + assert!(read(10.0, 7.0, &[0.0, 37.5, 12.5, 50.0], &sy, &pts).is_none(), "slices must increase"); + assert!(read(10.0, 7.0, &[0.0, f64::NAN, 37.5, 50.0], &sy, &pts).is_none()); + assert!(read(1e9, 7.0, &sx, &sy, &pts).is_none(), "an absurd grid is refused before any arithmetic"); + assert!(read(10.0, 7.0, &sx, &sy, &pts[..pts.len() - 1]).is_none()); + let mut nan = pts.clone(); + nan[3] = [f64::NAN, 0.0]; + assert!(read(10.0, 7.0, &sx, &sy, &nan).is_none()); + } + /// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the /// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. #[test] From f58107b50574e7d7ffc13cced453f4bdfea89479 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:13:21 +0900 Subject: [PATCH 0397/1452] docs: record Object Finder foreground detection fix Document the boundary-based classification change and keep semantic segmentation explicitly open so the parity notes do not imply Photoshop-level recognition. Closes #6548 Co-Authored-By: OpenAI Codex --- ROADMAP.md | 4 +++- docs/gaps.md | 4 +++- docs/ui-parity.md | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..a4e7ac1b00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548)) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | @@ -135,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..8117b3a985 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Classical Object Finder improvement; learned segmentation remains open) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -141,6 +141,7 @@ what an agent can't finish alone. exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). - **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). +- **Progress (2026-10-17):** classical Object Finder no longer rejects a foreground solely for covering over 60% of the canvas (#6548); canvas-boundary coverage distinguishes it from the surrounding backdrop. This does not add semantic recognition or close #2788. - **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). ### G16. Plug-in compatibility @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Classical Object Finder improvement; learned segmentation remains open | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..813be83193 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder foreground/background discrimination (#6548)) · **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, @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Dominant enclosed objects are retained; regions covering most of the canvas boundary are treated as background (#6548). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | @@ -227,6 +227,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From e31905358d9a25f1882cb97a575dd8dc91d7c5b7 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:32:44 +0800 Subject: [PATCH 0398/1452] Restore export preset field omitted by upstream preset loader --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From 3f89289b84e5abbe774c3a4d91ea4ad1d1bf9a12 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 22:13:08 -0400 Subject: [PATCH 0399/1452] test(ui): a toolbar click with no prior hover picks the tool (part of #2603) The tool buttons are plain Sense::click widgets and need no hover first. The offset the report describes is #2246, fixed by #2401 in 0.7.0. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7f2eadd084..b784e850fc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -5691,6 +5691,24 @@ mod toolbar_tests { assert_eq!(h.state().ui.tool, Tool::Zoom); assert!(h.state().session.active().is_none()); } + + /// A press and release with no pointer move before it (first click after focus, a pen or + /// touch tap) picks the tool: the button doesn't need to have been hovered (#2603). + #[test] + fn a_click_without_a_prior_hover_picks_the_tool() { + for tool in [Tool::Brush, Tool::Type] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Move; + let mut h = toolbar_harness(app); + let p = tool_button(&h, tool); + assert!(h.ctx.input(|i| i.pointer.latest_pos()).is_none(), "the pointer has not entered the window"); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + assert_eq!(h.state().ui.tool, tool); + } + } } #[cfg(test)] From 85acfeee65de6ec10e7687384648b92853e306ef Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 22:14:47 -0400 Subject: [PATCH 0400/1452] Background redraw: keep the last finished composites, so a solo's restore uploads instead of recompositing (part of #6528) Over the GPU memory budget a large visibility change is redrawn by the CPU compositor in blocks, and nothing kept the result: Alt-click on an eye and again recomposited every layer (4 s on a 50 MP PSB, ~100 s on a multi-GB one). The last two finished redraws are now kept, keyed by the whole document they drew (compared with ==, as the GPU prefix cache does), the display texture key, the texture format and the block grid; a redraw of an equal state uploads those blocks. Bounded by what Memory Usage leaves beside the document and its History, dropped with the document. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/background_redraw.rs | 177 ++++++++++++++++-- crates/ui-egui/src/background_redraw_tests.rs | 91 +++++++++ crates/ui-egui/src/lib.rs | 3 + 3 files changed, 256 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs index e8196bc1c2..9b3837d535 100644 --- a/crates/ui-egui/src/background_redraw.rs +++ b/crates/ui-egui/src/background_redraw.rs @@ -10,7 +10,12 @@ //! the edit's own damage is drawn by the canvas as usual. A full change (a new preview, a new //! display transform) starts over. Without background jobs (web, tests) the canvas keeps the //! synchronous [`GpuCanvas::upload_composite`]. +//! +//! The last finished redraws are kept ([`Kept`]), keyed by the whole document they drew (#6528): +//! going back to a state drawn a moment ago (⌥-click on an eye and again, an undo and its redo) +//! uploads those blocks instead of compositing every layer again. +use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError}; @@ -32,6 +37,12 @@ const BLOCK: i32 = 1024; /// finished blocks may wait for the UI thread: bounds the memory a redraw holds. const BATCH: usize = 4; +/// Finished redraws kept: a state and the one before it (a solo and its restore). +const KEEP: usize = 2; + +/// [`GpuCanvas::refresh`] kind of a redraw served from a [`Kept`] composite. +pub(crate) const KEPT: &str = "kept"; + /// One finished block, as texels of the canvas texture. struct Piece { block: usize, @@ -49,6 +60,15 @@ pub(crate) struct Redraw { stop: Arc, started_ms: f64, fallback: Option, + /// The state the blocks are drawn from, and what the texels depend on besides it. + doc: Arc, + display: u64, + format: eframe::wgpu::TextureFormat, + /// Uploaded blocks of `doc` kept for [`Kept`] (empty once over `cap`), and their bytes. + collected: Vec>>, + collected_bytes: u64, + /// Bytes the kept composites may hold: what Memory Usage leaves beside the document. + cap: u64, } impl Redraw { @@ -64,6 +84,72 @@ impl Drop for Redraw { } } +/// A finished redraw: the canvas texels of every block of `doc`. +pub(crate) struct Kept { + doc: Arc, + display: u64, + format: eframe::wgpu::TextureFormat, + blocks: Vec, + texels: Vec>, + bytes: u64, +} + +impl Kept { + /// Whether these are the texels a redraw of `doc` would draw. The key is the whole document + /// (layers and their pixels, visibility, opacity, blending, masks, adjustments and everything + /// else it holds), so a stale composite can't come back; Arc-shared tiles compare without + /// reading their pixels. + fn matches(&self, doc: &Arc, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool { + self.display == display && self.format == format && self.blocks == blocks && (Arc::ptr_eq(&self.doc, doc) || *self.doc == **doc) + } +} + +/// The last [`KEEP`] finished redraws, newest first, within a byte cap. +#[derive(Default)] +pub(crate) struct KeptComposites { + entries: VecDeque, +} + +impl KeptComposites { + /// The kept redraw of exactly this state, now the newest. + fn find(&mut self, doc: &Arc, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> Option<&Kept> { + let i = self.entries.iter().position(|k| k.matches(doc, display, format, blocks))?; + let k = self.entries.remove(i)?; + self.entries.push_front(k); + self.entries.front() + } + + /// Keep `k` (in place of an entry of the same state), within `cap` bytes in all. + fn store(&mut self, k: Kept, cap: u64) { + self.entries.retain(|e| !e.matches(&k.doc, k.display, k.format, &k.blocks)); + self.entries.push_front(k); + self.trim(cap); + } + + /// Drop the oldest entries until at most [`KEEP`], holding at most `cap` bytes, are left. + fn trim(&mut self, cap: u64) { + while self.entries.len() > KEEP || self.bytes() > cap { + self.entries.pop_back(); + } + } + + fn bytes(&self) -> u64 { + self.entries.iter().fold(0, |n, k| n.saturating_add(k.bytes)) + } + + fn retain(&mut self, live: impl Fn(DocId) -> bool) { + self.entries.retain(|k| live(k.doc.id)); + } +} + +/// Bytes kept composites of document `doc` may hold: what Memory Usage leaves beside its pixels +/// and History. It shrinks as the document grows, which drops them under memory pressure. +fn keep_cap(app: &PhotocraftApp, doc: DocId) -> u64 { + let budget = app.session.prefs().performance.history_budget_bytes() as u64; + let used = app.session.documents().iter().find(|st| st.doc.id == doc).map_or(u64::MAX, |st| st.memory_bytes() as u64); + budget.saturating_sub(used) +} + /// Whether full CPU refreshes run in the background here. pub(crate) fn enabled(app: &PhotocraftApp) -> bool { cfg!(not(target_arch = "wasm32")) && app.background_jobs @@ -112,16 +198,46 @@ pub(crate) fn start( fallback: Option, ) { let all = blocks(doc.bounds()); - let (mut pending, since, fallback) = match app.redraws.remove(&doc.id) { - Some(old) if old.blocks == all => (old.pending.clone(), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.clone())), - _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback), + let display_key = display.as_deref().map_or(0, |d| d.texture_key); + let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) { + Some(mut old) if old.blocks == all => { + // Blocks drawn from another state aren't part of this one's composite. + let same = old.display == display_key && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc); + let (collected, bytes) = if same { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) }; + (std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, old.cap)) + } + _ => { + let cap = keep_cap(app, doc.id); + app.kept.trim(cap); + (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, (vec![None; all.len()], 0, cap)) + } }; for (p, b) in pending.iter_mut().zip(&all) { *p |= !b.intersect(®ion).is_empty(); } - if pending.contains(&true) { - spawn(app, ctx, gpu, doc, display, visible, all, pending, since, fallback); + if !pending.contains(&true) { + return; } + let Some(format) = gpu.prepare_texture(doc.id.0, &doc) else { return }; + // This exact state was drawn a moment ago: upload its blocks rather than composite them. + if let Some(k) = app.kept.find(&doc, display_key, format, &all) { + let t0 = crate::gpu_canvas::now_ms(); + let mut px = 0u64; + for (i, p) in pending.iter_mut().enumerate() { + if let (true, Some(b), Some(t)) = (*p, all.get(i), k.texels.get(i)) + && gpu.upload_rect(doc.id.0, [b.x0 as u32, b.y0 as u32], [b.width(), b.height()], t) + { + *p = false; + px = px.saturating_add(b.width() as u64 * b.height() as u64); + } + } + if !pending.contains(&true) { + app.perf.record(KEPT, px, 0.0, crate::gpu_canvas::now_ms() - t0); + return; + } + keep = (vec![None; all.len()], 0, keep.2); + } + spawn(app, ctx, Spawn { doc, display, display_key, format, visible, blocks: all, pending, since, fallback, keep }); } /// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on @@ -145,22 +261,43 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { r.pending[p.block] = false; r.left -= 1; r.drawn += b.width() as u64 * b.height() as u64; + collect(r, p); } } Err(TryRecvError::Empty) => return, Err(TryRecvError::Disconnected) => break, } } - let Some(r) = app.redraws.remove(&doc) else { return }; + let Some(mut r) = app.redraws.remove(&doc) else { return }; if r.left > 0 { log::warn!("canvas redraw stopped with {} of {} blocks left", r.left, r.blocks.len()); return; } + if r.collected.len() == r.blocks.len() && r.collected.iter().all(Option::is_some) { + let texels = std::mem::take(&mut r.collected).into_iter().flatten().collect(); + let k = Kept { doc: r.doc.clone(), display: r.display, format: r.format, blocks: r.blocks.clone(), texels, bytes: r.collected_bytes }; + app.kept.store(k, r.cap); + } let ms = crate::gpu_canvas::now_ms() - r.started_ms; app.perf.record("background", r.drawn, ms, 0.0); crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_deref()); } +/// Keep uploaded block `p` for [`Kept`], unless the composite would pass the cap. +fn collect(r: &mut Redraw, p: Piece) { + let len = p.texels.len() as u64; + let Some(slot) = r.collected.get_mut(p.block) else { return }; + let before = slot.as_ref().map_or(0, |t| t.len() as u64); + let bytes = r.collected_bytes.saturating_sub(before).saturating_add(len); + if bytes > r.cap { + r.collected = Vec::new(); + r.collected_bytes = 0; + return; + } + *slot = Some(p.texels); + r.collected_bytes = bytes; +} + /// The active document's redraw in the status bar: "Redrawing" and how much is done. pub(crate) fn status(app: &PhotocraftApp, ui: &mut egui::Ui) { let Some(r) = app.session.active().and_then(|st| app.redraws.get(&st.doc.id)) else { return }; @@ -178,23 +315,30 @@ pub(crate) fn cancel(app: &mut PhotocraftApp, doc: DocId) { pub(crate) fn retain(app: &mut PhotocraftApp) { let docs = app.session.documents(); app.redraws.retain(|id, _| docs.iter().any(|st| st.doc.id == *id)); + app.kept.retain(|id| docs.iter().any(|st| st.doc.id == id)); } -#[allow(clippy::too_many_arguments)] -fn spawn( - app: &mut PhotocraftApp, - ctx: &egui::Context, - gpu: &GpuCanvas, +/// What [`spawn`] starts a redraw with. +struct Spawn { doc: Arc, display: Option>, + display_key: u64, + format: eframe::wgpu::TextureFormat, visible: Rect, blocks: Vec, pending: Vec, - (started_ms, drawn): (f64, u64), + /// When it started and the pixels uploaded so far. + since: (f64, u64), fallback: Option, -) { + /// Blocks collected for [`Kept`], their bytes, and the cap. + keep: (Vec>>, u64, u64), +} + +fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { + let Spawn { doc, display, display_key, format, visible, blocks, pending, since: (started_ms, drawn), fallback, keep: (collected, collected_bytes, cap) } = + s; let id = doc.id; - let Some(format) = gpu.prepare_texture(id.0, &doc) else { return }; + let kept_doc = doc.clone(); let todo = order(&blocks, (0..blocks.len()).filter(|&i| pending[i]), visible); let left = todo.len(); let (tx, rx) = std::sync::mpsc::sync_channel(BATCH); @@ -210,7 +354,10 @@ fn spawn( log::warn!("couldn't start the canvas redraw: {e}"); return; } - app.redraws.insert(id, Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback }); + app.redraws.insert( + id, + Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback, doc: kept_doc, display: display_key, format, collected, collected_bytes, cap }, + ); } /// The worker: composite `todo`'s blocks of `doc` in batches and hand each over, until done or diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index 650fccaa33..220681fc7f 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -31,6 +31,73 @@ fn visible_blocks_come_first_nearest_the_view_centre() { assert_eq!(order(&b, [3, 9].into_iter(), visible).len(), 2); } +/// A kept composite of `doc`: `bytes` texels per block. +fn kept(doc: &Arc, bytes: usize) -> Kept { + let all = blocks(doc.bounds()); + let texels: Vec> = all.iter().map(|_| vec![1u8; bytes]).collect(); + Kept { doc: doc.clone(), display: 7, format: eframe::wgpu::TextureFormat::Rgba8Unorm, bytes: (bytes * all.len()) as u64, blocks: all, texels } +} + +#[test] +fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { + use eframe::wgpu::TextureFormat::{Rgba8Unorm, Rgba16Float}; + let mut a = Document::new("kept", photocraft_geom::Size::new(2000, 1500), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let mut layer = photocraft_doc::Layer::raster("one", a.pixel_format()); + if let photocraft_doc::LayerContent::Raster(s) = &mut layer.content { + s.fill_rect(Rect::new(0, 0, 300, 300), &[0.2, 0.4, 0.6, 1.0]); + } + a.layers.push(layer); + let a = Arc::new(a); + let all = blocks(a.bounds()); + let changed = |f: &dyn Fn(&mut Document)| { + let mut d = (*a).clone(); + f(&mut d); + Arc::new(d) + }; + let mut cache = KeptComposites::default(); + cache.store(kept(&a, 4), u64::MAX); + // The same state hits, also as an equal document that isn't the same Arc (a restore after a solo). + assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); + assert!(cache.find(&Arc::new((*a).clone()), 7, Rgba8Unorm, &all).is_some()); + // Any change to what the composite depends on misses. + let hidden = changed(&|d| d.layers[0].visible = false); + let faded = changed(&|d| d.layers[0].opacity = 0.5); + let multiplied = changed(&|d| d.layers[0].blend = photocraft_color::BlendMode::Multiply); + let painted = changed(&|d| { + if let photocraft_doc::LayerContent::Raster(s) = &mut d.layers[0].content { + s.fill_rect(Rect::new(10, 10, 11, 11), &[1.0, 0.0, 0.0, 1.0]); + } + }); + for (what, d) in [("visibility", &hidden), ("opacity", &faded), ("blend mode", &multiplied), ("pixels", &painted)] { + assert!(cache.find(d, 7, Rgba8Unorm, &all).is_none(), "{what} changed"); + } + assert!(cache.find(&a, 8, Rgba8Unorm, &all).is_none(), "another display transform"); + assert!(cache.find(&a, 7, Rgba16Float, &all).is_none(), "another texture format"); + assert!(cache.find(&a, 7, Rgba8Unorm, &all[1..]).is_none(), "other blocks"); + // At most KEEP entries, the least recently used going first. + cache.store(kept(&hidden, 4), u64::MAX); + assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); + cache.store(kept(&faded, 4), u64::MAX); + assert_eq!(cache.entries.len(), KEEP); + assert!(cache.find(&hidden, 7, Rgba8Unorm, &all).is_none(), "the least recently used went"); + assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, Rgba8Unorm, &all).is_some()); + // Storing a state again replaces its entry. + cache.store(kept(&faded, 4), u64::MAX); + assert_eq!(cache.entries.len(), 2); + // The byte cap: a smaller one drops the oldest, and a composite over it isn't kept. + let one = kept(&a, 4).bytes; + cache.trim(one); + assert_eq!(cache.entries.len(), 1); + assert!(cache.bytes() <= one); + cache.trim(0); + cache.store(kept(&a, 4), one - 1); + assert!(cache.entries.is_empty(), "over the cap"); + // A closed document's composites go. + cache.store(kept(&a, 4), u64::MAX); + cache.retain(|id| id != a.id); + assert!(cache.entries.is_empty()); +} + struct Gpu { harness: egui_kittest::Harness<'static, PhotocraftApp>, observer: GpuCanvas, @@ -157,6 +224,30 @@ fn an_edit_damaging_most_of_a_large_document_is_drawn_in_the_background_too() { assert_matches_synchronous(&mut g, doc); } +#[test] +fn showing_a_hidden_layer_again_reuses_the_redraw_from_before_it_was_hidden() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + let layer = g.harness.state().session.active().and_then(|st| st.active_layer).expect("layer"); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide"); + settle(&mut g); + // Back to the state drawn before: its blocks come back, nothing is composited. + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": true})).expect("show"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty(), "no redraw: {}", g.harness.state().perf.last_refresh); + assert_eq!(g.harness.state().perf.last_refresh, KEPT); + assert_matches_synchronous(&mut g, doc); + // A real change composites again. + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "opacity": 0.3})).expect("opacity"); + g.harness.step(); + assert_ne!(g.harness.state().perf.last_refresh, KEPT); + settle(&mut g); + assert_matches_synchronous(&mut g, doc); +} + #[test] fn closing_the_document_stops_its_redraw() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e82be7107..2a6de18774 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -680,6 +680,8 @@ pub struct PhotocraftApp { filter_preview_worker: filter_preview_worker::Worker, /// Full CPU canvas redraws running in the background, by document (#4024). pub(crate) redraws: std::collections::HashMap, + /// The last finished background redraws, for going back to a state drawn a moment ago (#6528). + pub(crate) kept: background_redraw::KeptComposites, /// Select › Color Range dialog preview (proxy document + mask / image textures). pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. @@ -886,6 +888,7 @@ impl PhotocraftApp { #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: Default::default(), redraws: Default::default(), + kept: Default::default(), color_range: None, adjust_preview: None, synthetic: Vec::new(), From 7c44b2bc152bd2c6269942ee4be09f6779be9f12 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 02:19:47 +0000 Subject: [PATCH 0401/1452] Build: use craft-docpath from craft-libs --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e1206c2277..6c3369fdec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,6 +1225,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" [[package]] name = "craft-print" diff --git a/Cargo.toml b/Cargo.toml index f215b0d049..1d65deac3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } +craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From 1be48a8360379b01ef5ded05c5ff1a52c92fd5c9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 22:38:29 -0400 Subject: [PATCH 0402/1452] fix(web): one press per tap, so menus open again on touch (#2095) In the browser eframe 0.36 turns one finger or pen tap into two primary presses and two releases: one pair from Pointer Events (pointerdown runs a frame of its own) and one from Touch Events (touchstart/touchend). The menu bar titles and press menus toggle on the press, so the twin press closed the menu the first had just opened. The web shell's raw_input_hook now drops a primary press that arrives while the button is already held (pressed within 0.5 s) and a release while it is already up. A press held longer still passes, so a lost release can't lock the pointer down. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 6 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/touch_twins.rs | 107 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 crates/ui-egui/src/touch_twins.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..b855befb88 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -283,6 +283,12 @@ impl WebShell { } impl eframe::App for WebShell { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the + // twin press closes the menu the first one opened (#2095). + photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input); + } + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..f66ee2e24e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,6 +214,7 @@ mod timeline_ui; mod titlebar; pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; +pub mod touch_twins; mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; diff --git a/crates/ui-egui/src/touch_twins.rs b/crates/ui-egui/src/touch_twins.rs new file mode 100644 index 0000000000..a000d0b05b --- /dev/null +++ b/crates/ui-egui/src/touch_twins.rs @@ -0,0 +1,107 @@ +//! One tap, one press (#2095). In the browser eframe turns a finger or pen tap into two presses +//! and two releases: one pair from Pointer Events (`pointerdown` / `pointerup`) and one from Touch +//! Events (`touchstart` / `touchend`), which mobile browsers fire for the same contact. A menu +//! title or press menu toggles on the press, so the second press closed the menu the first had +//! just opened. [`drop_twins`] keeps the first of each and drops its twin. + +use egui::{Event, PointerButton, RawInput}; + +/// Longest gap, in seconds, between a press and its twin from the other event family (both come +/// from one contact, normally within a frame or two). +const TWIN_WINDOW: f64 = 0.5; + +/// Drops a primary press that arrives while the primary button is already held (pressed within +/// [`TWIN_WINDOW`]) and a primary release that arrives while it is already up. A press while held +/// for longer still passes, so a release the browser never sent can't lock the pointer down. +/// Call from `raw_input_hook`, before the frame runs. +pub fn drop_twins(ctx: &egui::Context, raw: &mut RawInput) { + let (mut down, mut pressed_at, last) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_start_time(), i.time)); + let now = raw.time.unwrap_or(last + f64::from(raw.predicted_dt)); + raw.events.retain(|e| { + let Event::PointerButton { button: PointerButton::Primary, pressed, .. } = e else { return true }; + if *pressed != down { + down = *pressed; + pressed_at = pressed.then_some(now); + return true; + } + // A press while held is a twin only while fresh; a release while up always is. + *pressed && !pressed_at.is_some_and(|t| now - t <= TWIN_WINDOW) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Modifiers, Pos2, pos2}; + + fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } + } + + fn touch(pos: Pos2, phase: egui::TouchPhase) -> Event { + Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(7), phase, pos, force: None } + } + + /// One browser tap as eframe delivers it: `pointerdown` runs a frame of its own, then + /// `touchstart`, `pointerup` (again a frame of its own) and `touchend`. + fn tap_frames(pos: Pos2) -> [Vec; 4] { + [ + vec![button(pos, true)], + vec![button(pos, true), touch(pos, egui::TouchPhase::Start)], + vec![button(pos, false)], + vec![button(pos, false), Event::PointerGone, touch(pos, egui::TouchPhase::End)], + ] + } + + #[test] + fn a_tap_opens_a_menu_title_every_time() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app, + ); + crate::PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + let file = harness.get_by_label("File").rect().center(); + let ctx = harness.ctx.clone(); + let tap = |harness: &mut Harness<'_, crate::PhotocraftApp>, dedupe: bool| { + for events in tap_frames(file) { + harness.input_mut().events = events; + if dedupe { + drop_twins(&ctx, harness.input_mut()); + } + harness.step(); + } + harness.run_steps(2); + harness.query_by_label_contains("Open…").is_some() + }; + // Without the hook the second press closes the menu the first opened. + assert!(!tap(&mut harness, false), "the cause: the twin press closes the menu"); + assert!(tap(&mut harness, true), "the first tap opens File"); + assert!(!tap(&mut harness, true), "a second tap closes it"); + assert!(tap(&mut harness, true), "a third tap opens it again"); + } + + #[test] + fn a_press_held_for_long_still_passes() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let frame = |events: Vec, time: f64| { + let mut raw = RawInput { time: Some(time), events, ..Default::default() }; + drop_twins(&ctx, &mut raw); + let kept = raw.events.clone(); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + kept + }; + assert_eq!(frame(vec![button(p, true)], 1.0).len(), 1); + assert!(frame(vec![button(p, true)], 1.1).is_empty(), "the twin press"); + // The release never came: a later press is a new one, not a twin. + assert_eq!(frame(vec![button(p, true)], 3.0).len(), 1); + assert_eq!(frame(vec![button(p, false), button(p, false)], 3.1).len(), 1, "one release per press"); + assert!(frame(vec![button(p, false)], 3.2).is_empty(), "a release while up"); + } +} From 97a42733ab30c2e66017057fcd04a39b105d7be8 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Fri, 16 Oct 2026 22:44:01 -0400 Subject: [PATCH 0403/1452] test pasting a layer style onto the background --- .../engine/tests/background_and_mask_depth.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/engine/tests/background_and_mask_depth.rs b/crates/engine/tests/background_and_mask_depth.rs index 2011205e62..1fa400e244 100644 --- a/crates/engine/tests/background_and_mask_depth.rs +++ b/crates/engine/tests/background_and_mask_depth.rs @@ -71,3 +71,32 @@ fn frame_and_remove_background_masks_match_the_document_depth() { } } } + +fn copied_style_over_background() -> (Session, u64, u64) { + let mut s = background_only(); + let bg = s.active().unwrap().doc.background().unwrap().id.0; + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap().0; + (s, bg, layer) +} + +#[test] +fn pasting_a_style_onto_the_background_turns_it_into_a_layer() { + let (mut s, bg, _) = copied_style_over_background(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": bg})).unwrap(); + assert!(!has_background(&s), "the pasted style landed on a Background"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "one undo restores the Background"); +} + +#[test] +fn pasting_a_style_with_no_layer_skips_the_background() { + let (mut s, bg, layer) = copied_style_over_background(); + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("layer.select", json!({"layer": layer, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + assert!(has_background(&s)); + assert!(s.active().unwrap().doc.background().unwrap().effects.items.is_empty()); +} From e8834e8d938d53e48e07a015d41889048107b2db Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 00:06:18 -0400 Subject: [PATCH 0404/1452] Selections don't mark a document modified (#6865) Making, changing or dropping a selection (marquee, lasso, Magic Wand, Quick / Object Selection, Select menu) is still a history step, but it no longer dirties a clean document, nor do its undo, redo or a History panel jump over selection-only states. A step counts as selection-only when the documents differ in nothing but `selection` (`layer_multi_cmds::selection_only_change`: pixels by tile pointer, every other field structurally, exhaustive so new fields must be classified). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/history_select_cmds.rs | 4 +- crates/engine/src/layer_multi_cmds.rs | 64 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 28 +++++++++-- crates/engine/src/tests.rs | 32 ++++++++++++ 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/history_select_cmds.rs b/crates/engine/src/history_select_cmds.rs index e05a859a5a..2409722121 100644 --- a/crates/engine/src/history_select_cmds.rs +++ b/crates/engine/src/history_select_cmds.rs @@ -59,8 +59,10 @@ fn select_state(s: &mut Session, p: &Value) -> Result { } let damage = crate::layer_multi_cmds::step_damage(&start, &doc); st.doc = doc; + // Stepping only over selection changes keeps a clean document clean (#6865). + let keep_clean = st.keeps_clean(&start); crate::restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; if at != target { // Not reached (the bounds were checked): the document is consistent at the state reached. diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..f01046db37 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -745,6 +745,70 @@ pub fn same_pixels(a: &Document, b: &Document) -> bool { doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0) } +/// Whether `a` and `b` differ at most in their selection (#6865): a selection step, which must +/// not mark a clean document modified. Pixels compare by tile pointer ([`same_pixels`], which +/// also turns down video layers, whose own equality is shallow); every other field compares +/// structurally, where copy-on-write `Arc`s of `Eq` data short-circuit on pointer identity. The +/// destructure is exhaustive, so a new `Document` field fails to compile until it is classified. +pub fn selection_only_change(a: &Document, b: &Document) -> bool { + let Document { + id, + name, + size: _, + resolution_dpi: _, + mode: _, + depth: _, + icc_profile: _, + layers, + channels: _, + guides, + ruler_origin, + selection: _, + metadata, + global_light: _, + paths, + work_path, + clipping_path, + quick_mask: _, + quick_mask_unselected, + patterns: _, + color_table: _, + duotone: _, + layer_comps, + last_applied_comp, + last_document_state, + measurement, + notes, + text_styles, + slices, + variables, + timeline, + file_encoding, + } = a; + // The ignored fields above are the pixel ones `same_pixels` already compares. + same_pixels(a, b) + && *id == b.id + && *name == b.name + && *layers == b.layers + && *guides == b.guides + && *ruler_origin == b.ruler_origin + && *metadata == b.metadata + && *paths == b.paths + && *work_path == b.work_path + && *clipping_path == b.clipping_path + && *quick_mask_unselected == b.quick_mask_unselected + && *layer_comps == b.layer_comps + && *last_applied_comp == b.last_applied_comp + && *last_document_state == b.last_document_state + && *measurement == b.measurement + && *notes == b.notes + && *text_styles == b.text_styles + && *slices == b.slices + && *variables == b.variables + && *timeline == b.timeline + && *file_encoding == b.file_encoding +} + /// Pixels a single history step can have changed between `a` and `b` (the documents on either /// side of an edit, its undo or its redo): the union of the change bounds of every layer whose /// pixels differ, so a local edit recomposites only that area instead of the whole canvas. A diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22b1e6d728..2c69d977ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -297,6 +297,21 @@ impl DocState { pub fn is_dirty(&self) -> bool { self.revision != self.saved_revision } + /// Whether the step from `before` to the current document leaves a clean document clean: + /// it changed nothing but the selection (#6865). Making, changing or dropping a selection + /// (marquee, lasso, Magic Wand, Quick / Object Selection, Select › …) is a history step but + /// not an edit, so it neither marks the file modified nor makes closing ask to save. + pub(crate) fn keeps_clean(&self, before: &Document) -> bool { + self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc) + } + /// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved + /// revision with it. + pub(crate) fn bump_revision(&mut self, keep_clean: bool) { + self.revision += 1; + if keep_clean { + self.saved_revision = self.revision; + } + } /// The targeted layers, as history stores them with each state. fn layer_target(&self) -> LayerTarget { LayerTarget { active: self.active_layer, selected: self.selected_layers.clone() } @@ -830,6 +845,7 @@ impl Session { // duplicate or a paste), and everything when that can't be bounded. A command may still // report its own, tighter damage after this. let damage = layer_multi_cmds::step_damage(&before, &st.doc); + let keep_clean = st.keeps_clean(&before); let key = self.coalesce_request.clone(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let layers = st.layer_target(); @@ -841,7 +857,7 @@ impl Session { st.history.set_current_layers(layers); } st.coalesce = key; - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; self.samplers_removed += samplers_removed; if damage.is_none_or(|d| !d.is_empty()) { @@ -930,9 +946,10 @@ impl Session { // Pixels this step can have touched, so the canvas recomposites only that // (it recomposited everything before). let damage = layer_multi_cmds::step_damage(&d, &st.doc); - st.doc = d; + let before = std::mem::replace(&mut st.doc, d); + let keep_clean = st.keeps_clean(&before); restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; true } @@ -952,9 +969,10 @@ impl Session { Arc::make_mut(&mut d).name.clone_from(&st.doc.name); } let damage = layer_multi_cmds::step_damage(&st.doc, &d); - st.doc = d; + let before = std::mem::replace(&mut st.doc, d); + let keep_clean = st.keeps_clean(&before); restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; true } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..6e4c59c519 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1129,6 +1129,38 @@ fn selecting_a_layer_does_not_dirty_the_document() { assert!(s.active().unwrap().is_dirty()); } +#[test] +fn selection_tools_do_not_dirty_the_document() { + // #6865: marquee, lasso, Select All / Inverse / Deselect and their undo and redo are + // history steps, but not edits, so a clean document stays clean. + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 20, "width": 8, "height": 8, "ellipse": true, "mode": "add"})).unwrap(); + s.execute("select.lasso", json!({"points": [[0, 0], [40, 0], [20, 30]], "mode": "subtract"})).unwrap(); + s.execute("select.inverse", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().history.past_len() > past, "selections are still history steps"); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: selecting is not an edit"); + assert!(s.undo() && s.undo()); + assert!(s.redo()); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: undoing a selection is not an edit"); + s.execute("history.selectState", json!({"state": past})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: stepping back over selections is not an edit"); + // A real edit still dirties, with or without a selection, and a selection can't clean it. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + assert!(s.undo()); + assert!(s.active().unwrap().is_dirty(), "the new layer is still unsaved"); + } +} + #[test] fn coalesced_edits_share_one_history_step() { let mut s = Session::new(); From 425e782ac2fd4d3f754d9015b6925f44aac4ce30 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 00:25:15 -0400 Subject: [PATCH 0405/1452] Pen: a finished path keeps its anchor squares on screen (#6884) Closing a path or pressing Enter with the Pen left only the 1 px outline, so the path looked gone and the anchors to edit were hidden. The Pen, Freeform Pen and Curvature Pen now draw the active paths' anchors as Path Selection does, as in Photoshop. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..205e47aaf9 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -978,6 +978,14 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a painter.circle_filled(handle, 3.0, accent); } +/// Does `tool` draw the anchor squares of the paths it can edit? Path Selection does, and so do +/// the Pen tools: as in Photoshop, a path finished with Enter or by closing it keeps its anchors +/// on screen, so it can still be edited (⌘/Ctrl-drag, Auto Add/Delete) (#6884, #4716). +pub(crate) fn shows_anchors(tool: Tool) -> bool { + // Add/Delete Anchor Point and Convert Point draw Direct Selection's overlay instead. + matches!(tool, Tool::PathSelection | Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen) +} + /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; @@ -1001,20 +1009,21 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct - // Selection has its own overlay above. + // Paths remain visible as outlines with any tool. Path Selection and the Pen tools add + // anchor squares; Direct Selection has its own overlay above. + let anchors = shows_anchors(tool); if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); + draw_path(wp, anchors); } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content { - draw_path(&sh.path, tool == Tool::PathSelection); + draw_path(&sh.path, anchors); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { - draw_path(&p, tool == Tool::PathSelection); + draw_path(&p, anchors); } crate::corner_radius::draw(app, painter, &to_scr, accent); } @@ -2036,6 +2045,30 @@ mod tests { out.shapes.len() } + #[test] + fn pen_keeps_anchors_of_a_finished_path() { + // #6884: a path finished with Enter, or by closing it, lost its anchor squares while the + // Pen stayed active, so it looked gone and could not be edited. + let ctx = egui::Context::default(); + let mut app = app(); + app.ui.tool = Tool::Pen; + let knots = vec![[[10.0, 10.0]; 3], [[80.0, 10.0]; 3], [[80.0, 80.0]; 3]]; + app.ui.pen = Some(PenPath { knots, ..Default::default() }); + pen_commit(&mut app, true); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + let with_pen = overlay_shapes(&app, &ctx); + app.ui.tool = Tool::Brush; + let outline_only = overlay_shapes(&app, &ctx); + assert!(outline_only > 0, "the outline is drawn with any tool"); + // Each of the three anchors adds its square (fill and stroke). + assert!(with_pen >= outline_only + 3, "Pen draws the anchors: {with_pen} vs {outline_only}"); + for tool in [Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::PathSelection] { + assert!(shows_anchors(tool), "{tool:?}"); + } + assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move)); + } + #[test] fn show_target_path_and_extras_hide_the_path_outlines() { // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the From d9dc34711b224656ae00bf9db84d125031952405 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:04:02 +0200 Subject: [PATCH 0406/1452] Don't pan the canvas when a dialog's title bar is dragged (#6810) --- crates/ui-egui/src/dialogs.rs | 50 +++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 013c79e825..8730dc09f7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -19,6 +19,26 @@ pub(crate) fn titlebar_drag_area_is_clear(ctx: &egui::Context, area: egui::Rect) rects(ctx).iter().all(|dialog| !dialog.intersects(area)) } +/// Whether the press that is being held began on a dialog (#6810). The dialog follows the pointer +/// while its title is dragged, so the press origin ends up outside the (moved) dialog and a canvas +/// check against the dialog rects alone would take the drag for a canvas drag. Remembered from the +/// frame of the press, which is the only frame that carries the press event. +fn press_began_on_dialog(ctx: &egui::Context, rects: &[egui::Rect]) -> bool { + let key = egui::Id::new("pc-dialog-press-grabbed"); + let pressed = ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos), + _ => None, + }) + }); + if let Some(p) = pressed { + let on_dialog = rects.iter().any(|r| r.contains(p)); + ctx.data_mut(|m| m.insert_temp(key, on_dialog)); + return on_dialog; + } + ctx.data(|m| m.get_temp::(key)).unwrap_or(false) +} + /// With a dialog open the rest of the window is inert (egui's modal layer), but like Photoshop the /// image can still be panned and zoomed under it. The pointer position when it is over `canvas` /// and not over a dialog or one of its popups. @@ -35,12 +55,13 @@ pub fn free_pointer_over(ctx: &egui::Context, canvas: egui::Rect) -> Option Option { let rects = rects(ctx); + let grabbed = press_began_on_dialog(ctx, &rects); let (origin, panning, delta) = ctx.input(|i| { let p = &i.pointer; (p.press_origin(), p.middle_down() || (p.primary_down() && (hand || i.key_down(egui::Key::Space))), p.delta()) }); let origin = origin?; - (panning && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta) + (panning && !grabbed && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta) } /// A click or drag with the primary button started on the free canvas under an open dialog, Space @@ -52,6 +73,7 @@ pub fn free_press(ctx: &egui::Context, canvas: egui::Rect) -> Option } let rects = rects(ctx); let free = |p: egui::Pos2| canvas.contains(p) && !rects.iter().any(|r| r.contains(p)); + let grabbed = press_began_on_dialog(ctx, &rects); ctx.input(|i| { if i.key_down(egui::Key::Space) { return None; @@ -63,7 +85,7 @@ pub fn free_press(ctx: &egui::Context, canvas: egui::Rect) -> Option if let Some(p) = pressed { return free(p).then_some(p); } - let held = i.pointer.primary_down() && i.pointer.press_origin().is_some_and(free); + let held = i.pointer.primary_down() && !grabbed && i.pointer.press_origin().is_some_and(free); i.pointer.latest_pos().filter(|p| held && free(*p)) }) } @@ -842,6 +864,30 @@ mod tests { assert!((moved - egui::vec2(-120.0, 40.0)).length() < 2.0, "the band beside the × moved the dialog: {moved:?}"); } + /// #6810: dragging a dialog's title moves the dialog under the pointer, so the press origin is + /// no longer inside it; that drag must not pan the canvas or reach a canvas tool. + #[test] + fn dragging_a_dialog_title_does_not_pan_the_canvas() { + let ctx = egui::Context::default(); + let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 800.0)); + let at_press = egui::Rect::from_min_max(egui::pos2(100.0, 100.0), egui::pos2(300.0, 200.0)); + let moved = egui::Rect::from_min_max(egui::pos2(550.0, 480.0), egui::pos2(750.0, 580.0)); + let run = |rect: egui::Rect, events: Vec| { + ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), vec![rect])); + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + let out = (pan_delta(&ctx, canvas, true), free_press(&ctx, canvas)); + ctx.end_pass().textures_delta.clear(); + out + }; + let press = + egui::Event::PointerButton { pos: egui::pos2(150.0, 120.0), button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + let (pan, click) = run(at_press, vec![egui::Event::PointerMoved(egui::pos2(150.0, 120.0)), press]); + assert!(pan.is_none() && click.is_none(), "the press is on the dialog"); + let (pan, click) = run(moved, vec![egui::Event::PointerMoved(egui::pos2(600.0, 500.0))]); + assert!(pan.is_none(), "the dialog moved away from the press origin: {pan:?}"); + assert!(click.is_none(), "and the drag is not a canvas tool drag: {click:?}"); + } + #[test] fn color_picker_has_inset_content_and_actions_at_the_right_edge() { use egui_kittest::{Harness, kittest::Queryable}; From e249382ae60b099c9302534830555d29937dd9ab Mon Sep 17 00:00:00 2001 From: dangphuc2470 Date: Sat, 17 Oct 2026 12:19:59 +0700 Subject: [PATCH 0407/1452] fix(engine): preserve Background layer on Merge Layers --- crates/engine/src/layer_multi_cmds.rs | 79 ++++++++++++++++++++------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..7fc04e8a3c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1303,32 +1303,54 @@ fn merge_layers(s: &mut Session) -> Result { } let mid = s.edit("Merge Layers", |doc, active| { let ids = top_level(doc, &sel); - let top_id = *ids.last().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; - let top = doc.layer(top_id).ok_or(EngineError::NoLayer(top_id))?.clone(); + let lowest_id = *ids.first().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; + let is_background = doc.is_background(lowest_id); + let mut solo = doc.clone(); solo.layers = ids.iter().filter_map(|id| doc.layer(*id)).filter(|l| l.visible).cloned().collect(); if solo.layers.is_empty() { return Err(EngineError::Other("the selected layers are all hidden".into())); } - let fmt = doc.pixel_format(); - let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); - let mut merged = Layer::raster(top.name.clone(), fmt); - merged.locks = top.locks; - merged.label = top.label; - // Clipped layers of one chain merge into a layer still clipped to their base. Clipped - // layers of different chains don't: the result, at the top one's slot, would clip the - // lower chain's pixels to the upper chain's base. - let base = crate::clip_group::base_of(doc, top_id); - merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); - let mid = merged.id; - // Hidden selected layers are discarded, as in Photoshop. - for id in &ids[..ids.len() - 1] { - doc.remove(*id); + + if is_background { + let base = doc.layer(lowest_id).ok_or(EngineError::NoLayer(lowest_id))?.clone(); + let fmt = doc.pixel_format(); + let mut merged = Layer::raster(base.name.clone(), fmt); + merged.locks = base.locks; + merged.label = base.label; + merged.id = lowest_id; + merged.blend = base.blend; + merged.opacity = 1.0; + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + for id in &ids[1..] { + doc.remove(*id); + } + *doc.layer_mut(lowest_id).ok_or(EngineError::NoLayer(lowest_id))? = merged; + *active = Some(lowest_id); + Ok(lowest_id) + } else { + let top_id = *ids.last().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; + let top = doc.layer(top_id).ok_or(EngineError::NoLayer(top_id))?.clone(); + let fmt = doc.pixel_format(); + let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); + let mut merged = Layer::raster(top.name.clone(), fmt); + merged.locks = top.locks; + merged.label = top.label; + // Clipped layers of one chain merge into a layer still clipped to their base. Clipped + // layers of different chains don't: the result, at the top one's slot, would clip the + // lower chain's pixels to the upper chain's base. + let base = crate::clip_group::base_of(doc, top_id); + merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); + let mid = merged.id; + // Hidden selected layers are discarded, as in Photoshop. + for id in &ids[..ids.len() - 1] { + doc.remove(*id); + } + *doc.layer_mut(top_id).ok_or(EngineError::NoLayer(top_id))? = merged; + *active = Some(mid); + Ok(mid) } - *doc.layer_mut(top_id).ok_or(EngineError::NoLayer(top_id))? = merged; - *active = Some(mid); - Ok(mid) })?; reselect(s, vec![mid], Some(mid)); Ok(json!({ "layer": mid.0 })) @@ -1514,6 +1536,23 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn merge_with_background_preserves_background() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); + let bg_id = s.active().unwrap().doc.layers[0].id; + assert!(s.active().unwrap().doc.is_background(bg_id)); + let l1 = LayerId(s.execute("layer.new.layer", json!({"name": "Generative Fill"})).unwrap()["layer"].as_u64().unwrap()); + set_selection(&mut s, vec![bg_id, l1], Some(l1), Some(bg_id)).unwrap(); + let res = s.execute("layer.mergeLayers", json!({})).unwrap(); + let mid = LayerId(res["layer"].as_u64().unwrap()); + assert_eq!(mid, bg_id); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert!(doc.is_background(mid)); + assert_eq!(doc.layers[0].name, "Background"); + } + #[test] fn duplicates_are_numbered_not_stacked() { let mut s = Session::new(); From 8f971cc79f1167f1ba4281a8ebe4bb41cf77d552 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:22:05 -0700 Subject: [PATCH 0408/1452] Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off The Mixer Brush could not be emptied. MixerState::clean dropped only the pickup well, paint.mixerBrush refilled the reservoir from the foreground before every stroke unless loadAfterStroke was false, and the app always sent the defaults. A stroke on a new layer over an image always carried the foreground color, and the options bar had no Load Brush or Clean Brush control. Cleaning now empties both wells, so a clean brush lays down only the color it picks up from the canvas. The new commands brush.mixer.load and brush.mixer.clean load and empty the brush. paint.mixerBrush reads loadAfterStroke and cleanAfterStroke from the brush and applies them after the stroke. Both are on by default, and a stroke with both on paints as before. A stroke whose color differs from the one recorded by the last stroke, load or clean loads that color first. A load or clean records the foreground, so choosing a new foreground loads the reservoir. The options bar has the Current Brush Load swatch, whose menu holds Load Brush and Clean Brush, and two toggles for the automatic Load and Clean. Brush Settings shows the same two options. The new strings have rows in every catalog. --- crates/engine/src/brush_cmds.rs | 48 ++++++++++--- crates/engine/src/brush_cmds/tests.rs | 95 +++++++++++++++++++++++++ crates/paint/src/mixer.rs | 81 ++++++++++++++++++++- crates/ui-egui/src/brush_panel_tests.rs | 60 +++++++++++++++- crates/ui-egui/src/brush_sections.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 5 ++ crates/ui-egui/src/i18n/de.tsv | 5 ++ crates/ui-egui/src/i18n/el.tsv | 5 ++ crates/ui-egui/src/i18n/es.tsv | 5 ++ crates/ui-egui/src/i18n/fr.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 5 ++ crates/ui-egui/src/i18n/id.tsv | 5 ++ crates/ui-egui/src/i18n/it.tsv | 5 ++ crates/ui-egui/src/i18n/ja.tsv | 5 ++ crates/ui-egui/src/i18n/ko.tsv | 5 ++ crates/ui-egui/src/i18n/mod.rs | 15 +++- crates/ui-egui/src/i18n/nl.tsv | 5 ++ crates/ui-egui/src/i18n/pl.tsv | 5 ++ crates/ui-egui/src/i18n/pt-br.tsv | 5 ++ crates/ui-egui/src/i18n/ru.tsv | 5 ++ crates/ui-egui/src/i18n/sr.tsv | 5 ++ crates/ui-egui/src/i18n/tr.tsv | 5 ++ crates/ui-egui/src/i18n/uk.tsv | 5 ++ crates/ui-egui/src/i18n/vi.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 ++ crates/ui-egui/src/panels.rs | 52 ++++++++++++++ docs/brush-parity.md | 2 +- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 30 files changed, 443 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index f2c9e28b2c..580b1610d3 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { mix: pct(p, "mix", bm.mix * 100.0), flow: pct(p, "flow", bm.flow * 100.0), sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), + load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke), + clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke), }; let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); + let paint = color(p.get("color"), s.tools.foreground); let mut state = s.tools.mixer.clone(); - if load_after || state.reservoir.is_none() { - state.load(color(p.get("color"), s.tools.foreground)); - } + state.begin_stroke(paint); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { @@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) })?; - if clean { - state.clean(); - } + state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; Ok(damage_json(s, dmg)) } +/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke +/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now +/// carries. +fn mixer_load(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.mixer.load"; + let paint = match p.get("color").filter(|v| !v.is_null()) { + None => Some(s.tools.foreground), + Some(Value::String(h)) => crate::commands::parse_hex(h), + Some(Value::Array(a)) => { + let c: Option> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect(); + match c.as_deref() { + Some(&[r, g, b]) => Some([r, g, b, 1.0]), + Some(&[r, g, b, a]) => Some([r, g, b, a]), + _ => None, + } + } + Some(_) => None, + }; + let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?; + s.tools.mixer.load(paint); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + +/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless +/// a different color is chosen first. Returns the paint the brush now carries. +fn mixer_clean(s: &mut Session, _: &Value) -> Result { + s.tools.mixer.clean(); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour /// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. @@ -1126,11 +1156,13 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, has_paintable, mixer_brush, true ), + spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true), + spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true), spec!( "paint.colorReplacement", "Color Replacement", diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 5977603f94..965c02f5b8 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -763,6 +763,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() { assert!(rgba(&s, 190, 5)[3] > 0.95); } +/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all +/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off. +fn mixer_over_red() -> Session { + let mut s = session(200, 30); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false}); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap(); + s +} + +fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] { + s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap(); + rgba(s, 100, y) +} + +#[test] +fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({})).unwrap(); + let state = s.execute("brush.mixer.clean", json!({})).unwrap(); + assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}"); +} + +#[test] +fn the_automatic_clean_empties_the_reservoir() { + let mut s = mixer_over_red(); + // The brush was never loaded, so the first stroke takes the foreground. + assert!(mixer_row(&mut s, 5)[2] > 0.2); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}"); + // With the automatic Load on as well (the default), the brush is cleaned and then refilled + // after the stroke, so the next one starts with a full reservoir and no pickup. + s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap(); + mixer_row(&mut s, 25); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None)); +} + +#[test] +fn load_brush_after_a_clean_paints_the_foreground_again() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.clean", json!({})).unwrap(); + assert!(mixer_row(&mut s, 5)[2] < 0.01); + let state = s.execute("brush.mixer.load", json!({})).unwrap(); + assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0])); + assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue"); + // A color param loads that color instead of the foreground, and the next stroke paints it. + s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap(); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0)); + assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0])); + let c = mixer_row(&mut s, 25); + assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}"); +} + +#[test] +fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() { + let mut s = mixer_over_red(); + assert!(mixer_row(&mut s, 5)[2] > 0.2); + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + let c = mixer_row(&mut s, 15); + assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}"); + // The automatic Clean emptied it again, and the foreground did not change. + let c = mixer_row(&mut s, 25); + assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); +} + +#[test] +fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap(); + let before = s.tools.mixer.clone(); + for bad in [ + json!({"color": 5}), + json!({"color": "#zz0000"}), + json!({"color": "#ff"}), + json!({"color": [2, 0, 0]}), + json!({"color": [0, 0]}), + json!({"color": [0, 0, 0, 0, 0]}), + json!({"color": {"r": 1}}), + json!({"color": ["a", 0, 0]}), + ] { + assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}"); + assert_eq!(s.tools.mixer, before, "{bad}"); + } + // Clean Brush takes no params, so anything extra is ignored, without a document too. + let mut empty = Session::new(); + empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap(); + empty.execute("brush.mixer.load", json!({"color": null})).unwrap(); + assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground)); +} + #[test] fn coalesced_set_brush_calls_journal_once_per_gesture() { let mut s = session(20, 20); diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index afb8092cd2..49abb127a1 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -26,11 +26,15 @@ pub struct MixerSettings { pub flow: f32, /// Pick up colour from all visible layers instead of the target layer only. pub sample_all_layers: bool, + /// Load the brush with the stroke color after each stroke (the automatic Load option). + pub load_after_stroke: bool, + /// Empty the brush after each stroke (the automatic Clean option). + pub clean_after_stroke: bool, } impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true } } } @@ -44,18 +48,47 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: Option<[f32; 4]>, + /// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush + /// (the foreground). None = no stroke, load or clean yet. + pub color: Option<[f32; 4]>, } impl MixerState { - /// Load the reservoir with a colour (full). + /// Load Brush: fill the reservoir with a color (full). pub fn load(&mut self, color: [f32; 4]) { self.reservoir = Some(color); self.amount = 1.0; } - /// Clean the brush (drop the picked-up colour). + /// Clean Brush: empty the reservoir and the pickup well. pub fn clean(&mut self) { + self.reservoir = None; + self.amount = 0.0; self.pickup = None; } + /// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the + /// reservoir with it. + pub fn begin_stroke(&mut self, color: [f32; 4]) { + if self.color != Some(color) { + self.load(color); + self.color = Some(color); + } + } + /// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load + /// fills the reservoir again. + pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) { + if clean { + self.clean(); + } + if load { + self.load(color); + } + self.color = Some(color); + } + /// The reservoir color the next stroke in `color` starts with (the Current Brush Load + /// swatch); None = a clean brush. + pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> { + if self.color != Some(color) { Some(color) } else { self.reservoir } + } } /// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself @@ -209,6 +242,48 @@ mod tests { assert!(st.pickup.is_none()); } + #[test] + fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() { + let mut st = MixerState::default(); + st.load([0.0, 0.0, 1.0, 1.0]); + st.pickup = Some([0.0, 1.0, 0.0, 1.0]); + st.clean(); + assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None)); + // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + let c = s.rgba(100, 10); + assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); + } + + #[test] + fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() { + let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]); + let mut st = MixerState::default(); + // A brush never loaded takes the stroke's color. + assert_eq!(st.next_load(blue), Some(blue)); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0)); + // Clean on, Load off: the next stroke in the same color starts empty. + st.end_stroke(blue, true, false); + assert_eq!(st.next_load(blue), None); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.pickup), (None, None)); + // Choosing another color loads it. + assert_eq!(st.next_load(green), Some(green)); + st.begin_stroke(green); + assert_eq!((st.reservoir, st.amount), (Some(green), 1.0)); + // Load on, Clean off: the reservoir is full again and the pickup stays. + st.amount = 0.1; + st.pickup = Some(blue); + st.end_stroke(green, false, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue))); + // Both on: a fresh load with no pickup. + st.end_stroke(green, true, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None)); + } + #[test] fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 670f442be2..f1e9e50a35 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.transfer.flow = dynamic(0.25, Control::Fade, 0.3); b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1); b.transfer.mix = dynamic(0.45, Control::Fade, 0.2); - b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true }; + b.mixer = paint::MixerSettings { + wet: 0.9, + load: 0.15, + mix: 0.7, + flow: 0.8, + sample_all_layers: true, + load_after_stroke: false, + clean_after_stroke: false, + }; } 7 => { let p = &mut b.pose; @@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() { assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true); } +#[test] +fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() { + use egui_kittest::kittest::Queryable; + let mut h = options_bar_harness(crate::state::Tool::MixerBrush); + // A red image, an empty layer above it, a blue foreground, and a brush that samples all layers. + for (id, p) in [ + ("file.new", json!({"width": 200, "height": 40, "background": "transparent"})), + ("edit.fill", json!({"color": "#ff0000"})), + ("layer.new.layer", json!({})), + ("tools.setColors", json!({"foreground": "#0000ff"})), + ("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})), + ] { + h.state_mut().run(id, p).unwrap(); + } + h.run_steps(3); + for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}"); + } + + // Turn the automatic Load off: one tools.setBrush call. + h.get_by_label("Load the brush after each stroke").click(); + h.run_steps(3); + assert!(!h.state().session.tools.brush.mixer.load_after_stroke); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false))); + + // Current Brush Load › Clean Brush empties the brush. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Clean Brush").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string())); + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // The stroke the canvas commits on release lays down only the red it picks up. + crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE); + let d = h.state().session.active().unwrap(); + let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}"); + // With the automatic Load off, the stroke did not refill the brush either. + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // Load Brush fills it with the foreground again. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Load Brush").click(); + h.run_steps(3); + assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0])); +} + #[test] fn drop_targets_and_actions_reorder_presets() { use crate::brushes_tab::{Action, apply, drop_target, group_key}; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index e7d3a16f56..9e66f9b0a4 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) { pct(ui, tl!("Mix"), &mut m.mix, 1.0); pct(ui, tl!("Flow"), &mut m.flow, 1.0); widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers"); + widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke")); + widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")); } /// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..79ec121421 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2171,6 +2171,11 @@ Color Replacement Tool Nástroj Nahrazení barvy Color Replacement Nahrazení barvy Wet Vlhkost + Current Brush Load Aktuální náplň štětce + Load Brush Naplnit štětec + Clean Brush Vyčistit štětec + Load the brush after each stroke Naplnit štětec po každém tahu + Clean the brush after each stroke Vyčistit štětec po každém tahu Mix Míchání Wetness Jitter Kolísání vlhkosti Mix Jitter Kolísání míchání diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b1ea8977e2..19cf26c048 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2140,6 +2140,11 @@ Mixer Brush Tool Mischer-Pinsel-Werkzeug Color Replacement Tool Farbersatz-Werkzeug Wet Feucht + Current Brush Load Aktuelle Pinselladung + Load Brush Pinsel laden + Clean Brush Pinsel reinigen + Load the brush after each stroke Pinsel nach jedem Strich laden + Clean the brush after each stroke Pinsel nach jedem Strich reinigen Mix Mischen Wetness Jitter Feuchtigkeitsschwankung Mix Jitter Mischschwankung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8229806307..39f5a79207 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1984,6 +1984,11 @@ Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool) Color Replacement Αντικατάσταση χρώματος Wet Υγρασία + Current Brush Load Τρέχουσα φόρτωση πινέλου + Load Brush Φόρτωση πινέλου + Clean Brush Καθαρισμός πινέλου + Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά + Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά Load Φόρτωση Mix Ανάμειξη 8 bit 8 bit ανά κανάλι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f3d85002b..32771121d9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2158,6 +2158,11 @@ Mixer Brush Tool Herramienta Pincel mezclador Color Replacement Tool Herramienta Sustitución de color Wet Húmedo + Current Brush Load Carga actual del pincel + Load Brush Cargar pincel + Clean Brush Limpiar pincel + Load the brush after each stroke Cargar el pincel después de cada trazo + Clean the brush after each stroke Limpiar el pincel después de cada trazo Mix Mezcla Wetness Jitter Variación de humedad Mix Jitter Variación de mezcla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4a819b0e1b..956fbb1a2f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2118,6 +2118,11 @@ Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe Mixer Brush Pinceau mélangeur Wet Humidité + Current Brush Load Charge actuelle du pinceau + Load Brush Charger le pinceau + Clean Brush Nettoyer le pinceau + Load the brush after each stroke Charger le pinceau après chaque trait + Clean the brush after each stroke Nettoyer le pinceau après chaque trait Mix Mélange Wetness Jitter Variation de l'humidité Mix Jitter Variation du mélange diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86a6b30acb..3d33c44243 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1407,6 +1407,11 @@ Warp: Hajlítás: Weight Vastagság Wet Nedvesség + Current Brush Load Az ecset jelenlegi töltete + Load Brush Ecset feltöltése + Clean Brush Ecset tisztítása + Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után + Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után Wet Edges Nedves szélek Wetness Jitter Nedvesség ingadozása Width Szélesség diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 49777913c1..81d950899c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung Mixer Brush Kuas Pencampur Wet Basah + Current Brush Load Muatan Kuas Saat Ini + Load Brush Muat Kuas + Clean Brush Bersihkan Kuas + Load the brush after each stroke Muat kuas setelah setiap goresan + Clean the brush after each stroke Bersihkan kuas setelah setiap goresan Mix Campur Wetness Jitter Jitter Kebasahan Mix Jitter Jitter Campuran diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f1144c0fdd..79b3f232c5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool Strumento Pennello miscelatore Color Replacement Tool Strumento Sostituzione colore Wet Bagnato + Current Brush Load Carico attuale del pennello + Load Brush Carica pennello + Clean Brush Pulisci pennello + Load the brush after each stroke Carica il pennello dopo ogni tratto + Clean the brush after each stroke Pulisci il pennello dopo ogni tratto Mix Miscela Wetness Jitter Variazione bagnato Mix Jitter Variazione miscela diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 351699ea89..9617f848a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool 混合ブラシツール Color Replacement Tool 色の置き換えツール Wet ウェット + Current Brush Load 現在のブラシの絵の具 + Load Brush ブラシに補充 + Clean Brush ブラシを洗浄 + Load the brush after each stroke ストロークごとにブラシに補充 + Clean the brush after each stroke ストロークごとにブラシを洗浄 Mix 混合 Wetness Jitter ウェットのジッター Mix Jitter 混合のジッター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3eaf143979..6e797e001e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용 Mixer Brush 혼합 브러시 Wet 젖음 + Current Brush Load 현재 브러시 물감 + Load Brush 브러시 채우기 + Clean Brush 브러시 세척 + Load the brush after each stroke 획마다 브러시 채우기 + Clean the brush after each stroke 획마다 브러시 세척 Mix 혼합 Wetness Jitter 젖음 지터 Mix Jitter 혼합 지터 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..7476f8d513 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1257,7 +1257,20 @@ mod tests { #[test] fn mixer_brush_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"]; + const STRINGS: &[&str] = &[ + "Mixer Brush", + "Mixer Brush Tool", + "Wet", + "Load", + "Mix", + "Flow", + "Sample All Layers", + "Current Brush Load", + "Load Brush", + "Clean Brush", + "Load the brush after each stroke", + "Clean the brush after each stroke", + ]; for lang in Lang::all() { for source in STRINGS { let translated = tr(lang, source); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09dd568009..f1fab787f1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2118,6 +2118,11 @@ Mixer Brush Tool Mengpenseelgereedschap Color Replacement Tool Kleurvervangingsgereedschap Wet Nat + Current Brush Load Huidige penseellading + Load Brush Penseel laden + Clean Brush Penseel reinigen + Load the brush after each stroke Penseel laden na elke streek + Clean the brush after each stroke Penseel reinigen na elke streek Mix Mengen Wetness Jitter Nattigheidsvariatie Mix Jitter Mengvariatie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c4827aa829..f5f525f080 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2137,6 +2137,11 @@ Mixer Brush Tool Narzędzie Pędzel mieszający Color Replacement Tool Narzędzie Zamiana koloru Wet Wilgotność + Current Brush Load Bieżący ładunek pędzla + Load Brush Napełnij pędzel + Clean Brush Wyczyść pędzel + Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu + Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu Mix Mieszanie Wetness Jitter Rozrzut wilgotności Mix Jitter Rozrzut mieszania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f96542d642..1dc356f0cc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta Mixer Brush Pincel misturador Wet Molhado + Current Brush Load Carga atual do pincel + Load Brush Carregar pincel + Clean Brush Limpar pincel + Load the brush after each stroke Carregar o pincel após cada traço + Clean the brush after each stroke Limpar o pincel após cada traço Mix Mistura Wetness Jitter Variação da umidade Mix Jitter Variação da mistura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..efd6907f4d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1023,6 +1023,11 @@ Mix Смешивание Auto Clean Brush Автоочистка кисти Wet Влага + Current Brush Load Текущая загрузка кисти + Load Brush Загрузить кисть + Clean Brush Очистить кисть + Load the brush after each stroke Загружать кисть после каждого штриха + Clean the brush after each stroke Очищать кисть после каждого штриха Rate Скорость Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен. Pulled String Mode Режим натянутой нити diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c207facb72..3ba821cd97 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2095,6 +2095,11 @@ Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha Mixer Brush Četkica za mešanje Wet Vlažnost + Current Brush Load Trenutno punjenje četkice + Load Brush Napuni četkicu + Clean Brush Očisti četkicu + Load the brush after each stroke Napuni četkicu posle svakog poteza + Clean the brush after each stroke Očisti četkicu posle svakog poteza Mix Mešanje Wetness Jitter Kolebanje vlažnosti Mix Jitter Kolebanje mešanja diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8aa0c3c58..9648c631ac 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2092,6 +2092,11 @@ Mixer Brush Tool Karıştırıcı Fırça Aracı Color Replacement Tool Renk Değiştirme Aracı Wet Islaklık + Current Brush Load Geçerli Fırça Yükü + Load Brush Fırçayı Yükle + Clean Brush Fırçayı Temizle + Load the brush after each stroke Her vuruştan sonra fırçayı yükle + Clean the brush after each stroke Her vuruştan sonra fırçayı temizle Mix Karışım Wetness Jitter Islaklık Titreme Mix Jitter Karıştırma Titreşimi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 48fb60343c..6246957cc7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1000,6 +1000,11 @@ Mix Змішування Auto Clean Brush Автоматично очищувати пензель Wet Вологість + Current Brush Load Поточне завантаження пензля + Load Brush Завантажити пензель + Clean Brush Очистити пензель + Load the brush after each stroke Завантажувати пензель після кожного штриха + Clean the brush after each stroke Очищати пензель після кожного штриха Rate Частота Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий. Pulled String Mode Режим натягнутої нитки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f33ef15011..38585fa2cc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1296,6 +1296,11 @@ Mixer Brush Cọ trộn Mixer Brush Tool Công cụ Cọ trộn Wet Ướt + Current Brush Load Lượng sơn hiện tại của cọ + Load Brush Nạp sơn cho cọ + Clean Brush Làm sạch cọ + Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ + Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ Load Tải Mix Trộn Flow Lưu lượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75eacc994d..570aada058 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2219,6 +2219,11 @@ cameraRaw Blending 混合 Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状 Mixer Brush 混合器画笔 Wet 湿润 + Current Brush Load 当前画笔载入 + Load Brush 载入画笔 + Clean Brush 清理画笔 + Load the brush after each stroke 每次描边后载入画笔 + Clean the brush after each stroke 每次描边后清理画笔 Mix 混合 Wetness Jitter 湿润度抖动 Mix Jitter 混合抖动 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a11dc593..4e6d901201 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2158,6 +2158,11 @@ Color Replacement Tool 顏色取代工具 Color Replacement 顏色取代 Wet 濕度 + Current Brush Load 目前筆刷載入 + Load Brush 載入筆刷 + Clean Brush 清理筆刷 + Load the brush after each stroke 每次描邊後載入筆刷 + Clean the brush after each stroke 每次描邊後清理筆刷 Mix 混合 Wetness Jitter 濕度快速變換 Mix Jitter 混合快速變換 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fc8e2d8f4..6de63cdff6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1078,6 +1078,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); + mixer_load_swatch(app, ui); + let m = &mut b.mixer; + for (icon, on, tip) in [ + ("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")), + ("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")), + ] { + if icons::button(ui, icon, 24.0, *on, tip).clicked() { + *on = !*on; + } + } + widgets::vline(ui, 22.0); for (label, value) in [ (tl!("Wet"), &mut b.mixer.wet), (tl!("Load"), &mut b.mixer.load), @@ -3906,6 +3917,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings }); } +/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an +/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush. +fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover()); + let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + let p = ui.painter(); + match app.session.tools.mixer.next_load(app.session.tools.foreground) { + Some(c) => { + p.rect_filled(chip, 2.0, c32(c)); + } + None => { + p.rect_filled(chip, 2.0, t.field); + p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint)); + } + } + p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load"))); + let resp = resp.on_hover_text(tl!("Current Brush Load")); + let mut run = None; + egui::Popup::menu(&resp).show(|ui| { + if ui.button(tl!("Load Brush")).clicked() { + run = Some("brush.mixer.load"); + } + if ui.button(tl!("Clean Brush")).clicked() { + run = Some("brush.mixer.clean"); + } + }); + if let Some(id) = run + && let Err(e) = app.run(id, json!({})) + { + app.ui.status = e; + app.ui.status_error = true; + } +} + /// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size, /// hardness, the preset library), the one a right-click on the canvas opens. fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 05697f9e6a..5c75a3e55f 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -91,7 +91,7 @@ tablets against Photoshop side by side. | Tool | Status | Notes | |---|---|---| | Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | -| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) | +| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) | | Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | | Pattern Stamp (aligned, impressionist) | done | | | History Brush | done | Art History Brush missing | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..e8ae8961c2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | -| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | +| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..efe2bb159e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -17,7 +17,7 @@ id = "TOOL-213-2" target = "Mixer Brush tool on the toolbar" status = "done" issue = 213 -note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests" +note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests" [[item]] id = "TOOL-213-3" From 3148b32a74077657cc76fb26ac7088d992e27d66 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 11:05:47 +0300 Subject: [PATCH 0409/1452] Limit decimal arrow steps to slider_row_prec adjustment rows (#5914). Display-only decimals on value_field_prec and value_field_units keep range-based 1.0 steps on wide fields like New Document dimensions. Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 40 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a8ab3a0840..0a18f2db1b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -497,22 +497,30 @@ fn take_label_scrub(ui: &Ui, rect: Rect) -> Option { struct FieldOpts { /// Decimal places shown (default: one, or two for a small range). decimals: Option, + /// Arrow-key and scrub step follow `decimals` (adjustment rows via [`slider_row_prec`], #5914). + step_from_decimals: bool, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, wrap_drag: bool, } -/// Arrow-key and drag step for a value field: explicit `decimals` win over the range width -/// (Exposure's ±20 stops still step by 0.01 when it shows two decimals, #5914). -fn field_step(range: &std::ops::RangeInclusive, decimals: Option) -> f32 { - if let Some(d) = decimals { - return 10f32.powi(-(d.min(6) as i32)); +/// Arrow-key and drag step for a value field: [`FieldOpts::step_from_decimals`] uses `decimals`; +/// otherwise the range width picks 0.01 (≤10) or 1.0. +fn field_step(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> f32 { + if step_from_decimals { + if let Some(d) = decimals { + return 10f32.powi(-(d.min(6) as i32)); + } } if *range.end() - *range.start() <= 10.0 { 0.01 } else { 1.0 } } -fn field_fine(range: &std::ops::RangeInclusive, decimals: Option) -> bool { - decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 +fn field_fine(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> bool { + if step_from_decimals { + decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 + } else { + *range.end() - *range.start() <= 10.0 + } } /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and @@ -539,7 +547,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units, wrap_drag } = opts; + let FieldOpts { decimals, step_from_decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect).map(|s| { @@ -577,8 +585,8 @@ fn value_field_box( let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. - let fine = field_fine(&range, decimals); - let unit = field_step(&range, decimals); + let fine = field_fine(&range, decimals, step_from_decimals); + let unit = field_step(&range, decimals, step_from_decimals); let (lo, hi) = (*range.start(), *range.end()); let (step, grid) = arrow_step(ui, slot.id, unit); // new_child (not scope_builder): a scope would move the parent cursor back to the child rect. @@ -1103,7 +1111,15 @@ pub fn slider_row_prec( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, ..Default::default() }).0); + changed_resp = Some(value_field_in( + ui, + value, + range.clone(), + suffix, + 74.0, + 0.0, + FieldOpts { decimals, step_from_decimals: decimals.is_some(), ..Default::default() }, + ).0); }); }); let s = slider_with(ui, value, range, gradient, g); @@ -2053,7 +2069,7 @@ mod tests { fn arrow_keys_step_fractional_stops_when_decimals_are_set() { let mut h = Harness::new_ui_state( |ui, v: &mut f32| { - super::value_field_prec(ui, v, -20.0..=20.0, "", 74.0, 2); + super::slider_row_prec(ui, "Exposure", v, -20.0..=20.0, "", None, super::RowGestures::default(), Some(2)); }, 0.0, ); From d36e6dfdbdf704463838bf2e8319853a8df23f1f Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 17 Oct 2026 09:11:38 +0200 Subject: [PATCH 0410/1452] Crop tool: switching tools drops an untouched ratio-fitted frame instead of cropping (#6750) With a crop ratio preset that differs from the document, entering the Crop tool fits the default frame to the ratio. That fit went through keyed(), which marks the frame as drawn, so pressing V (or picking another tool) committed the crop and changed the document even though the user never touched the frame. #3451 (#3368) established that an untouched default frame is dropped on leaving the tool and only a drawn one commits; the auto-fit was accidentally turning the untouched frame into a drawn one. fit_frame now moves the untouched default frame to the fitted rect without clearing default_frame, so leaving the tool drops it like the plain default frame. Everything else keeps its old meaning: - a handle resize, a nudge or a drag inside the fitted frame still makes it a drawn frame, and leaving the tool commits it (#3368); - Enter still commits the fitted frame (it crops to the ratio, as choosing_a_ratio_fits_and_commits_without_a_resize pins); - a W x H x Resolution output size is an explicit instruction and still marks the frame pending even when it already fits (crop_size.rs). --- crates/ui-egui/src/crop_ui.rs | 67 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..0910f3de6b 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -250,13 +250,22 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { fn fit_frame(app: &mut PhotocraftApp) { let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); + let sized = crate::crop_size::target(&app.ui.tool_options, dpi).is_some(); if let Some(r) = app.ui.crop_rect && let Some(ratio) = preset_ratio(app) && let Some(fitted) = fitted_ratio(r, ratio) // W x H x Resolution can change output size even when the frame already fits. - && (fitted != r || crate::crop_size::target(&app.ui.tool_options, dpi).is_some()) + && (fitted != r || sized) { - keyed(app, fitted); + // #6750: fitting the untouched default frame to a ratio is not an edit: the user only + // picked the tool, so leaving it drops the frame instead of cropping to it, as with the + // plain default frame. A W x H x Resolution output size is an explicit instruction and + // stays a pending crop even on the default frame. + if fitted != r && !sized && app.crop.default_frame { + app.ui.crop_rect = Some(fitted); + } else { + keyed(app, fitted); + } } } @@ -1672,6 +1681,60 @@ mod tests { } } + /// #6750: a ratio preset that shrinks the untouched default frame does not make it a drawn + /// one: leaving the tool drops the frame and crops nothing, as with the plain default frame. + /// Picking the ratio before entering the tool (the issue's repro) and while it is picked both + /// stay untouched; editing the fitted frame makes it a drawn one that leaving commits (#3368). + #[test] + fn another_tool_drops_an_untouched_ratio_fitted_frame() { + // Entering the Crop tool with a saved ratio: the frame is fitted but untouched. + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_ratio = "1:1".into(); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0]), "the default frame is fitted to the ratio"); + assert!(app.crop.default_frame, "fitting it is not an edit"); + let steps = app.session.active().unwrap().history.past_len(); + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "switching tools must not crop"); + assert_eq!(app.ui.crop_rect, None); + // Choosing the ratio with the Crop tool already picked: the same untouched fit. + let mut app2 = self::app(SampleType::U8); + ensure_frame(&mut app2); + app2.ui.tool_options.crop_ratio = "1:1".into(); + crate::crop_size::chosen(&mut app2); + assert_eq!(app2.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(app2.crop.default_frame); + let steps = app2.session.active().unwrap().history.past_len(); + app2.ui.tool = Tool::Move; + ensure_frame(&mut app2); + let st = app2.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps)); + // Resizing a handle of the fitted frame is an edit: leaving commits it (#3368). + let mut app3 = self::app(SampleType::U8); + app3.ui.tool_options.crop_ratio = "1:1".into(); + ensure_frame(&mut app3); + drag(&mut app3, &[[50.0, 0.0], [70.0, 20.0]], NONE); + assert!(!app3.crop.default_frame, "the resize made it a drawn frame"); + app3.ui.tool = Tool::Move; + ensure_frame(&mut app3); + assert_eq!(app3.session.active().unwrap().doc.size, Size::new(80, 80), "the edited frame crops"); + // Default (non-classic) mode, the issue's exact scenario: the fitted preview stays a + // preview; pressing V neither crops nor applies anything. + let mut app4 = self::app(SampleType::U8); + app4.ui.tool_options.crop_shield.classic_mode = false; + app4.sync_views(); + app4.ui.tool_options.crop_ratio = "1:1".into(); + ensure_frame(&mut app4); + assert!(app4.crop.default_frame, "the fitted preview is still the untouched frame"); + let steps = app4.session.active().unwrap().history.past_len(); + app4.ui.tool = Tool::Move; + ensure_frame(&mut app4); + let st = app4.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps)); + } + /// The pen's eraser end stands in for the Crop tool only until the tip comes back (`stylus`), /// and a gesture still in progress isn't cut short: neither commits the frame. #[test] From e8cd8c917cf762b7499f0352d2abfbec0a4623e4 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 17 Oct 2026 10:24:23 +0200 Subject: [PATCH 0411/1452] engine: restore export_presets in load_presets_json (fixes #6911) The Persisted initializer in Session::load_presets_json lost the export_presets field when the sets were refactored to load independently (#5616 landed after #5344 added the field), so photocraft-engine does not compile on main. One-line build fix, kept as its own commit: drop it once #6906 or #6930 lands. --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From 242d1cfec089beeb1317ce5d394e267fb6d7a6c7 Mon Sep 17 00:00:00 2001 From: wasif Date: Sat, 17 Oct 2026 12:37:34 +0400 Subject: [PATCH 0412/1452] docs(xtask): point the layers help text at docs/architecture.md cargo xtask --help and the layers command output both referred to plan/architecture.md, which does not exist in this repository; there is no plan/ directory at the root. The dependency layering rules are documented as section 3 of docs/architecture.md, so point both references there. --- xtask/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..d971ea7bdf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,7 +22,7 @@ const USAGE: &str = "\ usage: cargo xtask commands: - layers enforce the crate dependency layering (plan/architecture.md §3) + layers enforce the crate dependency layering (docs/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] @@ -106,7 +106,7 @@ pub fn metadata() -> Result { fn cmd_layers() -> Result<(), String> { let crates = layers::from_metadata(&metadata()?)?; - println!("Dependency layering (plan/architecture.md §3)\n"); + println!("Dependency layering (docs/architecture.md §3)\n"); println!("{:<28} {:<14} workspace deps", "crate", "layer"); for c in &crates { let ws: Vec = c From 9328c735af9714dc59a9097b2d577bbbcde27e8b Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 17 Oct 2026 10:46:02 +0200 Subject: [PATCH 0413/1452] algo: gate the blocks/fnv test helpers to the test that uses them The recorded-hash test for harris/describe is cfg-gated to linux x86_64 (the libm the hashes were recorded from), but its two helper fns were not, so on other platforms the lib test build has dead code and fails under -D warnings (#6489). --- crates/algo/src/features.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index e8e45bcaf2..10c3c2a763 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -701,10 +701,14 @@ mod parallel_tests { } /// Blocks of random brightness: lots of strong corners, no libm in the making. + /// Only the recorded-hash test below uses these two helpers, and it is gated to the + /// platform whose libm the hashes were recorded from (#6489). + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn blocks(w: usize, h: usize, seed: u64) -> Vec { (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn fnv(parts: &[u64]) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for p in parts { From 8b3ea4174e8cea165522be5024b658e8f47d669a Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 12:00:42 +0300 Subject: [PATCH 0414/1452] Collapse field_step conditionals for clippy on CI. Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0a18f2db1b..1429a2ed23 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -507,10 +507,8 @@ struct FieldOpts { /// Arrow-key and drag step for a value field: [`FieldOpts::step_from_decimals`] uses `decimals`; /// otherwise the range width picks 0.01 (≤10) or 1.0. fn field_step(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> f32 { - if step_from_decimals { - if let Some(d) = decimals { - return 10f32.powi(-(d.min(6) as i32)); - } + if step_from_decimals && let Some(d) = decimals { + return 10f32.powi(-(d.min(6) as i32)); } if *range.end() - *range.start() <= 10.0 { 0.01 } else { 1.0 } } From dd57638d9ecd6c88edfcfcd8d07af7f2f21960fc Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sat, 17 Oct 2026 10:11:57 +0100 Subject: [PATCH 0415/1452] fix(psd): make packbits::decode_into crate-private and overflow-safe decode_into computed out.len() + expected unchecked. With a non-empty buffer and an expected length near usize::MAX it panicked in debug builds and, in release, wrapped and returned Ok having decoded nothing. Compute the target with checked_add and report LimitExceeded, and test each packet as len > target - out.len() so the bound checks cannot overflow either. The only callers are decode and decode_rle in this module, and the crate README documents packbits::{encode, decode} as the public API, so decode_into becomes pub(crate). Co-Authored-By: Claude Opus 5.5 --- crates/psd/src/compression.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index aab785dff4..68782464ad 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -111,9 +111,10 @@ pub mod packbits { /// /// Errors if the input is exhausted early or a packet would overflow the /// row. Trailing input after the row is complete is ignored except for - /// `-128` no-op bytes (which are always permitted). - pub fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { - let target = out.len() + expected; + /// `-128` no-op bytes (which are always permitted). Errors, rather than + /// overflowing, when `out.len() + expected` does not fit in `usize`. + pub(crate) fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { + let target = out.len().checked_add(expected).ok_or(PsdError::LimitExceeded("PackBits output length overflows usize"))?; let mut i = 0; while out.len() < target { let Some(&h) = src.get(i) else { @@ -126,7 +127,7 @@ pub mod packbits { let Some(lit) = src.get(i..i + len) else { return Err(PsdError::Decompress("PackBits literal truncated".into())); }; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits literal overflows row".into())); } out.extend_from_slice(lit); @@ -138,7 +139,7 @@ pub mod packbits { return Err(PsdError::Decompress("PackBits run truncated".into())); }; i += 1; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits run overflows row".into())); } out.extend(std::iter::repeat_n(b, len)); @@ -687,6 +688,20 @@ mod tests { assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows } + /// `out.len() + expected` past `usize::MAX` used to panic in debug builds and, in release, + /// wrap and return `Ok` having decoded nothing. + #[test] + fn packbits_decode_into_output_length_overflow_is_an_error() { + let mut out = vec![7u8]; + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX, &mut out); + assert!(matches!(r, Err(PsdError::LimitExceeded(_))), "{r:?}"); + assert_eq!(out, [7]); + // The largest `expected` that fits still decodes up to the input, then reports it short. + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX - 1, &mut out); + assert!(matches!(r, Err(PsdError::Decompress(_))), "{r:?}"); + assert_eq!(out, [7, 0xaa]); + } + #[test] fn packbits_zero_expected_ignores_input() { assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); From 59660345f959cb9e3afb5aaa37a142fc85afe3d4 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 12:34:02 +0300 Subject: [PATCH 0416/1452] fix(ui): keep Color panel hex/RGB readout visible in short docks When the pro Color field stretched to a 120 pt minimum, the hex and RGB controls were clipped below the scroll area in compact groups (storytold/photocraft#6885). Co-authored-by: Cursor --- crates/ui-egui/src/panels.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..db313ee6bb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3610,7 +3610,9 @@ pub(crate) fn color_field_fill_id() -> egui::Id { /// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. fn color_field_height(avail: Option, width: f32) -> f32 { let reserve = 32.0 + if width < 280.0 { 26.0 } else { 0.0 }; - avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(120.0, 2000.0)) + // When docked, shrink the field so the hex/RGB readout stays in view (#6885). A 120 pt floor + // here pushed the readout below the scroll area in a compact Color group. + avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(0.0, 2000.0)) } /// The colour the SV field draws for `hsva`, with what the field showed last (`hue`, `saturation`) @@ -4497,11 +4499,34 @@ mod color_tests { assert_eq!(color_field_height(None, 300.0), 120.0); assert_eq!(color_field_height(Some(500.0), 300.0), 468.0); assert_eq!(color_field_height(Some(500.0), 200.0), 442.0); - assert_eq!(color_field_height(Some(60.0), 300.0), 120.0); + assert_eq!(color_field_height(Some(60.0), 300.0), 28.0); assert_eq!(color_field_height(Some(f32::NAN), 300.0), 120.0); assert_eq!(color_field_height(Some(1.0e9), 300.0), 2000.0); } + /// A squeezed Color group still shows the hex/RGB readout, not only the spectrum field (#6885). + #[test] + fn docked_color_field_keeps_rgb_readout_at_compact_height() { + use egui_kittest::kittest::Queryable; + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // Body height after tab strip and margins in a Color group at `compact_height` (130 pt). + let inner = 86.0; + let mut h = egui_kittest::Harness::builder().with_size(vec2(240.0, inner)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.set_height(inner); + ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner)); + color_field(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(2); + assert!(h.query_by_label("R").is_some(), "RGB readout stays visible when the group is short"); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "hex field stays visible"); + } + fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a frame apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state( From a78e9f05fca98315732ef18eccd5cd0e6581e496 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:33:33 -0700 Subject: [PATCH 0417/1452] Save: ask before replacing a file that an added or lowercased extension names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save dialog asks before replacing only the file it answers with. After it closes, pick_save can change that path: it gives a name without an extension the suggested file's extension (#5039), and Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to the changed path at once, and a file there was replaced without a question. On Linux this happens when the Swatches panel exports to a folder that holds Swatches.aco (the one-type portal dialog shows and asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and in rfd's zenity fallback dialog. When the path written differs from the dialog's answer and a file exists there, the save now asks "“{name}” already exists. Replace it?". Replace writes the file, and ui.dialog.confirm returns the save's result. Cancel writes nothing and ends the save like the dialog's Cancel, and Save in the close prompt of an untitled document returns to that prompt. PhotoCraft counts the file dialog as open until the question is answered. A name that reaches the same file, as CAT.PSD and CAT.psd do on Windows and macOS by default, is not asked about again. The new same_file service decides this: the desktop app compares device and inode on Unix and the canonical paths on Windows. The web has no file stamps and does not ask. The two new strings have rows in all 20 catalogs. --- apps/photocraft/src/services.rs | 37 +++++++++ crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/discard_ui.rs | 47 +++++++++++ crates/ui-egui/src/file_dialog.rs | 94 +++++++++++++++++++--- crates/ui-egui/src/file_dialog/tests.rs | 102 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 7 ++ crates/ui-egui/src/replace_prompt.rs | 46 +++++++++++ crates/ui-egui/src/swatches_ui.rs | 26 ++++++ docs/control-protocol.md | 1 + docs/ui-dialogs.md | 2 + 30 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/replace_prompt.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..92ce4840f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -298,6 +298,26 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) } +/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path +/// on Windows (where it holds the name's letter case as stored). False when either can't be read. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -511,6 +531,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), + same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))), automation_read, automation_write, automation_open_pcraft, @@ -888,6 +909,22 @@ mod tests { assert_eq!(dialog_file_name("", true), None); } + /// A name in another letter case reaches the same file where the file system ignores case + /// (Windows, and macOS by default), and only there. Two files with the same contents are two. + #[test] + fn same_file_follows_the_file_system() { + let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD")); + std::fs::write(&cat, b"same").unwrap(); + std::fs::write(&dog, b"same").unwrap(); + assert!(same_file(&cat, &cat)); + assert!(!same_file(&cat, &dog)); + assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored"); + assert!(!same_file(&dir.join("missing.psd"), &cat)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43b4f3d3f9..0d76025828 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -322,6 +322,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), DialogKind::Command if crate::disk_change_prompt::owns(&fields) => crate::disk_change_prompt::body(ui, &fields), + DialogKind::Command if crate::replace_prompt::owns(&fields) => crate::replace_prompt::body(ui, &fields), DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), @@ -405,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { tl!("Create") } else if crate::delete_layer_prompt::owns(&d.fields) { tl!("Delete") - } else if crate::disk_change_prompt::owns(&d.fields) { + } else if crate::disk_change_prompt::owns(&d.fields) || crate::replace_prompt::owns(&d.fields) { tl!("Replace") } else if d.fields.contains_key("__export") { tl!("Export") @@ -621,6 +622,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), DialogKind::Command if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), @@ -679,6 +681,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::open_prompt::owns(&closed.fields) { crate::open_prompt::answer(app, None)?; } + // Cancel on the question whether to replace a file writes nothing. + if crate::replace_prompt::owns(&closed.fields) { + crate::replace_prompt::cancel(app)?; + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 3dbf33c905..ac9d3b030e 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -878,6 +878,53 @@ mod tests { assert!(h.state().session.documents().is_empty()); } + /// Save on closing an untitled document, answered with `kept` while `kept.psd` exists: the + /// prompt waits for the question whether to replace it. Cancel brings the prompt back with + /// nothing written; Replace saves and closes the document. + #[test] + fn saving_from_the_prompt_waits_for_the_question_whether_to_replace() { + use crate::{FileDialogAnswer, FileDialogReply}; + use egui_kittest::kittest::Queryable; + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let (w, waiting): (_, Rc>>) = (written.clone(), Rc::default()); + let held = waiting.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + app.services.file_stamp = Some(Box::new(|p: &str| (p == "/pics/kept.psd").then_some(crate::FileStamp { len: 1, modified: None }))); + app.services.file_dialog = Some(Box::new(move |_, _, reply| *held.borrow_mut() = Some(reply))); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + let save = |h: &mut Prompted| { + h.key_press(Key::Y); + h.run_steps(3); + waiting.borrow_mut().take().expect("the save dialog is open").send(Some(FileDialogAnswer::SaveTo("/pics/kept".into()))); + h.run_steps(2); + h.state().ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the save asks first") + }; + let id = save(&mut h); + assert!(h.state().file_dialog_open()); + assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the question"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert_eq!(docs_left(&h), Some(1)); + assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back"); + + let id = save(&mut h); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty()); + } + #[test] fn saving_from_the_prompt_waits_for_a_background_save_then_closes() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7cdff50af9..7c9e83a0bb 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -73,12 +73,20 @@ pub type FileDialogFn = Box, /// What to do with the answer (`None`: cancelled). type Then = Box) -> Result>; +/// The path a save writes to for the path the save dialog answered (see [`saved_path`]). +type SavedPath = Box String>; + /// The file dialog in progress. pub(crate) struct Pending { /// Not shown yet: it is shown at the end of the frame, which has the window to parent it to. request: Option, answer: Option>>, then: Then, + /// For a save dialog, the path the save writes to for the dialog's answer. + saved_path: Option, + /// The save dialog's answer while the app asks whether to replace the file it writes to + /// (`replace_prompt`). + held: Option, } /// The error of a cancelled dialog: not reported, since backing out is the user's choice. @@ -115,7 +123,7 @@ impl PhotocraftApp { Some(FileDialogAnswer::Unavailable(why)) => Err(why), Some(a) => then(app, a), }); - self.file_dialog = Some(Pending { request: Some(request), answer: None, then }); + self.file_dialog = Some(Pending { request: Some(request), answer: None, then, saved_path: None, held: None }); Ok(json!({ "fileDialog": kind })) } @@ -130,7 +138,9 @@ impl PhotocraftApp { } /// Ask where to save: `then` gets the chosen path, with a lowercase extension when - /// Preferences ▸ File Handling › Lowercase Extension is on (its default). + /// Preferences ▸ File Handling › Lowercase Extension is on (its default). When that, or the + /// extension added to a bare name, changes the path to one where a file exists, the app asks + /// whether to replace it before `then` runs (`replace_prompt`). /// /// A bare file name (export dialogs, an untitled document) starts in the folder the active /// document was last saved or exported to through a dialog, else beside the document @@ -153,13 +163,10 @@ impl PhotocraftApp { // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's // Save As appends the extension of the chosen file type. let suggested_ext = suggested.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { + let ext = suggested_ext.clone(); + let asked = self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { FileDialogAnswer::SaveTo(path) => { - let path = Self::lowercased_extension(app, path); - let path = match &suggested_ext { - Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), - _ => path, - }; + let path = saved_path(app, &path, suggested_ext.as_deref()); // The web answers a download name, which has no folder to remember. if let Some(doc) = doc && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) @@ -169,7 +176,32 @@ impl PhotocraftApp { then(app, path) } _ => Err(UNEXPECTED.into()), - }) + })?; + if let Some(p) = self.file_dialog.as_mut() { + p.saved_path = Some(Box::new(move |app, path| saved_path(app, path, ext.as_deref()))); + } + Ok(asked) + } + + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog + /// answered `answered`, and [`saved_path`] changed it. A file at `path` that + /// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the + /// same name in another letter case on a file system that ignores case, as Windows and macOS do + /// by default) counts as asked about. Never on the web, which has no file stamps. + fn replaces_unasked(&self, answered: &str, path: &str) -> bool { + let Some(stamp) = self.services.file_stamp.as_ref() else { return false }; + answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path)) + } + + /// The question whether to replace a file is answered. Replace writes the save dialog's answer + /// as if the question had not been asked and returns what the save returns, its failure + /// included. Cancel ends the save like the dialog's Cancel. + pub(crate) fn answer_replace(&mut self, replace: bool) -> Result { + let held = self.file_dialog.as_mut().and_then(|p| p.held.take()).ok_or("no save is waiting to replace a file")?; + match self.finish_file_dialog(replace.then_some(held)) { + Err(e) if e == CANCELLED => Ok(json!({ "replaced": false })), + r => r, + } } /// `X.PSD` → `X.psd`, only the extension: the stem and the folders keep the user's spelling. @@ -275,6 +307,14 @@ impl PhotocraftApp { /// continues the action that asked once the answer is in. Its errors go to the status bar. pub fn poll_file_dialog(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) { let Some(p) = self.file_dialog.as_mut() else { return }; + if p.held.is_some() { + // The question whether to replace answers through `answer_replace`. Closed without an + // answer, it counts as Cancel. + if !self.ui.dialogs.iter().any(|d| crate::replace_prompt::owns(&d.fields)) { + let _ = self.answer_replace(false); + } + return; + } if let Some(request) = p.request.take() { let (tx, rx) = mpsc::channel(); p.answer = Some(rx); @@ -288,13 +328,43 @@ impl PhotocraftApp { // The reply was dropped unanswered: the dialog couldn't be shown. Some(Err(TryRecvError::Disconnected)) | None => None, }; - let Some(Pending { then, .. }) = self.file_dialog.take() else { return }; - if let Err(e) = then(self, answer) + if let Some(FileDialogAnswer::SaveTo(answered)) = &answer + && let Some(path) = self.file_dialog.as_ref().and_then(|p| p.saved_path.as_ref()).map(|written| written(self, answered)) + && self.replaces_unasked(answered, &path) + { + crate::replace_prompt::open(self, answered, &path); + if let Some(p) = self.file_dialog.as_mut() { + p.answer = None; + p.held = answer; + } + return; + } + let _ = self.finish_file_dialog(answer); + } + + /// Ends the file dialog in progress: its action continues with `answer`, and its errors go to + /// the status bar. Returns the action's result. + fn finish_file_dialog(&mut self, answer: Option) -> Result { + let Some(Pending { then, .. }) = self.file_dialog.take() else { return Err(CANCELLED.into()) }; + let r = then(self, answer); + if let Err(e) = &r && e != CANCELLED { - self.ui.status = e; + self.ui.status = e.clone(); self.ui.status_error = true; } + r + } +} + +/// The path a save writes to for the path the save dialog answered: its extension lowercased +/// when Preferences ▸ File Handling › Lowercase Extension is on, and a name without one given +/// `suggested_ext`, the suggested file's. +fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) -> String { + let path = PhotocraftApp::lowercased_extension(app, answered.to_string()); + match suggested_ext { + Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), + _ => path, } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..e08d576c01 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -433,6 +433,108 @@ fn save_paths_without_an_extension_get_the_suggested_one() { assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); } +/// The question whether to replace a file (`replace_prompt`), when one is open. +fn replace_prompt(app: &PhotocraftApp) -> Option { + app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id) +} + +/// File › Save As, answered with `path`. +fn save_as(app: &mut PhotocraftApp, open: &Open, path: &str) { + let ctx = egui::Context::default(); + menus::invoke(app, &ctx, "file.saveAs", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); +} + +/// The save dialog asked only about `cat`. The extension added to it names a file that exists, +/// so the save asks before replacing that file: Cancel writes nothing, Replace writes it. +#[test] +fn a_save_asks_before_replacing_the_file_its_added_extension_names() { + let ctx = egui::Context::default(); + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/cat.psd"); + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("the save asks first"); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert!(fields["message"].as_str().unwrap().contains("“cat.psd”"), "{fields:?}"); + assert!(fields["detail"].as_str().unwrap().contains("“cat”"), "{fields:?}"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + app.poll_file_dialog(&ctx, None); + assert!(app.file_dialog_open(), "the save waits for the answer"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(!app.file_dialog_open() && !app.ui.status_error); + assert_eq!(app.session.active().unwrap().path, None); + + // A question closed without an answer counts as Cancel. + save_as(&mut app, &open, "/pics/cat"); + app.ui.close_dialog(replace_prompt(&app).expect("asked again")); + app.poll_file_dialog(&ctx, None); + assert!(written.borrow().is_empty() && !app.file_dialog_open()); + + // Replace answers with the save's failure. + save_as(&mut app, &open, "/pics/cat"); + let write = app.services.write.replace(Box::new(|_: &str, _: &[u8]| Err("disk full".into()))); + let id = replace_prompt(&app).expect("asked again"); + let e = crate::dialogs::confirm(&mut app, id).unwrap_err(); + assert!(e.contains("disk full") && app.ui.status_error, "{e}"); + app.services.write = write; + + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("asked again"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/cat.psd"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/cat.psd")); + assert!(!app.file_dialog_open() && replace_prompt(&app).is_none()); +} + +/// Lowercase Extension changes `CAT.PSD` to `CAT.psd`. Where letter case tells two files apart +/// (Linux), the dialog asked only about `CAT.PSD`; where it doesn't (Windows and macOS by +/// default), both names reach the same file, which the dialog asked about. +#[test] +fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() { + { + // Two files, even with the same size and modification time. + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a == b)); + save_as(&mut app, &open, "/pics/CAT.PSD"); + let id = replace_prompt(&app).expect("the save asks first"); + assert!(written.borrow().is_empty()); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); + } + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a.eq_ignore_ascii_case(b))); + save_as(&mut app, &open, "/pics/CAT.PSD"); + assert!(replace_prompt(&app).is_none(), "the dialog asked about this file"); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); +} + +/// The save dialog asked about the file it answered: a bare name with no file under the added +/// extension, and a full name, are written at once. +#[test] +fn a_save_writes_at_once_when_the_dialog_asked_about_the_file() { + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/dog.png"); + save_as(&mut app, &open, "/pics/cat"); + save_as(&mut app, &open, "/pics/dog.png"); + assert!(replace_prompt(&app).is_none()); + assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); +} + #[test] fn save_and_export_dialogs_start_beside_the_document() { let (mut app, open, _) = app(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..d12c09fb5c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3818,3 +3818,5 @@ navigator Zoom Zvětšení GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. + “{name}” already exists. Replace it? „{name}“ již existuje. Nahradit? + The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b1ea8977e2..05c2a8cb7e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3212,3 +3212,5 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + “{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen? + The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8229806307..03d45ed037 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3910,3 +3910,5 @@ curves Smooth Εξομάλυνση GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. + “{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί; + The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}». diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f3d85002b..9e5158bf67 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4172,3 +4172,5 @@ navigator Zoom Zoom GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. + “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? + The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4a819b0e1b..139db79602 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3216,3 +3216,5 @@ curves Smooth Lisser Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. + “{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ? + The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ». diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86a6b30acb..7705a01e67 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4129,3 +4129,5 @@ cameraRaw Yellow Sárga GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. + “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? + The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 49777913c1..1ce1b3ab20 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3203,3 +3203,5 @@ curves Smooth Haluskan Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. + “{name}” already exists. Replace it? “{name}” sudah ada. Ganti? + The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f1144c0fdd..3e4d805f3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4331,3 +4331,5 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + “{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo? + The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 351699ea89..11532fe9e7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3887,3 +3887,5 @@ curves Smooth スムーズ Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 + “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? + The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3eaf143979..ad6ba85342 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3389,3 +3389,5 @@ curves Smooth 매끄럽게 Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. + “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? + The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09dd568009..4bb345d1e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3970,3 +3970,5 @@ curves Smooth Vloeiend maken GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. + “{name}” already exists. Replace it? “{name}” bestaat al. Vervangen? + The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c4827aa829..d2446750ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3809,3 +3809,5 @@ curves Smooth Wygładź Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. + “{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić? + The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f96542d642..75a9640695 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4000,3 +4000,5 @@ layerLabel No Color Sem cor GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. + “{name}” already exists. Replace it? “{name}” já existe. Substituir? + The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..f95a6a6cee 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3181,3 +3181,5 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + “{name}” already exists. Replace it? «{name}» уже существует. Заменить? + The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}». diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c207facb72..a83e28b7ef 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4283,3 +4283,5 @@ shape Appearance Izgled GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. + “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? + The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8aa0c3c58..9cf46749b6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3940,3 +3940,5 @@ cameraData Lens Lens GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. + “{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi? + The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 48fb60343c..28d4aa961c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3910,3 +3910,5 @@ curves Smooth Згладити Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. + “{name}” already exists. Replace it? «{name}» уже існує. Замінити? + The name in the save dialog was “{name}”. У вікні збереження було вказано назву «{name}». diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f33ef15011..112b44aa1e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3697,3 +3697,5 @@ curves Smooth Làm mượt Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. + “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế? + The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75eacc994d..988d3faad0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2827,3 +2827,5 @@ curves Smooth 平滑 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 + “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? + The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a11dc593..75be2a7289 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2812,3 +2812,5 @@ curves Smooth 平滑 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 + “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? + The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..8bb3bdc586 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,7 @@ pub mod proxy; pub mod puppet_ui; pub mod quick_pick; pub mod rasterize_prompt; +pub mod replace_prompt; pub mod retouch_ui; mod rgb_histogram; pub mod rotate_view; @@ -360,6 +361,8 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; +/// Whether two paths reach the same file on disk; false when either can't be read. +pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. @@ -460,6 +463,10 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, + /// Whether two paths reach the same file (native): a save dialog's answer and the path the + /// save writes to after Lowercase Extension, on a file system that ignores letter case. A save + /// asks before replacing a file it finds different (`replace_prompt`). + pub same_file: Option, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, diff --git a/crates/ui-egui/src/replace_prompt.rs b/crates/ui-egui/src/replace_prompt.rs new file mode 100644 index 0000000000..7f301db8e7 --- /dev/null +++ b/crates/ui-egui/src/replace_prompt.rs @@ -0,0 +1,46 @@ +//! A save asks before replacing a file that the save dialog did not ask about. The dialog asks +//! only about the name it answers, and the app can change that name afterwards: it gives a name +//! without an extension the suggested file's (#5039), and lowercases the extension when +//! Preferences ▸ File Handling › Lowercase Extension is on. When a file exists under the changed +//! name, Replace writes over it and Cancel ends the save like the save dialog's Cancel. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. The file dialog stays open until it is answered +//! ([`PhotocraftApp::file_dialog_open`]). + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::file_open::display_name; +use crate::state::DialogKind; + +const MARK: &str = "__replaceSaved"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt about `path`, the file a save writes to for `answered`, the save dialog's +/// answer. +pub(crate) fn open(app: &mut PhotocraftApp, answered: &str, path: &str) { + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", display_name(path).as_str())]); + let detail = crate::i18n::fmt(tl!("The name in the save dialog was “{name}”."), &[("name", display_name(answered).as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "detail": detail, "path": path}); + app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); +} + +pub fn body(ui: &mut egui::Ui, fields: &Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); + ui.add_space(6.0); + ui.label(fields.get("detail").and_then(Value::as_str).unwrap_or_default()); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp) -> Result { + app.answer_replace(true) +} + +/// Cancel: nothing is written. +pub fn cancel(app: &mut PhotocraftApp) -> Result { + app.answer_replace(false) +} diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..e73ec1d440 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -442,6 +442,32 @@ mod tests { assert!(out[0].1.starts_with(b"ASEF")); } + /// The Linux save dialog shows the export's name without its extension, `Swatches`, and asks + /// only about that name. The export adds `.aco`, so with `Swatches.aco` in the folder it asks + /// before replacing it. + #[test] + fn export_asks_before_replacing_the_file_its_added_extension_names() { + use crate::file_dialog::FileDialogAnswer; + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + let services = crate::Services { + file_dialog: Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("/swatches/Swatches".into()))]).0), + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + file_stamp: Some(Box::new(|p: &str| (p == "/swatches/Swatches.aco").then_some(crate::FileStamp { len: 1, modified: None }))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + export_dialog(&mut app, "aco").unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + let id = app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the export asks first"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/swatches/Swatches.aco"]); + } + /// #3658: dragging a group header reorders the folders, and the new order sticks in the UI /// state (UI state is serde, so it saves and reloads with the rest of the panel state). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..873dcb5f60 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -66,6 +66,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`. - The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails - The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered. + - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md index 032edfde5d..8767f59714 100644 --- a/docs/ui-dialogs.md +++ b/docs/ui-dialogs.md @@ -50,6 +50,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor | `__stroke` | `stroke_ui.rs` | Edit › Stroke… | | `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) | | `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation | +| `__replaceSaved` | `replace_prompt.rs` | Replace prompt when a save's added or lowercased extension names an existing file | | `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N; ⌥⇧⌘N makes the layer without it) | | `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt | | `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation | @@ -101,6 +102,7 @@ actions with their marker already set. | `__stroke` | `stroke_ui` | | `__createShape` | `shape_dialog` | | `__deleteLayer` | `delete_layer_prompt` | +| `__replaceSaved` | `replace_prompt` | | `__newLayer` | `new_layer_ui` | | `__rasterize` | `rasterize_prompt` | | `__resetAllTools` | `tool_reset` | From 50d42006b56dc87493f41e434d40835a0e284b1b Mon Sep 17 00:00:00 2001 From: Shubham Bhamare <87901258+theshubh77@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:12 +0530 Subject: [PATCH 0418/1452] fix(engine): populate export_presets in Persisted initialization --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From 1819e77c300326d8e798404dc0c9bc8448bef9db Mon Sep 17 00:00:00 2001 From: Shubham Bhamare <87901258+theshubh77@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:19 +0530 Subject: [PATCH 0419/1452] feat(i18n): add Hindi (hi) language catalog and locale resolution --- crates/ui-egui/src/i18n/hi.tsv | 491 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 19 +- crates/ui-egui/src/i18n/system.rs | 1 + docs/localization-parity.md | 2 +- docs/localization.md | 1 + 5 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/i18n/hi.tsv diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv new file mode 100644 index 0000000000..0049ba94a6 --- /dev/null +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -0,0 +1,491 @@ +# Hindi (hi) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Hindi computing and image-editing vocabulary. No proprietary localisation resource, string +# table or product UI was consulted or copied; add entries the same way. Product and technology +# names (PhotoCraft, ArtCraft, OpenType, RGB, CMYK, Lab, PSD, PNG, JPEG, WebP, OpenEXR, AVIF …) +# stay as they are; the trailing "…" of dialog commands is kept. Plural rule: one / other +# (`plural_one_other` in i18n/mod.rs). + +# Top-level Menus + File फ़ाइल + Edit एडिट + Image इमेज + Layer लेयर + Type टाइप + Select चयन + Filter फ़िल्टर + 3D 3डी + View व्यू + Window विंडो + Help सहायता + +# File Menu + New… नया… + Open… खोलें… + Open Recent हाल ही में खोले गए + Close बंद करें + Close All सभी बंद करें + Close Others अन्य सभी बंद करें + Save सहेजें + Save As… इस रूप में सहेजें… + Save a Copy… एक प्रति सहेजें… + Revert वापस लौटें + Export एक्सपोर्ट करें + Export As… इस रूप में एक्सपोर्ट करें… + Quick Export as PNG PNG के रूप में त्वरित एक्सपोर्ट + Import इम्पोर्ट करें + File Info… फ़ाइल जानकारी… + Print… प्रिंट करें… + Print One Copy एक प्रति प्रिंट करें + Exit बाहर निकलें + +# Edit Menu + Undo पूर्ववत करें + Redo फिर से करें + Cut काटें + Copy कॉपी करें + Copy Merged मर्ज की गई कॉपी + Paste पेस्ट + Paste Special विशेष रूप में पेस्ट करें + Paste in Place स्थान पर पेस्ट करें + Paste Into में पेस्ट करें + Paste Outside बाहर पेस्ट करें + Clear साफ़ करें + Fill… भरें… + Stroke… स्ट्रोक… + Free Transform मुक्त रूपांतरण + Transform रूपांतरण करें + Scale आकार बदलें + Rotate घुमाएँ + Skew तिरछा करें + Distort विरूपित करें + Perspective परिप्रेक्ष्य + Warp मोड़ें + Flip Horizontal क्षैतिज पलटें + Flip Vertical लंबवत पलटें + Preferences प्राथमिकताएं + General… सामान्य… + Interface… इंटरफ़ेस… + Workspace… कार्यस्थान… + Tools… टूल्स… + Performance… परफॉरमेंस… + +# Image Menu + Mode मोड + RGB Color RGB रंग + CMYK Color CMYK रंग + Grayscale ग्रेस्केल + Lab Color Lab रंग + 8 Bits/Channel 8 बिट्स/चैनल + 16 Bits/Channel 16 बिट्स/चैनल + 32 Bits/Channel 32 बिट्स/चैनल + Adjustments समायोजन + Brightness/Contrast… चमक/कंट्रास्ट… + Levels… स्तर… + Curves… कर्व्स… + Exposure… एक्सपोज़र… + Vibrance… वाइब्रेंस… + Hue/Saturation… ह्यू/सैचुरेशन… + Color Balance… कलर बैलेंस… + Black & White… ब्लैक एंड व्हाइट… + Photo Filter… फ़ोटो फ़िल्टर… + Invert इन्वर्ट + Posterize… पोस्टराइज़ करें… + Threshold… थ्रेशहोल्ड… + Image Size… इमेज साइज़… + Canvas Size… कैनवास साइज़… + Image Rotation इमेज रोटेशन + 180° 180° + 90° Clockwise 90° दक्षिणावर्त + 90° Counter Clockwise 90° वामावर्त + Arbitrary… इच्छानुसार… + Crop क्रॉप करें + Trim… ट्रिम करें… + Reveal All सभी दिखाएँ + Duplicate… डुप्लीकेट… + +# Layer Menu + New नया + Layer… लेयर… + Layer from Background पृष्ठभूमि से लेयर + Group… समूह… + Group from Layers… लेयर्स से समूह… + Artboard… आर्टबोर्ड… + Duplicate Layer… लेयर को डुप्लीकेट करें… + Delete हटाएँ + Delete Layer लेयर हटाएँ + Delete Hidden Layers छिपी हुई लेयर्स हटाएँ + Rename Layer… लेयर का नाम बदलें… + Layer Style लेयर स्टाइल + Blending Options… ब्लेंडिंग विकल्प… + Smart Objects स्मार्ट ऑब्जेक्ट्स + Convert to Smart Object स्मार्ट ऑब्जेक्ट में बदलें + Layer Mask लेयर मास्क + Hide All सभी छिपाएँ + Reveal Selection चयन दिखाएँ + Hide Selection चयन छिपाएँ + From Transparency पारदर्शिता से + Apply लागू करें + Enable सक्षम करें + Disable अक्षम करें + Merge Down नीचे मर्ज करें + Merge Visible दृश्यमान मर्ज करें + Flatten Image इमेज फ़्लैट करें + +# Select Menu + All सभी + Deselect अचयनित करें + Reselect पुनः चयन करें + Inverse उलटा करें + All Layers सभी लेयर्स + Deselect Layers लेयर्स अचयनित करें + Find Layers लेयर्स खोजें + Color Range… रंग सीमा… + Select and Mask… चयन और मास्क… + Modify संशोधित करें + Border… सीमा… + Smooth… चिकना करें… + Expand… विस्तार करें… + Contract… संकुचित करें… + Feather… फ़ेदर… + Grow बढ़ाएँ + Similar समान + Transform Selection चयन रूपांतरित करें + Load Selection… चयन लोड करें… + Save Selection… चयन सहेजें… + +# View Menu + Proof Setup प्रूफ़ सेटअप + Proof Colors प्रूफ़ रंग + Zoom In ज़ूम इन + Zoom Out ज़ूम आउट + Fit on Screen स्क्रीन पर फ़िट करें + Fit Layer on Screen स्क्रीन पर लेयर फ़िट करें + Actual Pixels वास्तविक पिक्सेल + 100% 100% + 200% 200% + Rulers रूलर्स + Snap स्नैप + Snap To पर स्नैप करें + Guides गाइड्स + Lock Guides गाइड्स लॉक करें + Clear Guides गाइड्स हटाएं + New Guide… नई गाइड… + Lock Slices स्लाइस लॉक करें + Clear Slices स्लाइस हटाएं + +# Window Menu & Panels + Arrange व्यवस्थित करें + Tile Horizontally क्षैतिज टाइल + Tile Vertically लंबवत टाइल + Consolidate All to Tabs सभी को टैब में समेकित करें + Workspace कार्यस्थान + Reset Essentials एसेंशियल्स रीसेट करें + New Workspace… नया कार्यस्थान… + Delete Workspace… कार्यस्थान हटाएँ… + Lock Workspace कार्यस्थान लॉक करें + Color रंग + Swatches नमूने + Gradients ग्रेडिएंट्स + Patterns पैटर्न्स + Properties गुण + Layers लेयर्स + Channels चैनल्स + Paths पथ + History हिस्ट्री + Actions एक्शन्स + Brushes ब्रश + Brush Settings ब्रश सेटिंग्स + Navigator नेविगेटर + Info जानकारी + Timeline टाइमलाइन + Tools टूल्स + Options विकल्प + Status Bar स्टेटस बार + +# Tools (Toolbox) + Move Tool मूव टूल + Artboard Tool आर्टबोर्ड टूल + Rectangular Marquee Tool रेक्टेंगुलर मार्की टूल + Elliptical Marquee Tool एलिप्टिकल मार्की टूल + Single Row Marquee Tool सिंगल रो मार्की टूल + Single Column Marquee Tool सिंगल कॉलम मार्की टूल + Lasso Tool लैस्सो टूल + Polygonal Lasso Tool पॉलीगोनल लैस्सो टूल + Magnetic Lasso Tool मैग्नेटिक लैस्सो टूल + Object Selection Tool ऑब्जेक्ट चयन टूल + Quick Selection Tool क्विक चयन टूल + Magic Wand Tool मैजिक वैंड टूल + Crop Tool क्रॉप टूल + Perspective Crop Tool परस्पेक्टिव क्रॉप टूल + Slice Tool स्लाइस टूल + Slice Select Tool स्लाइस चयन टूल + Frame Tool फ़्रेम टूल + Eyedropper Tool आईड्रॉपर टूल + Color Sampler Tool कलर सैंपलर टूल + Ruler Tool रूलर टूल + Note Tool नोट टूल + Count Tool काउंट टूल + Spot Healing Brush Tool स्पॉट हीलिंग ब्रश टूल + Healing Brush Tool हीलिंग ब्रश टूल + Patch Tool पैच टूल + Content-Aware Move Tool कंटेंट-अवेयर मूव टूल + Red Eye Tool रेड आई टूल + Brush Tool ब्रश टूल + Pencil Tool पेंसिल टूल + Color Replacement Tool कलर रिप्लेसमेंट टूल + Mixer Brush Tool मिक्सर ब्रश टूल + Clone Stamp Tool क्लोन स्टैम्प टूल + Pattern Stamp Tool पैटर्न स्टैम्प टूल + History Brush Tool हिस्ट्री ब्रश टूल + Art History Brush Tool आर्ट हिस्ट्री ब्रश टूल + Eraser Tool इरेज़र टूल + Background Eraser Tool बैकग्राउंड इरेज़र टूल + Magic Eraser Tool मैजिक इरेज़र टूल + Gradient Tool ग्रेडिएंट टूल + Paint Bucket Tool पेंट बकेट टूल + Blur Tool ब्लर टूल + Sharpen Tool शार्पन टूल + Smudge Tool स्मज टूल + Dodge Tool डॉज टूल + Burn Tool बर्न टूल + Sponge Tool स्पंज टूल + Pen Tool पेन टूल + Freeform Pen Tool फ्रीफॉर्म पेन टूल + Curvature Pen Tool कर्वचर पेन टूल + Add Anchor Point Tool एंकर पॉइंट टूल + Delete Anchor Point Tool एंकर पॉइंट टूल + Convert Point Tool कन्वर्ट पॉइंट टूल + Horizontal Type Tool हॉरिजोंटल टाइप टूल + Vertical Type Tool वर्टिकल टाइप टूल + Horizontal Type Mask Tool हॉरिजोंटल टाइप मास्क टूल + Vertical Type Mask Tool वर्टिकल टाइप मास्क टूल + Path Selection Tool पाथ चयन टूल + Direct Selection Tool डायरेक्ट चयन टूल + Rectangle Tool रेक्टेंगल टूल + Rounded Rectangle Tool राउंडेड रेक्टेंगल टूल + Ellipse Tool एलिप्स टूल + Triangle Tool ट्रायंगल टूल + Polygon Tool पॉलीगोन टूल + Line Tool लाइन टूल + Custom Shape Tool कस्टम शेप टूल + Hand Tool हैंड टूल + Rotate View Tool रोटेट व्यू टूल + Zoom Tool ज़ूम टूल + +# Common Dialogs and Buttons + OK ठीक है + Cancel रद्द करें + Reset रीसेट करें + Done संपन्न + Don't Save सहेजें नहीं + Yes हाँ + No नहीं + Width: चौड़ाई: + Height: ऊंचाई: + Resolution: रिज़ॉल्यूशन: + Pixels पिक्सेल + Inches इंच + Centimeters सेंटीमीटर + Millimeters मिलीमीटर + Points पॉइंट्स + Percent प्रतिशत + Opacity: ओपेसिटी: + Flow: फ्लो: + Hardness: हार्डनेस: + Size: साइज़: + Angle: एंगल: + Roundness: राउंडनेस: + Spacing: स्पेसिंग: + Color: कलर: + Name: नेम: + Presets: प्रीसेट्स: + Language भाषा + Auto स्वचालित + +# Plurals +@plural {n} layer|{n} layers {n} लेयर|{n} लेयरें +@plural {n} item|{n} items {n} आइटम|{n} आइटम +@plural {n} document|{n} documents {n} दस्तावेज़|{n} दस्तावेज़ +@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल + +# Tool and Scope strings + Scope दायरा + Selected चयनित + Mixer Brush मिक्सर ब्रश + Wet गीला + Load लोड + Mix मिश्रण + Flow प्रवाह + Sample All Layers सभी लेयर्स का नमूना लें + Pattern Stamp पैटर्न स्टैम्प + Impressionist प्रभाववादी + Aligned संरेखित + Fit in View दृश्य में फ़िट करें + Camera Raw Camera Raw + +# Camera Raw +cameraRaw Amount मात्रा + Amount मात्रा +cameraRaw Aqua एक्वा + Aqua एक्वा +cameraRaw Balance संतुलन + Balance संतुलन +cameraRaw Before (Y) पहले (Y) + Before (Y) पहले (Y) +cameraRaw Blacks काले बिंदु + Blacks काले बिंदु +cameraRaw Blending मिश्रण + Blending मिश्रण +cameraRaw Blue नीला + Blue नीला +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer}) + Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer}) +cameraRaw Camera Raw isn't open Camera Raw खुला नहीं है + Camera Raw isn't open Camera Raw खुला नहीं है +cameraRaw Cancel रद्द करें +cameraRaw Cancelled opening {name} {name} खोलना रद्द किया गया + Cancelled opening {name} {name} खोलना रद्द किया गया +cameraRaw Cancelled {label} {label} रद्द किया गया + Cancelled {label} {label} रद्द किया गया +cameraRaw Clarity स्पष्टता + Clarity स्पष्टता +cameraRaw Color रंग +cameraRaw Color Detail रंग विवरण + Color Detail रंग विवरण +cameraRaw Color Grading कलर ग्रेडिंग + Color Grading कलर ग्रेडिंग +cameraRaw Color Mixer कलर मिक्सर + Color Mixer कलर मिक्सर +cameraRaw Color Priority रंग प्राथमिकता + Color Priority रंग प्राथमिकता +cameraRaw Contrast कंट्रास्ट + Contrast कंट्रास्ट +cameraRaw Curve कर्व + Curve कर्व +cameraRaw Darks गहरा अंधेरा + Darks गहरा अंधेरा +cameraRaw Dehaze धुंध हटाएं + Dehaze धुंध हटाएं +cameraRaw Detail विवरण + Detail विवरण +cameraRaw Developing {name} {name} विकसित किया जा रहा है + Developing {name} {name} विकसित किया जा रहा है +cameraRaw Effects प्रभाव + Effects प्रभाव +cameraRaw Exposure एक्सपोज़र + Exposure एक्सपोज़र +cameraRaw Feather फ़ेदर + Feather फ़ेदर +cameraRaw Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है + Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है +cameraRaw Global समग्र + Global समग्र +cameraRaw Grain ग्रेन + Grain ग्रेन +cameraRaw Green हरा + Green हरा +cameraRaw Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O) + Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O) +cameraRaw Highlight Priority हाइलाइट प्राथमिकता + Highlight Priority हाइलाइट प्राथमिकता +cameraRaw Highlights हाइलाइट्स + Highlights हाइलाइट्स +cameraRaw Hue रंग + Hue रंग +cameraRaw Light प्रकाश + Light प्रकाश +cameraRaw Lights हल्की रोशनी + Lights हल्की रोशनी +cameraRaw Load Settings… सेटिंग्स लोड करें… + Load Settings… सेटिंग्स लोड करें… +cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें + Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें +cameraRaw Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं + Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं +cameraRaw Luminance चमक + Luminance चमक +cameraRaw Luminance Detail चमक विवरण + Luminance Detail चमक विवरण +cameraRaw Magenta मैजेंटा + Magenta मैजेंटा +cameraRaw Masking मास्किंग + Masking मास्किंग +cameraRaw Midpoint मध्य बिंदु + Midpoint मध्य बिंदु +cameraRaw Midtones मध्यम टोन + Midtones मध्यम टोन +cameraRaw Noise Reduction शोर में कमी + Noise Reduction शोर में कमी +cameraRaw OK ठीक है +cameraRaw Open खोलें + Open खोलें +cameraRaw Opened {name} {name} खोला गया + Opened {name} {name} खोला गया +cameraRaw Orange नारंगी + Orange नारंगी +cameraRaw Paint Overlay पेंट ओवरले + Paint Overlay पेंट ओवरले +cameraRaw Purple बैंगनी + Purple बैंगनी +cameraRaw Radius त्रिज्या + Radius त्रिज्या +cameraRaw Red लाल + Red लाल +cameraRaw Roughness खुरदरापन + Roughness खुरदरापन +cameraRaw Roundness गोलाई + Roundness गोलाई +cameraRaw Saturation संतृप्ति + Saturation संतृप्ति +cameraRaw Save Settings… सेटिंग्स सहेजें… + Save Settings… सेटिंग्स सहेजें… +cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें + Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें +cameraRaw Shadow Clipping Warning (U) छाया क्लिपिंग चेतावनी (U) + Shadow Clipping Warning (U) छाया क्लिपिंग चेतावनी (U) +cameraRaw Shadows शेडोज़ + Shadows शेडोज़ +cameraRaw Sharpening शार्पनिंग + Sharpening शार्पनिंग +cameraRaw Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ + Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ +cameraRaw Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ + Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ +cameraRaw Show Selected Region चयनित क्षेत्र दिखाएँ + Show Selected Region चयनित क्षेत्र दिखाएँ +cameraRaw Show Skin Tone Indicator त्वचा टोन संकेतक दिखाएँ + Show Skin Tone Indicator त्वचा टोन संकेतक दिखाएँ +cameraRaw Show Vectorscope वेक्टरस्कोप दिखाएँ + Show Vectorscope वेक्टरस्कोप दिखाएँ +cameraRaw Size आकार + Size आकार +cameraRaw Temperature तापमान + Temperature तापमान +cameraRaw Texture बनावट + Texture बनावट +cameraRaw Tint रंग झलक + Tint रंग झलक +cameraRaw Tone Histogram टोन हिस्टोग्राम + Tone Histogram टोन हिस्टोग्राम +cameraRaw Vibrance जीवंतता + Vibrance जीवंतता +cameraRaw Vignetting विगनेटिंग + Vignetting विगनेटिंग +cameraRaw White Balance: As Shot श्वेत संतुलन: शॉट के अनुसार + White Balance: As Shot श्वेत संतुलन: शॉट के अनुसार +cameraRaw Whites सफेद बिंदु + Whites सफेद बिंदु +cameraRaw Yellow पीला + Yellow पीला diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..53e3b39bc1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -220,6 +220,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + // Hindi; `hi`, `hi-IN` locales resolve here. + LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -436,6 +438,21 @@ mod tests { const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered"); const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered"); const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered"); + const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered"); + + #[test] + fn hindi_tags_resolve() { + for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(HI()), "{tag}"); + } + assert_eq!(HI().name(), "हिन्दी"); + assert_eq!(Lang::from_pref("hi"), HI()); + assert_eq!(Lang::from_pref("HI"), HI()); + assert_eq!(tr(HI(), "Layer"), "लेयर"); + assert_eq!(tr(HI(), "File"), "फ़ाइल"); + assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर"); + assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयरें"); + } #[test] fn indonesian_tags_resolve() { diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 49bc3bc79b..25cc9fec4f 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -100,6 +100,7 @@ mod tests { (vec!["tr-TR", "en-US"], "tr"), (vec!["sr-Latn-RS", "en-US"], "sr"), (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), + (vec!["hi-IN", "en-US"], "hi"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b2be85595..ab006f409f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -33,7 +33,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde | English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h | | Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h | | Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h | -| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h | +| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | yes (initial) | partial | 15–20 h | | Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h | | French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h | | Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h | diff --git a/docs/localization.md b/docs/localization.md index 36e09f8a55..5a1355057c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -28,6 +28,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | | Srpski | `sr` | one / few / many (teens use many) | +| हिन्दी | `hi` | one / other | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is From 5361595c8c32387414906ef972dfc5021f49ebd2 Mon Sep 17 00:00:00 2001 From: Shubham Bhamare <87901258+theshubh77@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:24 +0530 Subject: [PATCH 0420/1452] docs(contributors): add theshubh77 to people.toml --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..944a0f0ed9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,7 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.theshubh77] +real_name = "Shubham Bhamare" +sync_github_name = true From f13731a51a74667fbc7bd6b1a79fab1955c11683 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Sat, 17 Oct 2026 14:26:50 +0400 Subject: [PATCH 0421/1452] Text: count both coverage buffers on the plain path (#4979) Coverage::finish allocates its output while the accumulator it reads is still live, so a plain pass peaks at two coverage buffers rather than one. The budget charged one, which contradicted finish's own doc comment and left the plain path under its real peak. The bold path's three buffers were already right. The exactness assertion in the budget test now pins 18 bytes a pixel for a grey 8-bit plain render, and fails if the undercount comes back. Caught in review by @FredApps. --- crates/text/src/render.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 51b9e1e5db..13dccc44c3 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -19,20 +19,22 @@ pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; /// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the -/// accumulator and the output surface are both `stride` deep, and a faux-bold pass holds a second -/// coverage buffer, so a CMYK bold render reserves several times what a plain grey one does for -/// the same pixels. +/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more +/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does +/// for the same pixels. const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; /// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the -/// output surface (both `stride` deep), the finished coverage of the colour being drawn and, with -/// faux bold, the second coverage buffer and the vector its `finish` builds beside it. `false` -/// when the arithmetic overflows, so an absurd size is refused rather than wrapping. +/// output surface (both `stride` deep), plus the coverage buffers live at the peak. A plain pass +/// peaks inside `Coverage::finish`, which holds its accumulator and the vector it builds beside +/// it; a faux-bold pass peaks one buffer higher, since the resolved plain coverage is kept while +/// the bold pass resolves into it. `false` when the arithmetic overflows, so an absurd size is +/// refused rather than wrapping. fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool { let f32s = size_of::() as u64; let peak = (w as u64).checked_mul(h as u64).and_then(|px| { let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?; - let coverage = px.checked_mul(if bold { 3 } else { 1 })?.checked_mul(f32s)?; + let coverage = px.checked_mul(if bold { 3 } else { 2 })?.checked_mul(f32s)?; deep.checked_add(coverage) }); peak.is_some_and(|b| b <= max_bytes) @@ -470,17 +472,17 @@ mod tests { /// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel /// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers. - /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 4 = 14 bytes a pixel live. + /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 2 x 4 = 18 bytes a pixel live. #[test] fn the_byte_budget_follows_channels_depth_and_faux_bold() { let px = 100 * 100; - assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14)); - assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14 - 1), "the allowance is exact"); + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18)); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18 - 1), "the allowance is exact"); // The same pixels cost more with more channels, a wider sample, or a bold pass. - assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 14), "CMYK is deeper"); - assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 14), "f32 output is wider"); - assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 14), "bold holds two more buffers"); + assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 18), "CMYK is deeper"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 18), "f32 output is wider"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 18), "bold holds one more buffer"); assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel"); // A size whose peak overflows is refused rather than wrapping to a small number. From 875ec69fcee129ec2bed950b559dd6db86ad0b8b Mon Sep 17 00:00:00 2001 From: Matthias Schlich Date: Sat, 17 Oct 2026 12:28:10 +0200 Subject: [PATCH 0422/1452] fix(filters): Clouds size their pattern by the canvas, not the selection Since Clouds became a filter, its cloud size came from the filter's reference bounds, which are the selection's when there is one: clouds in a selection were finer than the canvas's. Before, they were sized by the document. Clouds now take the canvas as their bounds; the selection only masks them, as before (review on #6160). --- crates/algo/src/lib.rs | 6 ++++++ crates/engine/src/extra_cmds.rs | 25 +++++++++++++++++++++++++ crates/engine/src/filters.rs | 5 +++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ac23dace05..8d8ab4d34e 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -616,6 +616,12 @@ impl FilterParams { matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. }) } + /// Whether the filter sizes its pattern by the canvas even with a selection (Clouds): its + /// bounds are then the canvas, and the selection only masks the result. + pub fn sizes_by_canvas(&self) -> bool { + matches!(self, FilterParams::Clouds { .. }) + } + /// Human-readable name. pub fn label(&self) -> &'static str { match self { diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 2a899858d6..d13244f7ba 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -1440,6 +1440,31 @@ mod tests { } } + /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's + /// clouds, as when Clouds sized itself by the document (before it became a filter). + #[test] + fn clouds_in_a_selection_are_the_canvas_clouds() { + // Big enough that the canvas (cloud size 100 px) and the selection (16 px) size differently. + let big = |depth: u32| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s + }; + for depth in [8, 16, 32] { + let mut whole = big(depth); + whole.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); + let mut s = big(depth); + s.execute("select.rect", json!({"x": 50, "y": 40, "width": 60, "height": 50})).unwrap(); + s.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); + for (x, y) in [(50, 40), (75, 60), (100, 85), (109, 89)] { + let (a, b) = (pixel(&s, x, y), pixel(&whole, x, y)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "depth {depth} at {x},{y}: {a:?} vs {b:?}"); + } + assert_eq!(pixel(&s, 300, 200)[3], 0.0, "outside the selection the layer stays empty"); + } + } + #[test] fn average_and_clouds() { let mut s = session(8); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index e40be7b6ac..d6919ad906 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -262,9 +262,10 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result }; let selection = doc.selection.clone(); let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds); - // Distortions centre on the selection when there is one, else the canvas. + // Distortions centre on the selection when there is one, else the canvas. Clouds size + // their pattern by the canvas either way; the selection only masks them. let doc_bounds = doc.bounds(); - let bounds = sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds); + let bounds = if fp.sizes_by_canvas() { doc_bounds } else { sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds) }; // Neighbourhood filters repeat edge pixels at the canvas edge (or past it, where the // layer has off-canvas pixels) instead of fading in transparency, as Photoshop does. if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? { From 158dcc673197132bfd0404d0c2c5c7b05cc30111 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:30:14 +0000 Subject: [PATCH 0423/1452] Fix engine compile error: load export presets from presets JSON Fixes #6911 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit bb7a5f23091fb4d23c02d253ea78b20f10f142fb) --- crates/engine/src/presets/mod.rs | 1 + crates/engine/src/presets/tests.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b7c0a3ede2..1cde39abe5 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -774,3 +774,21 @@ fn a_damaged_preset_set_keeps_the_other_sets() { assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); assert_eq!(next.presets.documents, s.presets.documents); } + +// ------------------------------------------------------------------ export presets + +#[test] +fn export_presets_survive_a_presets_json_round_trip() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); + assert_eq!(s.presets.export_presets.len(), 1); + + let saved = s.presets.to_json(&s); + assert_eq!(saved["export_presets"].as_array().map(Vec::len), Some(1)); + + let mut restored = Session::new(); + restored.load_presets_json(saved); + assert_eq!(restored.presets.export_presets.len(), 1); + assert_eq!(restored.presets.export_presets[0].name, "Web"); + assert_eq!(restored.presets.export_presets[0].settings.format, "jpg"); +} From 9e2ec2cc8352d8e7f1e971bf33777a3a72409577 Mon Sep 17 00:00:00 2001 From: Shubham Bhamare <87901258+theshubh77@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:08:40 +0530 Subject: [PATCH 0424/1452] fix(i18n): refine Hindi Camera Raw terminology --- crates/ui-egui/src/i18n/hi.tsv | 80 +++++++++++++++++----------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0049ba94a6..cf09a7243a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -374,24 +374,24 @@ cameraRaw Contrast कंट्रास्ट Contrast कंट्रास्ट cameraRaw Curve कर्व Curve कर्व -cameraRaw Darks गहरा अंधेरा - Darks गहरा अंधेरा -cameraRaw Dehaze धुंध हटाएं - Dehaze धुंध हटाएं -cameraRaw Detail विवरण - Detail विवरण +cameraRaw Darks डार्क्स + Darks डार्क्स +cameraRaw Dehaze डीहेज़ + Dehaze डीहेज़ +cameraRaw Detail डिटेल + Detail डिटेल cameraRaw Developing {name} {name} विकसित किया जा रहा है Developing {name} {name} विकसित किया जा रहा है -cameraRaw Effects प्रभाव - Effects प्रभाव +cameraRaw Effects इफ़ेक्ट्स + Effects इफ़ेक्ट्स cameraRaw Exposure एक्सपोज़र Exposure एक्सपोज़र cameraRaw Feather फ़ेदर Feather फ़ेदर cameraRaw Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है -cameraRaw Global समग्र - Global समग्र +cameraRaw Global ग्लोबल + Global ग्लोबल cameraRaw Grain ग्रेन Grain ग्रेन cameraRaw Green हरा @@ -402,32 +402,32 @@ cameraRaw Highlight Priority हाइलाइट प्राथमिकत Highlight Priority हाइलाइट प्राथमिकता cameraRaw Highlights हाइलाइट्स Highlights हाइलाइट्स -cameraRaw Hue रंग - Hue रंग -cameraRaw Light प्रकाश - Light प्रकाश -cameraRaw Lights हल्की रोशनी - Lights हल्की रोशनी +cameraRaw Hue ह्यू + Hue ह्यू +cameraRaw Light लाइट + Light लाइट +cameraRaw Lights लाइट्स + Lights लाइट्स cameraRaw Load Settings… सेटिंग्स लोड करें… Load Settings… सेटिंग्स लोड करें… cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें cameraRaw Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं -cameraRaw Luminance चमक - Luminance चमक -cameraRaw Luminance Detail चमक विवरण - Luminance Detail चमक विवरण +cameraRaw Luminance लुमिनेंस + Luminance लुमिनेंस +cameraRaw Luminance Detail लुमिनेंस डिटेल + Luminance Detail लुमिनेंस डिटेल cameraRaw Magenta मैजेंटा Magenta मैजेंटा cameraRaw Masking मास्किंग Masking मास्किंग -cameraRaw Midpoint मध्य बिंदु - Midpoint मध्य बिंदु -cameraRaw Midtones मध्यम टोन - Midtones मध्यम टोन -cameraRaw Noise Reduction शोर में कमी - Noise Reduction शोर में कमी +cameraRaw Midpoint मिडपॉइंट + Midpoint मिडपॉइंट +cameraRaw Midtones मिडटोन्स + Midtones मिडटोन्स +cameraRaw Noise Reduction नॉइज़ रिडक्शन + Noise Reduction नॉइज़ रिडक्शन cameraRaw OK ठीक है cameraRaw Open खोलें Open खोलें @@ -443,12 +443,12 @@ cameraRaw Radius त्रिज्या Radius त्रिज्या cameraRaw Red लाल Red लाल -cameraRaw Roughness खुरदरापन - Roughness खुरदरापन -cameraRaw Roundness गोलाई - Roundness गोलाई -cameraRaw Saturation संतृप्ति - Saturation संतृप्ति +cameraRaw Roughness रफनेस + Roughness रफनेस +cameraRaw Roundness राउंडनेस + Roundness राउंडनेस +cameraRaw Saturation सैचुरेशन + Saturation सैचुरेशन cameraRaw Save Settings… सेटिंग्स सहेजें… Save Settings… सेटिंग्स सहेजें… cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें @@ -465,22 +465,22 @@ cameraRaw Show Red at 3 o'clock 3 बजे की स्थिति पर ल Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ cameraRaw Show Selected Region चयनित क्षेत्र दिखाएँ Show Selected Region चयनित क्षेत्र दिखाएँ -cameraRaw Show Skin Tone Indicator त्वचा टोन संकेतक दिखाएँ - Show Skin Tone Indicator त्वचा टोन संकेतक दिखाएँ +cameraRaw Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ + Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ cameraRaw Show Vectorscope वेक्टरस्कोप दिखाएँ Show Vectorscope वेक्टरस्कोप दिखाएँ cameraRaw Size आकार Size आकार cameraRaw Temperature तापमान Temperature तापमान -cameraRaw Texture बनावट - Texture बनावट -cameraRaw Tint रंग झलक - Tint रंग झलक +cameraRaw Texture टेक्सचर + Texture टेक्सचर +cameraRaw Tint टिंट + Tint टिंट cameraRaw Tone Histogram टोन हिस्टोग्राम Tone Histogram टोन हिस्टोग्राम -cameraRaw Vibrance जीवंतता - Vibrance जीवंतता +cameraRaw Vibrance वाइब्रेंस + Vibrance वाइब्रेंस cameraRaw Vignetting विगनेटिंग Vignetting विगनेटिंग cameraRaw White Balance: As Shot श्वेत संतुलन: शॉट के अनुसार From f112e6875752a002d45ed2906e0f5c9b2302d8ba Mon Sep 17 00:00:00 2001 From: Shubham Bhamare <87901258+theshubh77@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:24:19 +0530 Subject: [PATCH 0425/1452] fix(i18n): refine Hindi pluralization, tools, and terminology --- crates/ui-egui/src/i18n/hi.tsv | 40 +++++++++++++++++----------------- crates/ui-egui/src/i18n/mod.rs | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index cf09a7243a..fe630fa065 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -39,7 +39,7 @@ Save सहेजें Save As… इस रूप में सहेजें… Save a Copy… एक प्रति सहेजें… - Revert वापस लौटें + Revert मूल स्थिति में लाएं Export एक्सपोर्ट करें Export As… इस रूप में एक्सपोर्ट करें… Quick Export as PNG PNG के रूप में त्वरित एक्सपोर्ट @@ -55,10 +55,10 @@ Cut काटें Copy कॉपी करें Copy Merged मर्ज की गई कॉपी - Paste पेस्ट + Paste पेस्ट करें Paste Special विशेष रूप में पेस्ट करें Paste in Place स्थान पर पेस्ट करें - Paste Into में पेस्ट करें + Paste Into अंदर पेस्ट करें Paste Outside बाहर पेस्ट करें Clear साफ़ करें Fill… भरें… @@ -117,7 +117,7 @@ # Layer Menu New नया Layer… लेयर… - Layer from Background पृष्ठभूमि से लेयर + Layer from Background बैकग्राउंड से लेयर Group… समूह… Group from Layers… लेयर्स से समूह… Artboard… आर्टबोर्ड… @@ -146,7 +146,7 @@ All सभी Deselect अचयनित करें Reselect पुनः चयन करें - Inverse उलटा करें + Inverse उल्टा करें All Layers सभी लेयर्स Deselect Layers लेयर्स अचयनित करें Find Layers लेयर्स खोजें @@ -195,13 +195,13 @@ Delete Workspace… कार्यस्थान हटाएँ… Lock Workspace कार्यस्थान लॉक करें Color रंग - Swatches नमूने + Swatches स्वॉचेस Gradients ग्रेडिएंट्स Patterns पैटर्न्स Properties गुण Layers लेयर्स Channels चैनल्स - Paths पथ + Paths पाथ्स History हिस्ट्री Actions एक्शन्स Brushes ब्रश @@ -263,8 +263,8 @@ Pen Tool पेन टूल Freeform Pen Tool फ्रीफॉर्म पेन टूल Curvature Pen Tool कर्वचर पेन टूल - Add Anchor Point Tool एंकर पॉइंट टूल - Delete Anchor Point Tool एंकर पॉइंट टूल + Add Anchor Point Tool ऐड एंकर पॉइंट टूल + Delete Anchor Point Tool डिलीट एंकर पॉइंट टूल Convert Point Tool कन्वर्ट पॉइंट टूल Horizontal Type Tool हॉरिजोंटल टाइप टूल Vertical Type Tool वर्टिकल टाइप टूल @@ -314,10 +314,10 @@ Auto स्वचालित # Plurals -@plural {n} layer|{n} layers {n} लेयर|{n} लेयरें -@plural {n} item|{n} items {n} आइटम|{n} आइटम +@plural {n} layer|{n} layers {n} लेयर|{n} लेयर्स +@plural {n} item|{n} items {n} आइटम|{n} आइटम्स @plural {n} document|{n} documents {n} दस्तावेज़|{n} दस्तावेज़ -@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल +@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल्स # Tool and Scope strings Scope दायरा @@ -343,8 +343,8 @@ cameraRaw Balance संतुलन Balance संतुलन cameraRaw Before (Y) पहले (Y) Before (Y) पहले (Y) -cameraRaw Blacks काले बिंदु - Blacks काले बिंदु +cameraRaw Blacks ब्लैक्स + Blacks ब्लैक्स cameraRaw Blending मिश्रण Blending मिश्रण cameraRaw Blue नीला @@ -453,8 +453,8 @@ cameraRaw Save Settings… सेटिंग्स सहेजें… Save Settings… सेटिंग्स सहेजें… cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें -cameraRaw Shadow Clipping Warning (U) छाया क्लिपिंग चेतावनी (U) - Shadow Clipping Warning (U) छाया क्लिपिंग चेतावनी (U) +cameraRaw Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U) + Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U) cameraRaw Shadows शेडोज़ Shadows शेडोज़ cameraRaw Sharpening शार्पनिंग @@ -483,9 +483,9 @@ cameraRaw Vibrance वाइब्रेंस Vibrance वाइब्रेंस cameraRaw Vignetting विगनेटिंग Vignetting विगनेटिंग -cameraRaw White Balance: As Shot श्वेत संतुलन: शॉट के अनुसार - White Balance: As Shot श्वेत संतुलन: शॉट के अनुसार -cameraRaw Whites सफेद बिंदु - Whites सफेद बिंदु +cameraRaw White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार + White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार +cameraRaw Whites व्हाइट्स + Whites व्हाइट्स cameraRaw Yellow पीला Yellow पीला diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 53e3b39bc1..4424071b43 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -451,7 +451,7 @@ mod tests { assert_eq!(tr(HI(), "Layer"), "लेयर"); assert_eq!(tr(HI(), "File"), "फ़ाइल"); assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर"); - assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयरें"); + assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स"); } #[test] From 3ee53abe7b51012912c7ffd0c89be84cdba94968 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 14:18:48 +0300 Subject: [PATCH 0426/1452] Run rustfmt on widgets.rs so Linux CI format check passes. Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1429a2ed23..941677b325 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -514,11 +514,7 @@ fn field_step(range: &std::ops::RangeInclusive, decimals: Option, step } fn field_fine(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> bool { - if step_from_decimals { - decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 - } else { - *range.end() - *range.start() <= 10.0 - } + if step_from_decimals { decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 } else { *range.end() - *range.start() <= 10.0 } } /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and @@ -1109,15 +1105,18 @@ pub fn slider_row_prec( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field_in( - ui, - value, - range.clone(), - suffix, - 74.0, - 0.0, - FieldOpts { decimals, step_from_decimals: decimals.is_some(), ..Default::default() }, - ).0); + changed_resp = Some( + value_field_in( + ui, + value, + range.clone(), + suffix, + 74.0, + 0.0, + FieldOpts { decimals, step_from_decimals: decimals.is_some(), ..Default::default() }, + ) + .0, + ); }); }); let s = slider_with(ui, value, range, gradient, g); From e4c41e147eea8955edfc0e6559c4512dab7e3758 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 13:38:50 +0200 Subject: [PATCH 0427/1452] Image Size: Resolution with Resample on keeps the print size in physical units (#6979) With Resample on and the unit in inches/cm/mm/pt/picas, a new Resolution only stored the resolution; width/height are kept in pixels, so the print size shrank (720 x 576 px at 72 ppi, 10 x 8 in, set to 300 ppi stayed 720 x 576 px = 2.4 x 1.92 in). Photoshop keeps the print size and resamples: 3000 x 2400 px. Scale the stored pixels by new/old resolution in physical units; pixels and percent keep the pixel count as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/sizing.rs | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 3c48e041f4..fc3d79fc9a 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -290,6 +290,15 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { // Without resampling the pixel count is fixed: physical size follows resolution. f.insert("width".into(), json!(ow)); f.insert("height".into(), json!(oh)); + } else if !matches!(unit.as_str(), "px" | "percent") { + // Resampling in a physical unit keeps the print size: the pixels follow the + // resolution (Photoshop). In pixels or percent the pixel count stays. + let old = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); + if old > 0.0 && old.is_finite() { + for key in ["width", "height"] { + f.insert(key.into(), json!((num(f, key) * r / old).max(1.0))); + } + } } f.insert("resolution".into(), json!(r)); } @@ -606,9 +615,14 @@ mod tests { /// Types `text` into the Width field and leaves it with Tab. fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { + type_into(h, 0, text); + } + + /// Types `text` into the `n`th number field (Width, Height, Resolution) and leaves it with Tab. + fn type_into(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, n: usize, text: &str) { use egui::accesskit::Role; use egui_kittest::kittest::Queryable; - let width = h.query_all_by_role(Role::SpinButton).next().map(|n| n.rect()).expect("the Width field"); + let width = h.query_all_by_role(Role::SpinButton).nth(n).map(|n| n.rect()).expect("the number field"); h.hover_at(width.center()); h.run_steps(1); h.drag_at(width.center()); @@ -657,6 +671,32 @@ mod tests { assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); } + /// #6979: with Resample on, a new Resolution keeps the print size in a physical unit and + /// resamples the pixels (Photoshop): 720 x 576 px at 72 ppi is 10 x 8 in, so 300 ppi is + /// 3000 x 2400 px. In pixels the pixel count stays. + #[test] + fn resample_on_resolution_keeps_the_physical_size() { + for (unit, size) in [("in", (3000.0, 2400.0)), ("cm", (3000.0, 2400.0)), ("px", (720.0, 576.0))] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 720, "height": 576, "resolution": 72})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("bicubic")); + d.fields.insert("__unit".into(), json!(unit)); + h.run_steps(3); + type_into(&mut h, 2, "300"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height"), num(&f, "resolution")), (size.0, size.1, 300.0), "{unit}: {f:?}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(size.0 as u32, size.1 as u32), 300.0), "{unit}"); + } + } + /// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt) /// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with /// no edit resized (Image Size) or cropped (Canvas Size) it. From dfb8fe071a2524aab5826f39cfb8f84af59c2a36 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 16 Oct 2026 14:36:18 +0700 Subject: [PATCH 0428/1452] Relink to File: ask for the file (menu bar and layer context menu) Layer > Smart Objects > Relink to File... ran the engine command without a path, so it always failed with "pass `path`". Front it with the open-file picker like Replace Contents..., relinking every instance to the picked file's path (the web picker's name-only answer is an error). A single smart object's layer context menu now also offers Replace Contents... and Relink to File... after Edit Contents, as in Photoshop. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog/tests.rs | 39 +++++++++++++++++++++++++ crates/ui-egui/src/layer_menu_ui.rs | 16 ++++++++-- crates/ui-egui/src/view_cmds.rs | 13 +++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..64df522b01 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -765,6 +765,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/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/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..813e3dc019 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -584,6 +584,7 @@ fn wraps(id: &str) -> bool { | "file.placeLinked" | "layer.smartObjects.replaceContents" | "layer.smartObjects.exportContents" + | "layer.smartObjects.relinkToFile" | "layer.smartObjects.convertToLinked" | "file.closeAll" | "file.closeOthers" @@ -1237,6 +1238,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?); From 8b61a8b18666a2018057b0a5ad6a52aeaf3ad12d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 14:08:03 +0200 Subject: [PATCH 0429/1452] Control: ui.set rejects zoom, rotation, center and dockWidth beyond f32 range (#6988) A finite f64 like 1e39 became inf on `as f32`: zoom jumped to 100%, rotation reset to 0, dockWidth fell to its minimum and center was stored as [inf, 0], all with ok:true. A new f32_field (same filter as rotate_view's finite_number) rejects these in the validate phase, so a rejected call changes nothing. num_field stays as is: pointer events must keep accepting huge coordinates. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..92e1b632a4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -210,6 +210,20 @@ fn num_field(p: &Value, key: &str) -> std::result::Result, String> { } } +/// A finite number that stays finite as `f32` (1e39 would turn into inf and silently reset +/// rotation to 0° or zoom to 100%). +fn f32_range(n: &f64) -> bool { + n.is_finite() && n.abs() <= f64::from(f32::MAX) +} + +/// A `ui.set` numeric field the view stores as `f32`: `Ok(None)` when absent, else within `f32` range. +fn f32_field(p: &Value, key: &str) -> std::result::Result, String> { + match p.get(key) { + None => Ok(None), + Some(v) => v.as_f64().filter(f32_range).map(Some).ok_or_else(|| format!("{key} must be a finite number within f32 range")), + } +} + /// A `ui.set` object field merged into the current state (e.g. `panels`): the patch's keys are /// merged into the serialized current value and parsed back, so a non-object value or an /// unknown nested key is an error — a nested typo must not reply ok while changing nothing. @@ -571,9 +585,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let dock = whole_object(&app.ui.dock, p.get("dock"), "dock")?; // Which chip the Color panel edits. let color_panel = whole_object(&app.ui.color_panel, p.get("colorPanel"), "colorPanel")?; - let dock_width = num_field(p, "dockWidth")?; - let zoom = num_field(p, "zoom")?; - let rotation = num_field(p, "rotation")?; + let dock_width = f32_field(p, "dockWidth")?; + let zoom = f32_field(p, "zoom")?; + let rotation = f32_field(p, "rotation")?; if rotation.is_some() && app.session.active_index().is_none() { return Err("no document open".into()); } @@ -586,6 +600,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else { return Err("center must be [x, y] of two numbers".into()); }; + if !f32_range(&x) || !f32_range(&y) { + return Err("center must be finite numbers within f32 range".into()); + } Some([x as f32, y as f32]) } None => None, @@ -2252,6 +2269,32 @@ mod tests { assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushSize": "large"}))["ok"], true); } + #[test] + fn ui_set_rejects_view_numbers_beyond_f32_range() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + let r = call(&mut app, &ctx, "ui.set", json!({"rotation": 30, "zoom": 2, "center": [3, 4], "dockWidth": 300})); + assert_eq!(r["ok"], true, "{r}"); + let dock = |ctx: &egui::Context| ctx.data(|d| d.get_temp::(crate::panels::dock_width_id())); + let (rotation, zoom, center) = (app.ui.views[0].rotation, app.ui.views[0].zoom, app.ui.views[0].center); + // Finite in f64 but infinite as f32: an error, not a silent reset to 0°, 100% or the minimum dock width. + for params in + [json!({"rotation": 1e39}), json!({"zoom": 1e39}), json!({"center": [1e39, 0]}), json!({"center": [0, -1e39]}), json!({"dockWidth": 1e39})] + { + let r = call(&mut app, &ctx, "ui.set", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert_eq!(app.ui.views[0].rotation, rotation, "{params}"); + assert_eq!(app.ui.views[0].zoom, zoom, "{params}"); + assert_eq!(app.ui.views[0].center, center, "{params}"); + assert_eq!(dock(&ctx), Some(300.0), "{params}"); + } + // Huge but representable still clamps to the top zoom level. + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"zoom": 1e38}))["ok"], true); + assert!(app.ui.views[0].zoom.is_finite() && app.ui.views[0].zoom > zoom, "{}", app.ui.views[0].zoom); + } + #[test] fn ui_set_theme_keeps_explicit_dock_tab_indices() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From 82707fd15d5b13781029798b54b1d63f7cc04054 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:04:16 +0200 Subject: [PATCH 0430/1452] Stamp Down keeps the lower layer's clipping, label and link (#6999) Stamp Down rebuilt the layer below from scratch and copied only its id, name, blend mode, visibility and locks, so a clipped layer came back unclipped (its pixels showing outside the clip base) and lost its colour label and link. Photoshop writes the stamp into the existing layer; keep the three properties as Merge Down already does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/stamp_cmds.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/stamp_cmds.rs b/crates/engine/src/stamp_cmds.rs index a525d138b5..29e649a549 100644 --- a/crates/engine/src/stamp_cmds.rs +++ b/crates/engine/src/stamp_cmds.rs @@ -101,7 +101,7 @@ fn stamp_down(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("the layer is hidden".into())); } // The two layers on their own, composited in bands; the lower one keeps its id, name and - // blend mode, as Merge Down does. + // blend mode, clipping, label and link, as Merge Down does. let mut tmp = doc.clone(); let mut base = lower.clone(); base.visible = true; @@ -114,6 +114,9 @@ fn stamp_down(s: &mut Session, p: &Value) -> Result { merged.blend = lower.blend; merged.visible = lower.visible; merged.locks = lower.locks; + merged.clipped = lower.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged.opacity = 1.0; *doc.layer_at_mut(&below).ok_or(EngineError::NoLayer(lower.id))? = merged; Ok(()) @@ -285,6 +288,23 @@ mod tests { assert!(close(rgba(&s, hidden, 30, 30), [0.0, 1.0, 0.0, 1.0])); } + #[test] + fn stamp_down_keeps_the_lower_layers_clipping_label_and_link() { + let (mut s, _, hidden, blue) = session(8); + let l = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(hidden).unwrap(); + l.visible = true; + l.clipped = true; + l.label = photocraft_doc::LabelColor::Red; + l.link_group = Some(7); + s.execute("layer.stampDown", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(hidden).unwrap(); + assert!(close(rgba(&s, hidden, 30, 30), [0.0, 0.0, 1.0, 1.0]), "stamped"); + assert!(l.clipped, "still clipped to the layer below"); + assert_eq!(l.label, photocraft_doc::LabelColor::Red); + assert_eq!(l.link_group, Some(7)); + assert_eq!(s.active().unwrap().active_layer, Some(blue)); + } + #[test] fn stamp_down_refuses_what_it_cannot_do() { let (mut s, bg, hidden, blue) = session(8); From 1c312c4ba65819b585ab6ba2c914fb530f27307c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:10:33 +0200 Subject: [PATCH 0431/1452] Actions: deleting an action removes its function key (#7014) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..9555394159 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -474,6 +474,13 @@ fn delete(s: &mut Session, p: &Value) -> Result { } let name = s.actions.list.remove(idx).name; s.actions.touch(); + // Its function key goes with it, or the next action given the same name would inherit it. + let key = format!("{PLAY_SHORTCUT_PREFIX}{name}"); + if s.prefs().shortcuts.contains_key(&key) { + s.edit_prefs(|prefs| { + prefs.shortcuts.remove(&key); + }); + } Ok(json!({"deleted": name})) } @@ -769,6 +776,22 @@ mod tests { assert_eq!(s.actions.list[0].name, "Sepia"); } + #[test] + fn delete_action_drops_its_function_key_but_deleting_a_step_keeps_it() { + let mut s = Session::new(); + let key = format!("{PLAY_SHORTCUT_PREFIX}Action 1"); + s.actions.list.push(Action::new("Action 1", vec![("layer.new.layer".into(), json!({})), ("layer.new.layer".into(), json!({}))])); + s.edit_prefs(|p| p.shortcuts.insert(key.clone(), "F2".into())); + s.execute("actions.delete", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.prefs().shortcuts.get(&key).map(String::as_str), Some("F2"), "deleting a step keeps the key"); + assert_eq!(s.execute("actions.delete", json!({"action": 0})).unwrap()["deleted"], "Action 1"); + assert!(!s.prefs().shortcuts.contains_key(&key), "the deleted action's function key is gone"); + s.execute("actions.record", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].name, "Action 1"); + assert!(!s.prefs().shortcuts.contains_key(&key), "a new action with the same name starts unbound"); + } + fn record_red(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); s.execute("actions.record", json!({"name": "Red"})).unwrap(); From 99c6fb4ce7a6165e0e8bfcdd2ff8e7b4cf155e7a Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:10:46 +0200 Subject: [PATCH 0432/1452] Timeline: a longer duration moves a work area that reached the old end (#7006) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/timeline_cmds.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 9baa077608..80e961ae7a 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -104,6 +104,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result { t.fps = fps; } if let Some(d) = duration { + // A work area that reached the old end follows the new one, as in + // `video_cmds::grow_duration` (#4170); a shrink is left to `Timeline::clamp`. + if t.work_end >= t.duration { + t.work_end = d; + } t.duration = d; } if let Some(ws) = p.get("workStart").and_then(Value::as_u64) { @@ -245,6 +250,23 @@ mod tests { assert_eq!(current(&mut s), 1); } + /// A longer `duration` moves a work area that reached the old end to the new end, like + /// `video_cmds::grow_duration` (#4170); a narrowed one and an explicit `workEnd` stay put. + #[test] + fn set_props_longer_duration_moves_full_work_area() { + let work = |r: &Value| (r["timeline"]["workStart"].as_u64(), r["timeline"]["workEnd"].as_u64()); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("timeline.create", json!({"duration": 30})).unwrap(); + let r = s.execute("timeline.setProps", json!({"duration": 60})).unwrap(); + assert_eq!(work(&r), (Some(0), Some(60)), "the full work area follows the new end"); + let r = s.execute("timeline.setProps", json!({"duration": 90, "workEnd": 45})).unwrap(); + assert_eq!(work(&r), (Some(0), Some(45)), "an explicit workEnd wins"); + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + let r = s.execute("timeline.setProps", json!({"duration": 120})).unwrap(); + assert_eq!(work(&r), (Some(2), Some(10)), "a narrowed work area stays put"); + } + /// #1009: a timeline stored with `duration` 0 or `u64::MAX` (an old or hand-edited file) steps /// without panicking and lands on a valid frame. #[test] From 361a1bf22e107931754b46b802967c000e2c14b9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:12:14 +0200 Subject: [PATCH 0433/1452] Channels: Multichannel conversion keeps the targeted channel (#7002) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/multichannel_cmds.rs | 35 ++++++++++++++++++++ crates/engine/src/multichannel_cmds/tests.rs | 27 +++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/engine/src/multichannel_cmds.rs b/crates/engine/src/multichannel_cmds.rs index a6e74101b1..b0d46c2e4f 100644 --- a/crates/engine/src/multichannel_cmds.rs +++ b/crates/engine/src/multichannel_cmds.rs @@ -19,6 +19,7 @@ use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; use photocraft_raster::Surface; use serde_json::{Value, json}; +use crate::channel_cmds::ChannelTarget; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; @@ -119,6 +120,9 @@ fn inks_of(doc: &Document) -> Vec<(String, Color, Vec)> { } fn to_multichannel(s: &mut Session, _p: &Value) -> Result { + let Some((old, view)) = s.active().map(|st| (st.doc.channels.len(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) else { + return Err(EngineError::NoDocument); + }; let names = s.edit("Multichannel", |doc, active| { let inks = inks_of(doc); let mut channels: Vec = inks @@ -143,15 +147,45 @@ fn to_multichannel(s: &mut Session, _p: &Value) -> Result { doc.icc_profile = None; Ok(inks.into_iter().map(|(n, _, _)| n).collect::>()) })?; + // The alpha channels moved behind the inks. + let map: Vec> = (0..old).map(|i| Some(i + names.len())).collect(); + remap_view(s, view, &map); Ok(json!({ "mode": "multichannel", "channels": names })) } +/// Keep the Channels target and eyes on the same channels after a conversion moved them: +/// `view` is the target and alpha eyes before the conversion, `map[i]` the new index of old +/// channel `i`, `None` when it left (a targeted ink becomes the composite). +fn remap_view(s: &mut Session, (target, eyes): (ChannelTarget, Vec), map: &[Option]) { + let Some(st) = s.active_mut() else { return }; + let mut visible = vec![false; st.doc.channels.len()]; + for (&on, to) in eyes.iter().zip(map) { + if let (true, Some(slot)) = (on, to.and_then(|j| visible.get_mut(j))) { + *slot = true; + } + } + let v = &mut st.channel_view; + v.alpha_visible = visible; + if let ChannelTarget::Alpha(t) = target { + v.target = map.get(t).copied().flatten().map_or(ChannelTarget::Composite, ChannelTarget::Alpha); + } + crate::channel_cmds::fix_view(st); +} + /// Image › Mode › RGB / CMYK / Lab / Grayscale from a Multichannel document (called by /// `color_cmds::convert_mode`). Profiles are not involved: Multichannel has none. pub(crate) fn convert_from(s: &mut Session, mode: ColorMode, _p: &Value) -> Result { if !matches!(mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab | ColorMode::Grayscale) { return Err(EngineError::Other(format!("a Multichannel document can't be converted to {mode:?}"))); } + // The inks leave; the other channels close up. + let Some((spots, view)) = s + .active() + .map(|st| (st.doc.channels.iter().map(|c| c.spot.is_some()).collect::>(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) + else { + return Err(EngineError::NoDocument); + }; + let map: Vec> = spots.iter().enumerate().map(|(i, &spot)| (!spot).then(|| spots.iter().take(i).filter(|&&sp| !sp).count())).collect(); let how = s.edit("Mode Change", |doc, active| { let (inks, others): (Vec, Vec) = std::mem::take(&mut doc.channels).into_iter().partition(|c| c.spot.is_some()); let canvas = doc.bounds(); @@ -195,6 +229,7 @@ pub(crate) fn convert_from(s: &mut Session, mode: ColorMode, _p: &Value) -> Resu doc.channels = others; Ok(if reinterpret { "channels" } else { "appearance" }) })?; + remap_view(s, view, &map); Ok(json!({ "mode": format!("{mode:?}").to_lowercase(), "from": "multichannel", "converted": how })) } diff --git a/crates/engine/src/multichannel_cmds/tests.rs b/crates/engine/src/multichannel_cmds/tests.rs index e8df57498b..f96ff48e95 100644 --- a/crates/engine/src/multichannel_cmds/tests.rs +++ b/crates/engine/src/multichannel_cmds/tests.rs @@ -119,6 +119,33 @@ fn flattens_layers_and_keeps_alpha_channels() { assert!(doc(&s).layers[0].surface().unwrap().pixel(1, 1)[0] < 0.01); } +#[test] +fn targeted_alpha_channel_stays_targeted_across_conversions() { + use crate::channel_cmds::ChannelTarget; + let mut s = session("rgb", 8); + s.execute("channel.new", json!({})).unwrap(); + let view = |s: &Session| s.active().unwrap().channel_view.clone(); + assert_eq!(view(&s).target, ChannelTarget::Alpha(0)); + assert_eq!(view(&s).alpha_visible, [true]); + s.execute("image.mode.multichannel", json!({})).unwrap(); + // The alpha channel now follows the three inks. + assert_eq!(view(&s).target, ChannelTarget::Alpha(3)); + assert_eq!(view(&s).alpha_visible, [false, false, false, true]); + let cyan = doc(&s).channels[0].surface.pixel(4, 3)[0]; + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + assert_eq!(doc(&s).channels[0].surface.pixel(4, 3)[0], cyan, "the fill must not touch the Cyan plate"); + assert_eq!(doc(&s).channels[3].name, "Alpha 1"); + // Back to RGB: the inks leave, the alpha channel is index 0 again. + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(view(&s).target, ChannelTarget::Alpha(0)); + assert_eq!(view(&s).alpha_visible, [true]); + // A targeted ink becomes the composite. + s.execute("image.mode.multichannel", json!({})).unwrap(); + s.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(view(&s).target, ChannelTarget::Composite); +} + #[test] fn mismatched_channel_count_converts_the_printed_look() { let mut s = session("rgb", 8); From fd2c10bba851ae94fa72a50ebb235e1ec2052a59 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:12:17 +0200 Subject: [PATCH 0434/1452] Artboards to Files: paths, notes, slices and marks move with the board (#7010) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/artboard_cmds.rs | 5 ++++- crates/engine/src/artboard_cmds/tests.rs | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index c3e3e079f7..b612c7c604 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -9,7 +9,7 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Artboard, ArtboardBackground, Document, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -504,6 +504,9 @@ pub fn artboard_document(doc: &Document, id: LayerId) -> Result one.layer_comps.clear(); one.last_applied_comp = None; one.last_document_state = None; + // Paths, notes, slices and Count/Ruler marks move with the board. This also shifts + // `one.guides`, but they are rebuilt below from the source's, so nothing moves twice. + crate::canvas_geom::transform_doc_marks(&mut one, &Affine::translate(f64::from(dx), f64::from(dy))); let shift = |v: &[f64], o: i32, len: u32| v.iter().map(|g| g - f64::from(o)).filter(|g| *g > 0.0 && *g < f64::from(len)).collect::>(); one.guides.vertical = shift(&doc.guides.vertical, r.x0, r.width()); one.guides.horizontal = shift(&doc.guides.horizontal, r.y0, r.height()); diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index c7695f228e..f3053e21fd 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -331,6 +331,33 @@ fn export_rejects_a_board_too_far_from_the_origin() { assert_eq!(one.artboards()[0].2.rect, Rect::new(0, 0, 50, 50)); } +/// Artboards to Files moves paths, notes, slices and Count/Ruler marks with the board, like its +/// layers and guides, so they land on the same pixels in the exported file. +#[test] +fn artboard_export_moves_paths_notes_slices_and_marks_with_the_board() { + use photocraft_doc::{CountGroup, NamedPath, Note, Path, Slice, Subpath}; + let mut s = session(8); + s.execute("layer.new.artboard", json!({"x": 200, "y": 100, "width": 50, "height": 50})).unwrap(); + s.edit("marks", |d, _| { + d.paths.push(NamedPath { name: "P".into(), path: Path::new(vec![Subpath::polygon(&[(210.0, 110.0), (240.0, 110.0), (240.0, 140.0)])]), psd_raw: None }); + d.work_path = Some(Path::new(vec![Subpath::polygon(&[(205.0, 105.0), (215.0, 105.0), (215.0, 115.0)])])); + d.notes.push(Note { position: [220.0, 120.0], popup: [220.0, 120.0, 260.0, 160.0], ..Default::default() }); + d.slices.list.push(Slice { id: 1, rect: Rect::new(210, 110, 230, 130), ..Default::default() }); + d.measurement.count_groups.push(CountGroup { points: vec![[225.0, 125.0]], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let one = artboard_document(doc(&s), doc(&s).artboards()[0].0).unwrap().unwrap(); + let k = &one.paths[0].path.subpaths[0].knots[0].anchor; + assert_eq!((k.x, k.y), (10.0, 10.0)); + let w = &one.work_path.as_ref().unwrap().subpaths[0].knots[0].anchor; + assert_eq!((w.x, w.y), (5.0, 5.0)); + assert_eq!(one.notes[0].position, [20.0, 20.0]); + assert_eq!(one.notes[0].popup, [20.0, 20.0, 60.0, 60.0]); + assert_eq!(one.slices.list[0].rect, Rect::new(10, 10, 30, 30)); + assert_eq!(one.measurement.count_groups[0].points, vec![[25.0, 25.0]]); +} + /// #1531: a board 40×40 at (10, 10) holding a red square at (10..30)², the artboard active. fn board_with_square(s: &mut Session) -> LayerId { add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); From 30505e3b53f291719ba61e53a01cd69a23875433 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:12:30 +0200 Subject: [PATCH 0435/1452] Crop: Color Samplers scale with a W x H crop (#7012) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 17 +++++++++++++++-- crates/engine/src/sampler_cmds.rs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..48c43b1024 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -480,7 +480,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(EngineError::Other("nothing to crop: pass x/y/width/height or make a selection".into())); } - s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); + s.set_sampler_map(target.sampler_map(r)); let extension = s.tools.background; s.edit("Crop", |doc, active| { let original = doc.size; @@ -513,6 +513,19 @@ impl CropTarget { doc.resolution_dpi = r; } } + + /// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels + /// when [`Self::apply`] resamples the cropped area `r` (as Image Size scales them). + fn sampler_map(&self, r: Rect) -> photocraft_geom::Affine { + let shift = photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)); + match self.size { + Some((w, h)) if r.width() > 0 && r.height() > 0 => { + let (sx, sy) = (f64::from(w) / f64::from(r.width()), f64::from(h) / f64::from(r.height())); + shift.then(&photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }) + } + _ => shift, + } + } } /// The highest resolution (ppi) a crop may set, as for Image Size. @@ -631,7 +644,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar crate::analysis_cmds::compound(s, "Crop", |s| { crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). - s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); + s.set_sampler_map(crop_target.sampler_map(target)); let extension = s.tools.background; s.edit("Crop", |doc, active| { crop_doc(doc, CMD, target, delete, extension)?; diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 472ecb672a..b7e92550f3 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -388,6 +388,24 @@ mod tests { assert_eq!(p[0]["position"], json!([10.0, 10.0])); } + #[test] + fn a_crop_to_a_target_size_scales_points() { + // The crop resamples the 20 x 20 area to 40 x 40: the point (10, 10 after the shift) doubles. + for angle in [None, Some(90)] { + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); + let mut p = json!({"x": 40, "y": 30, "width": 20, "height": 20, "targetWidth": 40, "targetHeight": 40}); + if let Some(a) = angle { + p["angle"] = json!(a); + } + s.execute("image.crop", p).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1, "angle {angle:?}"); + assert_eq!(p[0]["position"], json!([20.0, 20.0]), "angle {angle:?}"); + assert_eq!(s.take_samplers_removed(), 0, "angle {angle:?}"); + } + } + #[test] fn bad_params_and_no_document_are_errors_not_panics() { let mut s = Session::new(); From 000fec6f5960dc905a05804d51fa68fa03fd28f6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:12:43 +0200 Subject: [PATCH 0436/1452] Keyboard Shortcuts: switching sets keeps action function keys (#7004) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/shortcut_sets.rs | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs index 26062def96..ba681f271b 100644 --- a/crates/engine/src/shortcut_sets.rs +++ b/crates/engine/src/shortcut_sets.rs @@ -35,14 +35,17 @@ pub struct ShortcutSet { } impl ShortcutSet { - /// The bindings `p` uses now. + /// The bindings `p` uses now, without the actions' function keys (they belong to the + /// actions, as in an `.atn` file, not to a set). pub fn of(p: &Preferences) -> Self { - Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() } + let shortcuts = p.shortcuts.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone())).collect(); + Self { shortcuts, alternate_shortcuts: p.alternate_shortcuts.clone() } } - /// Use this set's bindings in `p`. + /// Use this set's bindings in `p`, keeping the actions' function keys. pub fn load_into(&self, p: &mut Preferences) { - p.shortcuts = self.shortcuts.clone(); + p.shortcuts.retain(|id, _| is_action_key(id)); + p.shortcuts.extend(self.shortcuts.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone()))); p.alternate_shortcuts = self.alternate_shortcuts.clone(); } @@ -60,6 +63,11 @@ impl ShortcutSet { } } +/// An action's function key (`actions.play:`): kept by the action, not by a set. +fn is_action_key(id: &str) -> bool { + id.starts_with(crate::actions_cmds::PLAY_SHORTCUT_PREFIX) +} + /// A set name as given, trimmed; an error for an empty, overlong or reserved name. pub fn set_name(name: &str) -> Result { let n = name.trim(); @@ -292,6 +300,21 @@ mod tests { assert!(modified(&p), "F9 differs from the defaults"); } + #[test] + fn switching_sets_keeps_action_function_keys() { + // An action's F-key belongs to the action (as in an .atn file), not to a shortcut set. + let mut p = prefs(); + store(&mut p, &json!({"saveSet": "Mine"})).unwrap(); + p.shortcuts.insert("actions.play:Sepia".into(), "F2".into()); + assert!(!modified(&p), "an action key doesn't modify the set"); + load(&mut p, &json!({"useSet": ""})).unwrap(); + assert_eq!(p.shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"), "kept switching to the defaults"); + load(&mut p, &json!({"useSet": "Mine"})).unwrap(); + assert_eq!(p.shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"), "kept switching to a named set"); + store(&mut p, &json!({"saveSet": "Other"})).unwrap(); + assert!(p.shortcut_sets.values().all(|s| s.shortcuts.is_empty()), "a saved set holds no action keys"); + } + #[test] fn bad_names_sets_and_params_are_errors() { let mut p = prefs(); From 21970407993f1abdd20c8bdbd1492eaab51cafb5 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:36 +0300 Subject: [PATCH 0437/1452] Filter preview: main's view-capped factor follows the whole smart filter stack The merge with main kept both factor lines in ensure_filter_preview, and the second (main's filter_preview_factor, #6351) shadowed smart_preview_factor, so a smart filter edit was sized by the edited command alone again. The shared filter_preview_factor now starts from smart_preview_factor, which falls back to preview_factor for ordinary filters, and caps that by the view as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f29d6c6509..55e7979a37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1737,7 +1737,8 @@ fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { /// [`crate::filter_dialog::FilterPreviewKey`] under a finished preview and recompute it inline, /// the very stall this avoids. pub(crate) fn filter_preview_factor(app: &PhotocraftApp, doc: &Document, cmd: &str, params: &serde_json::Value) -> u32 { - let k = crate::filter_dialog::preview_factor(cmd, params, crate::proxy::preview_factor(doc, crate::proxy::reduced_previews(app))); + // A smart filter edit re-renders the whole stack: every stage constrains the factor. + let k = crate::filter_dialog::smart_preview_factor(doc, cmd, params, crate::proxy::preview_factor(doc, crate::proxy::reduced_previews(app))); if cfg!(target_arch = "wasm32") { k } else { crate::proxy::view_factor(k, app.current_zoom()) } } @@ -1776,8 +1777,6 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e params = crate::select_mask_view::request_params(&d.fields, params); } let st = app.session.documents().get(idx)?; - // Filters whose features don't scale with a proxy preview at full resolution (#2063). - let k = crate::filter_dialog::smart_preview_factor(&st.doc, &cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, From e727c3dcf8d6d3f32a51112878e589b3ffc8b4b3 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:37 +0300 Subject: [PATCH 0438/1452] Layers panel reorder preview: share move_to's structural move, clipping groups included The hover preview (preview_move) re-implemented the layer removal and insertion but skipped the rest of move_to: the clip-group rules (clip_group::Before/settle), the Background copy rules (unlocked copy) and the Background-stays-at-the-bottom check. So the preview showed different clipping than the drop commits, e.g. B C1* C2* U with U dropped above C1 committed B C1* U* C2* but previewed U unclipped, and moving a clipping base away left its layers clipped in the preview. Factor the document-only part of move_to into apply_move and call it from both, so they cannot drift. A drop move_to would refuse (Background off the bottom, groups nested too deep) now has no preview either. Tests compare preview and committed documents (stack order, clip flags and the flattened composite) for clip-group drops, a base moved away or up into its own group, option-drag copies, a Background copy, and refused Background drops. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 190 +++++++++++++++++++------- 1 file changed, 144 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index d4ea64bd8d..1ab67610d3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1092,31 +1092,49 @@ pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option Result> { + const CMD: &str = "layer.moveTo"; + let background = crate::background_cmds::background_id(doc); + let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, moved_ids) }; + let mut moved = Vec::with_capacity(moved_ids.len()); + for &id in moved_ids { + moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); + } + let copies: Vec = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() }; + // Look up the destination AFTER removing every source: its indices may have shifted. + let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; + if pos == "into" { + let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?; + children.extend(moved); + } else { + let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?; + let siblings = if parent.is_empty() { + &mut doc.layers + } else { + doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))? + }; + let at = if pos == "below" { idx } else { idx + 1 }; + siblings.splice(at..at, moved); + } + check_group_depth(doc, "Reorder Layer")?; + crate::background_cmds::keep_background_at_bottom(background, doc)?; + crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false); + Ok(copies) +} + /// Selected descendants of a moved group remain inside their parent; only top-level /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { @@ -1175,32 +1193,8 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { let original_active = st.active_layer; let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); - let background = crate::background_cmds::background_id(doc); let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| { - let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, &moved_ids) }; - let mut moved = Vec::with_capacity(moved_ids.len()); - for &id in &moved_ids { - moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); - } - let copies: Vec = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() }; - // Look up the destination AFTER removing every source: its indices may have shifted. - let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; - if pos == "into" { - let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?; - children.extend(moved); - } else { - let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?; - let siblings = if parent.is_empty() { - &mut doc.layers - } else { - doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))? - }; - let at = if pos == "below" { idx } else { idx + 1 }; - siblings.splice(at..at, moved); - } - check_group_depth(doc, "Reorder Layer")?; - crate::background_cmds::keep_background_at_bottom(background, doc)?; - crate::clip_group::settle(doc, if copy { &copies } else { &moved_ids }, &before, false); + let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?; *active = if copy { copies.last().copied() } else if batch.is_some() { @@ -2862,6 +2856,110 @@ mod layers_panel_parity_tests { assert_eq!(candidate, *s.active().unwrap().doc); } + /// A 16×16 document holding `spec` (bottom to top, `*` = clipped) above its Background. Every + /// layer is a translucent, differently placed rectangle, so the stack order and the clip + /// groups both show in the composite. + fn clip_stack(spec: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + for (n, name) in spec.split_whitespace().enumerate() { + let id = LayerId(s.execute("layer.new.layer", json!({"name": name.trim_end_matches('*')})).unwrap()["layer"].as_u64().unwrap()); + let n = n as i32; + let colour = [(n % 3) as f32 * 0.5, ((n + 1) % 3) as f32 * 0.5, ((n + 2) % 3) as f32 * 0.5, 0.6]; + s.edit("paint", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.clipped = name.ends_with('*'); + layer.surface_mut().unwrap().fill_rect(Rect::new(n * 2, 0, n * 2 + 10, 16), &colour); + Ok(()) + }) + .unwrap(); + } + s + } + + /// Every layer of `doc` from the bottom, `*` marking the clipped ones. + fn stack_text(doc: &Document) -> String { + doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::>().join(" ") + } + + fn id_of(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0 + } + + /// The preview of `params` and the committed `layer.moveTo` show the same stack (names, clip + /// flags) and the same composite, and both are `want`. + fn assert_preview_is_commit(mut s: Session, params: Value, want: &str) { + let before = s.active().unwrap().doc.clone(); + let preview = preview_move(&before, ¶ms, true).expect("the drop has a preview"); + s.execute("layer.moveTo", params.clone()).unwrap(); + let committed = &s.active().unwrap().doc; + assert_eq!(stack_text(&preview), want, "preview of {params}"); + assert_eq!(stack_text(committed), want, "commit of {params}"); + assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}"); + } + + #[test] + fn preview_follows_clip_groups_like_the_committed_reorder() { + // The fixtures of `clip_group::reorder_joins_and_leaves_groups`. + for (spec, layers, target, pos, want) in [ + // A layer dropped into a clip group joins it. + ("B C1* C2* U", vec!["U"], "C1", "above", "Background B C1* U* C2*"), + ("B C1* C2* U V", vec!["U", "V"], "C1", "above", "Background B C1* U* V* C2*"), + // A clipped layer dropped off its group is released. + ("B C1* C2* U", vec!["C1"], "U", "above", "Background B C2* U C1"), + // A base moved away releases the layers clipped to it. + ("B C1* C2* U", vec!["B"], "U", "above", "Background C1 C2 U B"), + // A base moved up into its own group joins it, and the layers it left are released. + ("B C1* C2*", vec!["B"], "C1", "above", "Background C1 B* C2"), + ("X B C1* C2* U", vec!["B", "C1"], "U", "above", "Background X C2 U B C1*"), + ] { + let s = clip_stack(spec); + let ids: Vec = layers.iter().map(|n| id_of(&s, n)).collect(); + let t = id_of(&s, target); + assert_preview_is_commit(s, json!({"layers": ids, "target": t, "position": pos}), want); + } + // An option-drag copy joins the clip group it lands in too. + let s = clip_stack("B C1* C2* U"); + let (u, c1) = (id_of(&s, "U"), id_of(&s, "C1")); + assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U"); + } + + #[test] + fn preview_copies_the_background_like_the_committed_copy() { + let mut s = clip_stack("A B*"); + let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A")); + let before = s.active().unwrap().doc.clone(); + assert_ne!(before.layer(bg).unwrap().locks, Default::default(), "the Background is locked"); + let p = json!({"layer": bg.0, "target": a, "position": "above", "copy": true}); + let preview = preview_move(&before, &p, true).unwrap(); + s.execute("layer.moveTo", p).unwrap(); + let committed = &s.active().unwrap().doc; + // The copy is an ordinary, unlocked layer that joins the clip group it lands in. + assert_eq!(stack_text(committed), "Background A Background copy* B*"); + assert_eq!(stack_text(&preview), stack_text(committed)); + for doc in [&preview, committed] { + assert_eq!(doc.layers.get(2).unwrap().locks, Default::default()); + assert!(doc.layers.first().unwrap().locks != Default::default()); + } + assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px); + } + + #[test] + fn preview_refuses_what_would_leave_the_background_off_the_bottom() { + let mut s = clip_stack("A B"); + let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A")); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layer": a, "target": bg.0, "position": "below"}), + json!({"layer": bg.0, "target": bg.0, "position": "below", "copy": true}), + json!({"layer": a, "target": bg.0, "position": "below", "copy": true}), + ] { + assert!(preview_move(&before, &p, true).is_none(), "{p}"); + assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}"); + assert_eq!(*before, *s.active().unwrap().doc, "{p}"); + } + } + #[test] fn expand_new_effects_applies_to_ui_start_dispatch_too() { let (mut s, ids) = new_session(); From 1c760a93b50ed35c02b1da80b93cbc88cd67b811 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 16:29:17 +0300 Subject: [PATCH 0439/1452] Fix Layer Style dialog reloading baked gradient overlay endpoints. Fixes storytold/photocraft#7031: classic/PSD multi-stop overlays no longer show factory black-to-white when reopened. Co-authored-by: Cursor --- crates/ui-egui/src/layer_style.rs | 65 +++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..068903b751 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -282,28 +282,27 @@ fn values_of(e: &Effect, light: f32) -> Value { Effect::GradientOverlay { common, gradient: g, .. } => { set(&mut v, "blend", json!(common.blend.label())); set(&mut v, "opacity", json!((common.opacity * 100.0).round())); - // The From/To fields model a plain two-stop gradient; anything richer - // (more stops, transparency stops) is carried by the snapshot instead. - if g.stops.len() == 2 && g.opacity_stops.is_empty() { - set(&mut v, "from", json!(hex(&g.stops[0].1))); - set(&mut v, "to", json!(hex(&g.stops[1].1))); - set(&mut v, "reverse", json!(g.reverse)); - set( - &mut v, - "style", - json!(match g.style { - photocraft_doc::effects::GradientStyle::Radial => "radial", - photocraft_doc::effects::GradientStyle::Angle => "angle", - photocraft_doc::effects::GradientStyle::Reflected => "reflected", - photocraft_doc::effects::GradientStyle::Diamond => "diamond", - photocraft_doc::effects::GradientStyle::Linear => "linear", - }), - ); - set(&mut v, "angle", json!(g.angle)); - set(&mut v, "scale", json!((g.scale * 100.0).round())); - } else { - drop_keys(&mut v, &["from", "to", "reverse", "style", "angle", "scale"]); + // Endpoint colours and geometry always load into the dialog. A baked curve + // (classic interpolation, PSD import) keeps its interior stops in `fx`; + // `overlay_gradient` only retints the ends unless `method` is present. + if let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) { + set(&mut v, "from", json!(hex(&first.1))); + set(&mut v, "to", json!(hex(&last.1))); } + set(&mut v, "reverse", json!(g.reverse)); + set( + &mut v, + "style", + json!(match g.style { + photocraft_doc::effects::GradientStyle::Radial => "radial", + photocraft_doc::effects::GradientStyle::Angle => "angle", + photocraft_doc::effects::GradientStyle::Reflected => "reflected", + photocraft_doc::effects::GradientStyle::Diamond => "diamond", + photocraft_doc::effects::GradientStyle::Linear => "linear", + }), + ); + set(&mut v, "angle", json!(g.angle)); + set(&mut v, "scale", json!((g.scale * 100.0).round())); } Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => { set(&mut v, "blend", json!(common.blend.label())); @@ -1614,6 +1613,30 @@ mod tests { assert!(!items[0].enabled()); } + #[test] + fn baked_gradient_overlay_keeps_endpoint_colours_on_reopen() { + let mut s = session(); + s.execute( + "layer.layerStyle.gradientOverlay", + json!({"from": "#ff0000", "to": "#0000ff", "method": "classic"}), + ) + .unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let layer = st.doc.layer(id).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &layer.effects.items[0] else { panic!() }; + assert!(gradient.stops.len() > 2, "classic bakes intermediate stops"); + let f = initial_fields(layer, Some("gradientOverlay"), st.doc.global_light.angle); + let params = &effects_of(&f)[0]["params"]; + assert_eq!(params["from"], json!("#ff0000")); + assert_eq!(params["to"], json!("#0000ff")); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!() }; + assert!(gradient.stops.len() > 2); + assert_eq!(gradient.stops.first().unwrap().1, photocraft_color::Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(gradient.stops.last().unwrap().1, photocraft_color::Color::rgb(0.0, 0.0, 1.0)); + } + #[test] fn gradient_strokes_survive_the_dialog() { let mut s = session(); From c6ea59461f9e7e89f422ae75b47bf1b2d64b96d9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:36:02 +0200 Subject: [PATCH 0440/1452] Automation: doc.save rejects a bad quality instead of ignoring or clamping it (#7023) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/rpc.rs | 37 +++++++++++++++++++++++++++++---- crates/automation/src/server.rs | 7 +++++-- crates/automation/tests/mcp.rs | 4 ++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 15a0e24e30..6706338b42 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -114,10 +114,19 @@ impl Headless { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; - if let Some(q) = p.get("quality").and_then(Value::as_u64) { - opts.encode.jpeg_quality = q.clamp(1, 100) as u8; - opts.encode.webp_quality = q.clamp(1, 100) as u8; - opts.encode.webp_lossless = false; + match p.get("quality") { + None | Some(Value::Null) => {} + Some(v) => { + // Like the CLI's `--quality`: never ignore or clamp a bad value. + let q = v + .as_u64() + .filter(|q| (1..=100).contains(q)) + .and_then(|q| u8::try_from(q).ok()) + .ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?; + opts.encode.jpeg_quality = q; + opts.encode.webp_quality = q; + opts.encode.webp_lossless = false; + } } let path = str_of(&p, "path").map(PathBuf::from); self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts) @@ -576,6 +585,26 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was + /// clamped, where the CLI refuses anything but a whole number from 1 to 100. + #[test] + fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() { + let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("x.webp"); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap(); + for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] { + let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string(); + assert!(err.contains("quality"), "{q}: {err}"); + assert!(!path.exists(), "{q} wrote a file"); + } + h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy"); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() { let mut h = Headless::new(); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f52376a9c5..60b8a70649 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -1001,6 +1001,9 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), }; + if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) { + return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}"))); + } if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless @@ -1033,8 +1036,8 @@ impl PhotocraftMcp { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; opts.encode.dpx_bits = dpx_bits; if let Some(q) = p.quality { - opts.encode.jpeg_quality = q.clamp(1, 100); - opts.encode.webp_quality = q.clamp(1, 100); + opts.encode.jpeg_quality = q; + opts.encode.webp_quality = q; opts.encode.webp_lossless = false; } let path = p.path.map(PathBuf::from); diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 7386f3d58c..75d0ea3cbd 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -318,6 +318,10 @@ async fn errors_are_tool_errors_not_crashes() { assert!(text(&r).contains("bridge")); let r = call(&client, "doc_export", json!({})).await; assert_eq!(r.is_error, Some(true)); + // Quality 0 is refused, not clamped to 1 (serde already refuses 500, -1 and "85"). + let r = call(&client, "doc_save", json!({"path": "q.jpg", "quality": 0})).await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("from 1 to 100"), "{}", text(&r)); // Server still alive. json_of(&call(&client, "session_list", json!({})).await); client.cancel().await.unwrap(); From e1c119200bb47d37910f0d66db7ab93d59f220f4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:34:46 +0200 Subject: [PATCH 0441/1452] Automation: place and layer/data-set export by index keep the active document (#7025) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/headless.rs | 172 ++++++++++++++++-------------- crates/automation/src/rpc.rs | 37 +++++++ docs/control-protocol.md | 5 + 3 files changed, 131 insertions(+), 83 deletions(-) diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index d6b3c17102..cad8d233fa 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -72,6 +72,22 @@ impl Headless { } } + /// Run `body` with document `index` active (the active one when `None`), then make the + /// previously active document active again, on success and on error alike: an `index` + /// targets one call and must not retarget the next automation command (#7025). + fn on_doc(&mut self, index: Option, body: impl FnOnce(&mut Self) -> Result) -> Result { + let i = self.doc_index(index)?; + let previously_active = self.session.active_index(); + if previously_active != Some(i) && !self.session.set_active(i) { + return Err(AutomationError::BadRequest(format!("no document at index {i}"))); + } + let result = body(self); + if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) { + self.session.set_active(active); + } + result + } + /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; @@ -113,27 +129,25 @@ impl Headless { /// automation (#2810). `path` resolves beneath the read root the way `open` resolves one, /// and `params` carries the command's own options (`scale`, `fit`, `center`). pub fn place(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - // Where ambient paths are allowed the command reads the file itself, as everywhere else. - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - return self.command_run("file.placeEmbedded", Value::Object(opts)); - } - let bytes = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace.read(path)?, - _ => { - return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + self.on_doc(index, |this| { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + // Where ambient paths are allowed the command reads the file itself, as everywhere else. + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + return this.command_run("file.placeEmbedded", Value::Object(opts)); } - }; - let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); - self.sync_jobs(); - // The bytes are already in memory and the path resolved beneath the root capability, so - // placing them needs no filesystem check of its own (`place_bytes` is the command body). - Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?) + let bytes = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace.read(path)?, + _ => { + return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + } + }; + let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); + this.sync_jobs(); + // The bytes are already in memory and the path resolved beneath the root capability, so + // placing them needs no filesystem check of its own (`place_bytes` is the command body). + Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?) + }) } /// Export each exported layer of a document as its own file beneath the write root: the @@ -143,25 +157,23 @@ impl Headless { /// already exists, where the ambient command would, and it creates the directory before the /// export runs, so a refused export can leave an empty directory behind — never a file. pub fn export_layers(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.layersToFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.layersToFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Export each data set of a document as its own flattened file beneath the write root: the @@ -169,25 +181,23 @@ impl Headless { /// batch is planned first and colliding names are refused before any file is written; the /// directory itself is created before that, so a refused batch can leave it empty behind. pub fn export_data_sets(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Import a data set CSV from the read root: the capability-scoped @@ -195,24 +205,22 @@ impl Headless { /// in automation (#3216). The file is read beneath the root the way `open` reads one and /// handed to the command's own parser over the text it already has. pub fn import_data_sets(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - return self.command_run("file.import.variableDataSets", Value::Object(opts)); - } - let text = match &self.filesystem { - Filesystem::Workspace(workspace) => { - let bytes = workspace.read(path)?; - String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + return this.command_run("file.import.variableDataSets", Value::Object(opts)); } - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), - }; - self.sync_jobs(); - Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?) + let text = match &this.filesystem { + Filesystem::Workspace(workspace) => { + let bytes = workspace.read(path)?; + String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + } + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), + }; + this.sync_jobs(); + Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?) + }) } /// Save (`.pcraft` or any export format by extension). With no path, @@ -266,14 +274,12 @@ impl Headless { format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) { // Saving by index must not retarget the next automation command. - let previously_active = self.session.active_index(); - self.session.set_active(i); - if let Some(st) = self.session.active_mut() { - st.saved_to(target.to_string_lossy().into_owned()); - } - if let Some(active) = previously_active { - self.session.set_active(active); - } + self.on_doc(Some(i), |this| { + if let Some(st) = this.session.active_mut() { + st.saved_to(target.to_string_lossy().into_owned()); + } + Ok(()) + })?; } Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings })) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 15a0e24e30..e9e4e0d2e4 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -756,6 +756,43 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn indexed_place_and_data_set_methods_keep_the_active_document() { + let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap(); + std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap(); + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "first"})).unwrap(); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap(); + let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id"); + h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}})) + .unwrap(); + h.handle("doc.select", json!({"index": 0})).unwrap(); + + let steps = [ + ("doc.place", json!({"path": "logo.png", "index": 1}), true), + ("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true), + ("doc.exportLayers", json!({"dir": "layers", "index": 1}), true), + ("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true), + // A failing call restores the active document too. + ("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false), + ]; + for (method, params, succeeds) in steps { + let result = h.handle(method, params.clone()); + assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}"); + assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1"); + } + assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document"); + assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1"); + assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn trusted_local_render_keeps_full_size_behavior() { let mut h = Headless::trusted_local(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..81cd8cb3f5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -382,6 +382,11 @@ no MCP framing, no app start-up per command. Configure its file access with the | `batch` | `{steps: [{command, params?, wait?} \| {method, params?}], stopOnError? (true)}` → `{completed, failed, results}` (a step with `wait: false` starts a long command as a background job, like `engine.execute`) | | `methods` | the list above | +An `index` targets that one call: `doc.place`, `doc.exportLayers`, `doc.exportDataSets`, +`doc.importDataSets`, `doc.save`, `doc.inspect` and `doc.render` act on the document at `index` +without making it the active one, so the next call without `index` still runs on the document +that was active before (`doc.select` is the method that changes it). + ```sh printf '%s\n' \ '{"id":1,"method":"doc.open","params":{"path":"in.jpg"}}' \ From 6bda896ad0e6a1e2fd2b08a23577f3ab91adfb39 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 19:08:27 +0530 Subject: [PATCH 0442/1452] test(algo): gate the Linux-only hash test's helpers to Linux x86_64 `blocks` and `fnv` are only used by the harris/describe hash test, which runs on Linux x86_64 only, so on Windows they were dead code and `-D warnings` failed the build. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/features.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index e8e45bcaf2..2da9d7b736 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -701,10 +701,12 @@ mod parallel_tests { } /// Blocks of random brightness: lots of strong corners, no libm in the making. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn blocks(w: usize, h: usize, seed: u64) -> Vec { (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn fnv(parts: &[u64]) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for p in parts { From 276d830e7c21051e0c287e12ca218a4cdc30b028 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:40:09 +0200 Subject: [PATCH 0443/1452] Transform Selection: an unknown reference point is an error (#7027) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/select_extra_cmds.rs | 50 ++++++++++++++++++++------ 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index 5feae1a75f..751f710aca 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -100,12 +100,23 @@ pub fn isolation_shows(doc: &Document, isolated: &[LayerId], id: LayerId) -> boo // ---------------------------------------------------------------- Transform Selection -fn reference_point(p: &Value, r: [f64; 4]) -> [f64; 2] { +/// The point the scale / rotate / skew params pivot about: a named point of the frame `r` or +/// `[x, y]`; missing (or null) is the centre. Anything else is an error, not a silent centre. +fn reference_point(cmd: &str, p: &Value, r: [f64; 4]) -> Result<[f64; 2]> { let (x0, y0, x1, y1) = (r[0], r[1], r[2], r[3]); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - match p.get("reference") { - Some(Value::Array(a)) if a.len() >= 2 => [a[0].as_f64().unwrap_or(cx), a[1].as_f64().unwrap_or(cy)], + let unknown = || bad(cmd, "`reference` must be one of center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight or [x, y]"); + Ok(match p.get("reference") { + None | Some(Value::Null) => [cx, cy], + Some(Value::Array(a)) => match a.as_slice() { + [x, y] => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => [x, y], + _ => return Err(unknown()), + }, + _ => return Err(unknown()), + }, Some(Value::String(k)) => match k.as_str() { + "center" => [cx, cy], "topLeft" => [x0, y0], "top" => [cx, y0], "topRight" => [x1, y0], @@ -114,10 +125,10 @@ fn reference_point(p: &Value, r: [f64; 4]) -> [f64; 2] { "bottomLeft" => [x0, y1], "bottom" => [cx, y1], "bottomRight" => [x1, y1], - _ => [cx, cy], + _ => return Err(unknown()), }, - _ => [cx, cy], - } + Some(_) => return Err(unknown()), + }) } fn quad(p: &Value) -> Option<[[f64; 2]; 4]> { @@ -134,8 +145,8 @@ fn quad(p: &Value) -> Option<[[f64; 2]; 4]> { } /// The homography of the scale / rotate / skew / move params about the reference point. -fn affine_params(p: &Value, r: [f64; 4]) -> Homography { - let [px, py] = reference_point(p, r); +fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result { + let [px, py] = reference_point(cmd, p, r)?; let (sx, sy) = (f(p, "scaleX", 100.0) / 100.0, f(p, "scaleY", 100.0) / 100.0); let a = f(p, "rotate", 0.0).to_radians(); let (kx, ky) = (f(p, "skewX", 0.0).to_radians().tan(), f(p, "skewY", 0.0).to_radians().tan()); @@ -145,7 +156,7 @@ fn affine_params(p: &Value, r: [f64; 4]) -> Homography { let rot = Homography([ca, -sa, 0.0, sa, ca, 0.0, 0.0, 0.0, 1.0]); let skew = Homography([1.0, kx, 0.0, ky, 1.0, 0.0, 0.0, 0.0, 1.0]); let scale = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); - t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py)) + Ok(t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py))) } fn transform_selection(s: &mut Session, p: &Value) -> Result { @@ -185,7 +196,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0]) } else { - affine_params(p, rect) + affine_params(CMD, p, rect)? }; if h.inverse().is_none() { return Err(bad(CMD, "the transform collapses the selection")); @@ -357,6 +368,25 @@ mod tests { assert_eq!(sel_bounds(&s), Some(Rect::new(50, 20, 70, 30))); } + /// A misspelt or malformed `reference` is an error, not a silent scale about the centre. + #[test] + fn transform_selection_rejects_an_unknown_reference() { + let mut s = rect_sel(); + for r in [json!("topleft"), json!("top-left"), json!(["a", 0]), json!([1]), json!([1, 2, 3]), json!(7)] { + assert!(s.execute("select.transformSelection", json!({"scaleX": 50, "reference": r})).is_err(), "{r}"); + assert_eq!(sel_bounds(&s), Some(Rect::new(20, 30, 40, 40)), "{r}"); + } + s.execute("select.transformSelection", json!({"scaleX": 50, "reference": "center"})).unwrap(); + let centre = sel_bounds(&s); + s.undo(); + s.execute("select.transformSelection", json!({"scaleX": 50})).unwrap(); + assert_eq!(sel_bounds(&s), centre); + s.undo(); + s.execute("select.transformSelection", json!({"scaleX": 50, "reference": [20, 30]})).unwrap(); + let b = sel_bounds(&s).unwrap(); + assert!(b.x0.abs_diff(20) <= 1 && b.x1.abs_diff(30) <= 1, "{b:?}"); + } + #[test] fn transform_selection_perspective_and_warp() { let mut s = rect_sel(); From 1bb3542b992716b7feb51a987dcfec272a63b13b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:42:14 +0200 Subject: [PATCH 0444/1452] Lens Correction: smart filters keep the exact focal length (#7029) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lens_cmds.rs | 58 ++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..9546731b7b 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -65,13 +65,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])), other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))), }; - let focal = num(p, "focalLength", 0.0); - lc.profile = match p.get("profile").and_then(Value::as_str).unwrap_or("none") { - "none" | "" => None, - "generic" => Some(lens::generic_profile(if focal > 0.0 { focal } else { 50.0 })), - "auto" => info.and_then(|i| i.focal_length_35mm.or(i.focal_length)).or((focal > 0.0).then_some(focal)).map(lens::generic_profile), - other => return Err(bad(cmd, format!("unknown profile `{other}` (none|auto|generic)"))), - }; + lc.profile = profile_focal(cmd, p, info)?.map(lens::generic_profile); if let Some(Value::Array(line)) = p.get("straighten") { let pt = |v: &Value| -> Option<[f64; 2]> { Some([v.get(0)?.as_f64()?, v.get(1)?.as_f64()?]) }; let (Some(a), Some(b)) = (line.first().and_then(pt), line.get(1).and_then(pt)) else { @@ -85,8 +79,20 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera Ok(lc) } -/// Writes the resolved profile / angle / scale back into the params (for smart filters). -fn resolved_lens_params(p: &Value, lc: &LensCorrection) -> Value { +/// The 35 mm-equivalent focal length of the generic profile the params ask for (`None`: no profile). +fn profile_focal(cmd: &str, p: &Value, info: Option<&exif::CameraInfo>) -> Result> { + let focal = num(p, "focalLength", 0.0); + Ok(match p.get("profile").and_then(Value::as_str).unwrap_or("none") { + "none" | "" => None, + "generic" => Some(if focal > 0.0 { focal } else { 50.0 }), + "auto" => info.and_then(|i| i.focal_length_35mm.or(i.focal_length)).or((focal > 0.0).then_some(focal)), + other => return Err(bad(cmd, format!("unknown profile `{other}` (none|auto|generic)"))), + }) +} + +/// Writes the resolved profile (its exact focal length, not the rounded one in its name) / +/// angle / scale back into the params (for smart filters). +fn resolved_lens_params(p: &Value, lc: &LensCorrection, focal: Option) -> Value { let mut q = p.clone(); if let Value::Object(m) = &mut q { m.remove("straighten"); @@ -94,10 +100,9 @@ fn resolved_lens_params(p: &Value, lc: &LensCorrection) -> Value { m.remove("layer"); m.insert("angle".into(), json!(lc.angle)); m.insert("scale".into(), json!(lc.scale)); - match &lc.profile { - Some(pr) => { + match lc.profile.as_ref().and(focal) { + Some(f) => { m.insert("profile".into(), json!("generic")); - let f = pr.name.trim_start_matches("Generic Lens (").split(' ').next().and_then(|v| v.parse::().ok()).unwrap_or(50.0); m.insert("focalLength".into(), json!(f)); } None => { @@ -320,7 +325,7 @@ fn lens_correction(s: &mut Session, p: &Value) -> Result { let frame = st.doc.bounds(); let info = camera_info(&st.doc); let lc = lens_params(LENS, p, frame, info.as_ref())?; - let stored = resolved_lens_params(p, &lc); + let stored = resolved_lens_params(p, &lc, profile_focal(LENS, p, info.as_ref())?); let t0 = Stopwatch::start(); let id = run_filter(s, LENS, "Lens Correction", stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?; @@ -554,6 +559,33 @@ mod tests { assert!(corner[0] > 0.4, "{corner:?}"); } + #[test] + fn lens_smart_filter_keeps_the_exact_focal_length() { + // A generic profile at a fractional focal length, and the auto profile from EXIF. + let generic = json!({"profile": "generic", "focalLength": 27.4, "edge": "edgeExtension"}); + let auto = json!({"profile": "auto", "edge": "edgeExtension"}); + for (p, focal) in [(generic, 27.4), (auto, 18.5)] { + let mut s = session(8); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { focal_length: Some(18.5), ..Default::default() }))); + Ok(()) + }) + .unwrap(); + let d = s.active().unwrap(); + let src = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().clone(); + let (frame, info) = (d.doc.bounds(), camera_info(&d.doc)); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let r = s.execute(LENS, p.clone()).unwrap(); + assert_eq!(r["params"]["focalLength"], focal, "{r}"); + // Re-rendering from the stored params gives the pixels the command rendered. + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().content else { panic!("smart") }; + let original = lens::correct(&src, frame, &lens_params(LENS, &p, frame, info.as_ref()).unwrap()); + let rerender = apply_to_surface(LENS, &sm.smart_filters[0].params, &src, frame).unwrap(); + assert!(original == rerender, "re-render differs for {p}"); + } + } + #[test] fn adaptive_wide_angle_straightens() { let mut s = session(8); From 01a58e8f6f519010d771f11c73bd0db26e63b6fa Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:44:42 +0200 Subject: [PATCH 0445/1452] Layer Style: gradient overlay and stroke scale stay within 10-150 % (#7047) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..0fd358c9ce 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -69,7 +69,7 @@ fn gradient(p: &Value) -> Gradient { stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), angle: f(p, "angle", 90.0), - scale: f(p, "scale", 100.0) / 100.0, + scale: (f(p, "scale", 100.0) / 100.0).clamp(0.1, 1.5), reverse: b(p, "reverse", false), ..Gradient::default() } @@ -582,7 +582,7 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { g.angle = x; } if let Some(x) = f_of(p, "scale") { - g.scale = (x / 100.0).clamp(0.01, 10.0); + g.scale = (x / 100.0).clamp(0.1, 1.5); } if let Some(x) = b_of(p, "reverse") { g.reverse = x; @@ -1190,6 +1190,32 @@ mod tests { assert!(matches!(&effects(&s)[2], Effect::DropShadow(sh) if sh.common.blend == BlendMode::Normal && sh.distance == 12.0)); } + /// The gradient overlay and the gradient stroke keep their scale in Photoshop's 10-150 %. + #[test] + fn gradient_overlay_and_stroke_scale_stay_in_range() { + let scale = |s: &Session| match &effects(s)[0] { + Effect::GradientOverlay { gradient, .. } => gradient.scale, + Effect::Stroke(st) => match &st.paint { + FxPaint::Gradient(g) => g.scale, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + for (cmd, p, want) in [ + ("gradientOverlay", json!({"scale": 1000}), 1.5), + ("gradientOverlay", json!({"scale": 1}), 0.1), + ("gradientOverlay", json!({"scale": 1e39}), 1.5), + ("gradientOverlay", json!({"scale": 80}), 0.8), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": -50}), 0.1), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 0}), 0.1), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 400}), 1.5), + ] { + let mut s = session(); + s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + assert!((scale(&s) - want).abs() < 1e-6, "{cmd} {p}: {}", scale(&s)); + } + } + #[test] fn clear_and_undo() { let mut s = session(); From 7ebaa94a821a51254b22561f685828dd33635992 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:44:43 +0200 Subject: [PATCH 0446/1452] Render Video: GIF frame delay follows timelines under 1 fps (#7045) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/video_cmds.rs | 4 +++- crates/engine/src/video_cmds/tests.rs | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 6fb9ab4574..a4ed4f840f 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -424,7 +424,9 @@ pub fn render_video_with( if frames > crate::timeline_cmds::MAX_DURATION { return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); } - let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); + // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are + // valid timelines, so only an unusable loaded rate falls back to 30. + let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); let bounds = base.bounds(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index c16c7f8e39..d9d8e451e1 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -151,6 +151,39 @@ fn a_fully_transparent_animated_gif_frame_encodes() { assert!(f.indices.iter().all(|&i| Some(i) == f.transparent)); } +/// The GIF frame delays (centiseconds, from each Graphic Control Extension) of a Render Video. +fn rendered_gif_delays(s: &Session) -> Vec { + let mut gif = Vec::new(); + render_video_with( + s, + &json!({"format": "gif"}), + |_, bytes| { + gif = bytes.to_vec(); + Ok(()) + }, + |_, _| true, + ) + .unwrap(); + gif.windows(6).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| u16::from_le_bytes([w[4], w[5]])).collect() +} + +#[test] +fn animated_gif_delay_follows_timelines_under_one_fps() { + // 0.5 fps is a valid timeline rate; its frames last 2 s, not the 1 s a 1 fps floor gave. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 2, "fps": 0.5})).unwrap(); + assert_eq!(rendered_gif_delays(&s), [200, 200]); + // A loaded timeline with an unusable rate falls back to 30 fps (3 cs), as with no timeline. + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(2, f32::NAN)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + assert_eq!(rendered_gif_delays(&s), [3, 3]); +} + #[test] fn inspect_reports_video_layer() { let dir = tmpdir("vinsp"); From cb185320ed8c296da4f7ca67b3dd404f3b838a9b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:44:56 +0200 Subject: [PATCH 0447/1452] Conditional Mode Change: "gray" as a single source mode matches Grayscale (#7042) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 11 +++++++---- crates/engine/src/file_cmds/tests.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..b588846d85 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -903,11 +903,14 @@ fn conditional_mode_change(s: &mut Session, p: &Value) -> Result { other => return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unsupported target mode \"{other}\" (rgb, grayscale, cmyk, lab)") }), }; let cur = mode_name(s.active().ok_or(EngineError::NoDocument)?.doc.mode); + // One spelling per source mode, for the array and the single-string forms alike (#7042). + let norm = |v: &str| { + let v = v.to_ascii_lowercase(); + if v == "gray" { "grayscale".to_string() } else { v } + }; let from: Vec = match p.get("from") { - Some(Value::Array(a)) => { - a.iter().filter_map(Value::as_str).map(|v| v.to_ascii_lowercase().replace("gray", "grayscale").replace("grayscalescale", "grayscale")).collect() - } - Some(Value::String(v)) if v != "any" => vec![v.to_ascii_lowercase()], + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(norm).collect(), + Some(Value::String(v)) => vec![norm(v)], _ => vec!["any".into()], }; if cur == to || !(from.iter().any(|f| f == "any" || f == cur)) { diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..d262cf7060 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -541,6 +541,23 @@ fn fit_image_and_conditional_mode_change() { assert!(s.execute("file.automate.conditionalModeChange", json!({"to": "duotone"})).is_err()); } +#[test] +fn conditional_mode_change_single_gray_source_matches_grayscale() { + // A single `"from"` string is normalised like the array form: "gray" names Grayscale (#7042). + let mut s = session(4, 4, 8); + s.execute("image.mode.grayscale", json!({})).unwrap(); + let r = s.execute("file.automate.conditionalModeChange", json!({"from": "rgb", "to": "rgb"})).unwrap(); + assert_eq!(r["changed"], false); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); + let r = s.execute("file.automate.conditionalModeChange", json!({"from": "gray", "to": "rgb"})).unwrap(); + assert_eq!(r["changed"], true, "{r}"); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + s.execute("image.mode.grayscale", json!({})).unwrap(); + let r = s.execute("file.automate.conditionalModeChange", json!({"from": "Gray", "to": "rgb"})).unwrap(); + assert_eq!(r["changed"], true, "{r}"); + assert_eq!(doc(&s).mode, ColorMode::Rgb); +} + #[test] fn conditional_mode_change_cannot_run_a_mode_change_the_gate_denies() { fn deny_mode(id: &str, _: &Value) -> Result<()> { From 758ad4db44660401be37a1b155bb399f4d8caae5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:43:47 +0200 Subject: [PATCH 0448/1452] =?UTF-8?q?Crop:=20the=20turn=20readout=20never?= =?UTF-8?q?=20shows=20-0.0=C2=B0=20or=20-180.0=C2=B0=20(#7040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++++++++++++++- crates/ui-egui/src/crop_straighten.rs | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 914d395acc..d61de03d3a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -398,6 +398,20 @@ fn draw_marquee_readout(app: &PhotocraftApp, ctx: &egui::Context, cursor: Pos2, draw_value_readout(app, ctx, "marquee-readout", cursor, ["W:", "H:"], values); } +/// A turn of `deg` degrees as the readout beside the pointer shows it, to 0.1° in (-180, 180]. +/// Rounded first so a hair below zero never reads "-0.0°" and a hair above -180 reads "180.0°". +pub(crate) fn angle_readout(deg: f64) -> String { + let d = (deg * 10.0).round() / 10.0; + let d = if d == 0.0 { + 0.0 + } else if d == -180.0 { + 180.0 + } else { + d + }; + format!("{d:.1}°") +} + /// A transformation-values readout beside the pointer (a marquee's or the crop frame's size, an /// angle), unless Preferences › Tools › Show Transformation Values is off, as in Photoshop (#204). /// The brush HUD isn't one: Show Transformation Values doesn't hide it, so it calls [`draw_readout`]. @@ -4713,7 +4727,7 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, } // The frame's angle beside the pointer while it turns (#1792). if let (Some(a), Some(h)) = (crate::crop_ui::turning(app), hover) { - draw_value_readout(app, painter.ctx(), "crop-angle-readout", h, ["Angle:"], [format!("{a:.1}°")]); + draw_value_readout(app, painter.ctx(), "crop-angle-readout", h, ["Angle:"], [angle_readout(a)]); } else if let (Some([w, h]), Some(at)) = (crate::crop_ui::sizing(app), hover) { // Its W × H while it is drawn or resized (#1919), like the marquee's. draw_marquee_readout(app, painter.ctx(), at, size_readout(app, [0.0, 0.0, w, h])); diff --git a/crates/ui-egui/src/crop_straighten.rs b/crates/ui-egui/src/crop_straighten.rs index 9f8128b80a..e6c1631cb8 100644 --- a/crates/ui-egui/src/crop_straighten.rs +++ b/crates/ui-egui/src/crop_straighten.rs @@ -156,7 +156,7 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_screen: impl Fn([f6 let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let deg = (-dy).atan2(dx).to_degrees(); if let Some(h) = hover.filter(|_| deg.is_finite() && (dx != 0.0 || dy != 0.0)) { - crate::canvas::draw_value_readout(app, painter.ctx(), "crop-straighten-readout", h, ["Angle:"], [format!("{deg:.1}°")]); + crate::canvas::draw_value_readout(app, painter.ctx(), "crop-straighten-readout", h, ["Angle:"], [crate::canvas::angle_readout(deg)]); } } @@ -231,6 +231,17 @@ mod tests { assert_eq!(frame_angle([0.0, 0.0], [f64::INFINITY, 1.0], 1.0), None); } + #[test] + fn the_angle_readout_never_shows_minus_zero_or_minus_180() { + use crate::canvas::angle_readout; + assert_eq!(angle_readout(-0.03), "0.0°"); + assert_eq!(angle_readout(0.0), "0.0°"); + assert_eq!(angle_readout(-179.97), "180.0°"); + assert_eq!(angle_readout(179.97), "180.0°"); + assert_eq!(angle_readout(12.34), "12.3°"); + assert_eq!(angle_readout(-12.36), "-12.4°"); + } + #[test] fn the_fitted_frame_is_the_largest_of_its_proportions_inside_the_canvas() { // Upright, the canvas's own proportions fill it. From b2f9c45c6a2d6672fce1d62dfb78c746f18e38ec Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:45:34 +0200 Subject: [PATCH 0449/1452] Match Zoom: matched views stay within each document's zoom range (#7036) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/view_cmds.rs | 11 +++++++---- crates/ui-egui/src/view_cmds/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..56f5071a2c 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -861,9 +861,12 @@ fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result let src = app.ui.views.get(i).cloned().ok_or("no document")?; let (zoom, loc, rot) = (matches!(k, "matchZoom" | "matchAll"), matches!(k, "matchLocation" | "matchAll"), matches!(k, "matchRotation" | "matchAll")); - let apply = |v: &mut crate::state::View| { + // Each view stays within its own document's zoom range (a view not drawn yet has no + // `doc_size`, so the size comes from the document). + let sizes: Vec<[u32; 2]> = app.session.documents().iter().map(|d| [d.doc.size.width, d.doc.size.height]).collect(); + let apply = |v: &mut crate::state::View, doc: usize| { if zoom { - v.zoom = src.zoom; + v.zoom = crate::zoom_levels::clamp(src.zoom, sizes.get(doc).copied().unwrap_or(v.doc_size)); } if loc { v.center = src.center; @@ -873,8 +876,8 @@ fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result } v.fit_pending = false; }; - app.ui.views.iter_mut().for_each(apply); - app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view)); + app.ui.views.iter_mut().enumerate().for_each(|(d, v)| apply(v, d)); + app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view, w.document)); Ok(json!({"zoom": src.zoom, "center": src.center, "rotation": src.rotation})) } _ => Err(format!("unknown arrangement {k}")), diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 1abe863558..a2d37d6e79 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -183,6 +183,28 @@ fn arrange_layouts_floating_windows_and_matching() { assert_eq!(app.ui.windows.len(), 1); } +/// Match Zoom clamps the matched zoom to each document's own range: a 10 x 10 document can't go +/// below the zoom at which its shorter side is 2 px, even when the source view is lower. +#[test] +fn match_zoom_clamps_to_each_documents_zoom_range() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 10, "height": 10})).unwrap(); + app.run("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + assert_eq!(app.session.active_index(), Some(1)); + app.ui.views[1].zoom = 0.05; + menu(&mut app, &ctx, "window.arrange.matchZoom", json!({})).unwrap(); + assert_eq!(app.ui.views[1].zoom, 0.05); + assert_eq!(app.ui.views[0].zoom, crate::zoom_levels::min([10, 10])); + // Floating windows too, before their first frame (no `doc_size` yet). + menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); + app.ui.views[1].zoom = 0.05; + menu(&mut app, &ctx, "window.arrange.matchZoom", json!({})).unwrap(); + assert_eq!(app.ui.windows.len(), 2); + assert!(app.ui.windows.iter().all(|w| w.view.zoom == if w.document == 0 { crate::zoom_levels::min([10, 10]) } else { 0.05 })); +} + #[test] fn two_up_and_stacked_cells_keep_their_existing_boundaries() { let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0)); From 0b1953c494775303b14a072f54d11f099c6e975e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:45:51 +0200 Subject: [PATCH 0450/1452] Crop: W x H x Resolution follows the Point/Pica Size preference (#7038) The crop tool's W x H x Resolution mode converted points and picas at a fixed 72 pt/in, while Image Size, Canvas Size (#5757), the rulers, Info and the shape dialog use Preferences > Units & Rulers > Point/Pica Size. With Traditional (72.27), W "72 pt" x H "1 in" at 300 ppi cropped to 300 x 300 instead of 299 x 300. target() and ratio() now take the points per inch, and the callers (commit_params, preset_ratio, fit_frame) pass the preference. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_size.rs | 46 +++++++++++++++++++++++---------- crates/ui-egui/src/crop_ui.rs | 6 +++-- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index b30cdd8c4a..8e1b3703d0 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -30,8 +30,6 @@ pub const FRONT: &str = "front"; /// Resolution units of the resolution field. pub const PX_PER_IN: &str = "px/in"; pub const PX_PER_CM: &str = "px/cm"; -/// Points per inch for pt / pica lengths (PostScript, Photoshop's default). -const POINTS_PER_INCH: f64 = 72.0; /// The largest number a length or resolution field keeps (well past any canvas or ppi limit, /// which `image.crop` enforces with its own message). const MAX_FIELD: f64 = 1e9; @@ -125,8 +123,9 @@ pub fn resolution_ppi(o: &ToolOptions) -> Option { /// W x H x Resolution's target: the size in pixels the crop is resampled to (unrounded) and the /// resolution it sets, when the mode is on and both W and H hold lengths. Physical lengths convert -/// at the typed resolution, or at `doc_dpi` when there is none. -pub fn target(o: &ToolOptions, doc_dpi: f64) -> Option<(f64, f64, Option)> { +/// at the typed resolution, or at `doc_dpi` when there is none; points and picas at `pt_per_in` +/// (the Point/Pica Size preference). +pub fn target(o: &ToolOptions, doc_dpi: f64, pt_per_in: f64) -> Option<(f64, f64, Option)> { if o.crop_ratio != WHR { return None; } @@ -134,15 +133,15 @@ pub fn target(o: &ToolOptions, doc_dpi: f64) -> Option<(f64, f64, Option)> let dpi = res.unwrap_or(doc_dpi); let px = |text: &str| { let (v, u) = parse_length(text, Unit::Pixels)?; - let px = u.to_px(v, dpi, 0.0, POINTS_PER_INCH); + let px = u.to_px(v, dpi, 0.0, pt_per_in); (px.is_finite() && px > 0.0).then_some(px) }; Some((px(&o.crop_width)?, px(&o.crop_height)?, res)) } /// The ratio W x H x Resolution holds while the frame is drawn or resized (width / height). -pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option { - let (w, h, _) = target(o, doc_dpi)?; +pub fn ratio(o: &ToolOptions, doc_dpi: f64, pt_per_in: f64) -> Option { + let (w, h, _) = target(o, doc_dpi, pt_per_in)?; Some(w / h).filter(|k| k.is_finite() && *k > 0.0) } @@ -194,7 +193,8 @@ pub fn commit_params(app: &PhotocraftApp, p: &mut Value) { } let doc_dpi = app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi)); let px = |v: f64| v.round().clamp(1.0, MAX_FIELD) as u64; - if let Some((w, h, _)) = target(o, doc_dpi) { + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); + if let Some((w, h, _)) = target(o, doc_dpi, pt_per_in) { p["targetWidth"] = json!(px(w)); p["targetHeight"] = json!(px(h)); p["resample"] = json!(crate::sizing::pref_resample(app.session.prefs().general.image_interpolation)); @@ -402,24 +402,24 @@ mod tests { #[test] fn target_converts_physical_units_at_the_resolution_or_the_documents() { let close = |a: f64, b: f64| (a - b).abs() < 1e-6; - let (w, h, r) = target(&whr("4 in", "5 in", "300", PX_PER_IN), 72.0).unwrap(); + let (w, h, r) = target(&whr("4 in", "5 in", "300", PX_PER_IN), 72.0, 72.0).unwrap(); assert!(close(w, 1200.0) && close(h, 1500.0) && r == Some(300.0)); - let (w, h, r) = target(&whr("10 cm", "25 mm", "100", PX_PER_CM), 72.0).unwrap(); + let (w, h, r) = target(&whr("10 cm", "25 mm", "100", PX_PER_CM), 72.0, 72.0).unwrap(); assert!(close(w, 1000.0) && close(h, 250.0), "{w} {h}"); assert!(close(r.unwrap(), 254.0), "100 px/cm is 254 ppi"); // No resolution: inches convert at the document's, and none is set. - let (w, h, r) = target(&whr("2 in", "100 px", "", PX_PER_IN), 72.0).unwrap(); + let (w, h, r) = target(&whr("2 in", "100 px", "", PX_PER_IN), 72.0, 72.0).unwrap(); assert!(close(w, 144.0) && close(h, 100.0) && r.is_none()); // W or H missing, or another mode: no target (a free crop). - assert_eq!(target(&whr("2 in", "", "300", PX_PER_IN), 72.0), None); - assert_eq!(target(&ToolOptions { crop_ratio: "1:1".into(), ..whr("2 in", "3 in", "", PX_PER_IN) }, 72.0), None); + assert_eq!(target(&whr("2 in", "", "300", PX_PER_IN), 72.0, 72.0), None); + assert_eq!(target(&ToolOptions { crop_ratio: "1:1".into(), ..whr("2 in", "3 in", "", PX_PER_IN) }, 72.0, 72.0), None); } #[test] fn the_frame_holds_w_by_h() { let mut app = app(SampleType::U8); app.ui.tool_options = whr("4 in", "5 in", "300", PX_PER_IN); - assert!((ratio(&app.ui.tool_options, 72.0).unwrap() - 0.8).abs() < 1e-9); + assert!((ratio(&app.ui.tool_options, 72.0, 72.0).unwrap() - 0.8).abs() < 1e-9); let mods = Modifiers::NONE; use crate::canvas::{ToolEvent, tool_event}; tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, mods); @@ -525,6 +525,24 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); } + #[test] + fn points_and_picas_follow_the_point_size_preference() { + let mut app = app(SampleType::U8); + app.ui.tool_options = whr("72 pt", "1 in", "300", PX_PER_IN); + let target_size = |app: &PhotocraftApp| { + let mut p = json!({}); + commit_params(app, &mut p); + (p["targetWidth"].as_u64(), p["targetHeight"].as_u64()) + }; + assert_eq!(target_size(&app), (Some(300), Some(300)), "PostScript: 72 pt is an inch"); + app.session.edit_prefs(|p| p.units_and_rulers.point_size = photocraft_engine::prefs::PointSize::Traditional); + assert_eq!(target_size(&app), (Some(299), Some(300)), "Traditional: 72.27 pt per inch"); + let (w, h, _) = target(&app.ui.tool_options, 72.0, 72.27).unwrap(); + assert!((w - 300.0 * 72.0 / 72.27).abs() < 1e-9 && (h - 300.0).abs() < 1e-9, "{w} {h}"); + app.ui.tool_options = whr("6 pc", "1 in", "300", PX_PER_IN); + assert_eq!(target_size(&app), (Some(299), Some(300)), "a pica is 12 points"); + } + #[test] fn older_tool_options_load_without_the_new_fields() { let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..2da287d605 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -217,7 +217,8 @@ fn size_ok(r: [f64; 4]) -> bool { pub(crate) fn preset_ratio(app: &PhotocraftApp) -> Option { if app.ui.tool_options.crop_ratio == crate::crop_size::WHR { let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); - return crate::crop_size::ratio(&app.ui.tool_options, dpi); + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); + return crate::crop_size::ratio(&app.ui.tool_options, dpi, pt_per_in); } let size = app.session.active().map_or((1.0, 1.0), |s| (s.doc.size.width as f64, s.doc.size.height as f64)); crate::chrome_ui::crop_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1).map(|(w, h)| w / h).filter(|k| k.is_finite() && *k > 0.0) @@ -250,11 +251,12 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { fn fit_frame(app: &mut PhotocraftApp) { let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); if let Some(r) = app.ui.crop_rect && let Some(ratio) = preset_ratio(app) && let Some(fitted) = fitted_ratio(r, ratio) // W x H x Resolution can change output size even when the frame already fits. - && (fitted != r || crate::crop_size::target(&app.ui.tool_options, dpi).is_some()) + && (fitted != r || crate::crop_size::target(&app.ui.tool_options, dpi, pt_per_in).is_some()) { keyed(app, fitted); } From 87e8e5977ccb511da9e2a1e7119bc9a49631e370 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 19:19:16 +0530 Subject: [PATCH 0451/1452] fix(engine): restore export_presets in load_presets_json (#5344 x #5616) main stopped compiling when #5616 rebuilt Persisted field by field without the export_presets field #5344 added. Same one-line fix as #6906, so it merges cleanly whichever lands first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From d685aa1484c9f0ff8a59e47eccc5872bdeeda67e Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:19:18 +0300 Subject: [PATCH 0452/1452] Fill layers: pin the preference-off precedence for selection and explicit path With "Use Default Masks on Fill Layers" off, only the default reveal-all mask goes away. Cover that contract directly for Solid Color, Gradient and Pattern Fill: no selection and no path gives no mask; an active selection still becomes the raster layer mask (revealing inside, hiding outside); an explicit vector path still becomes the vector mask, with or without a selection, and no raster mask is added next to it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 6330525d8b..1ea70ecb2c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2983,6 +2983,56 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); } + /// With the option off only the default reveal-all mask goes away: an active selection still + /// becomes the layer mask and an explicit path still becomes the vector mask (path first). + #[test] + fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() { + const KINDS: [(&str, &str); 3] = [ + ("layer.newFillLayer.solidColor", r##"{"color": "#112233"}"##), + ("layer.newFillLayer.gradient", "{}"), + ("layer.newFillLayer.pattern", r#"{"pattern": "Checkerboard"}"#), + ]; + let path = json!({"subpaths": [{"closed": true, "knots": [[2, 2], [10, 2], [10, 10], [2, 10]]}]}); + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + // Creates the fill layer; returns its raster mask and whether it has a vector mask. + let create = |s: &mut Session, cmd: &str, params: &str, path: Option<&Value>| { + let mut p: Value = serde_json::from_str(params).unwrap(); + if let Some(path) = path { + p["path"] = path.clone(); + } + let id = LayerId(s.execute(cmd, p).unwrap()["layer"].as_u64().unwrap()); + let l = s.active().unwrap().doc.layer(id).unwrap(); + (l.mask.clone(), l.vector_mask.is_some()) + }; + + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, None); + assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all"); + } + + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, None); + let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask")); + assert!(!vector, "{cmd}"); + assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals"); + assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides"); + } + + // An explicit path wins over the selection, as with the option on. + for selected in [true, false] { + if !selected { + s.execute("select.deselect", json!({})).unwrap(); + } + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, Some(&path)); + assert!(vector, "{cmd}: the explicit path becomes the vector mask (selection: {selected})"); + assert!(mask.is_none(), "{cmd}: no raster mask next to the vector mask (selection: {selected})"); + } + } + } + #[test] fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { let (mut s, ids) = new_session(); From 2430664dd3e407b53f8ca6cd61a73ab007c95d42 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:01:16 +0300 Subject: [PATCH 0453/1452] Smart Filters: note under Mode when Dissolve is selected Dissolve in Smart Filter blending follows the inherited color-blending path without per-pixel coverage sampling (docs/smart-filters.md). Show a short faint note under the Mode control in Blending Options when Dissolve is chosen, so the limitation is visible where the mode is picked. No behaviour change. The note is added to all 20 catalogs. The test renders the dialog body for every layer blend mode and checks that only Dissolve paints the note. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/smart_filter_dialog.rs | 8 +++++ .../ui-egui/src/smart_filter_dialog/tests.rs | 35 +++++++++++++++++++ 22 files changed, 63 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f16a4e56ad..b363cd2a0d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2649,6 +2649,7 @@ cameraData Lens Objektiv Wait for the current job to finish. Počkejte na dokončení aktuální úlohy. The document or Smart Filter changed. Reopen its dialog. Dokument nebo inteligentní filtr se změnil. Znovu otevřete jeho dialog. Opacity must be between 0% and 100%. Krytí musí být mezi 0% a 100%. + Dissolve is approximated: it blends without Photoshop's dither. Režim Rozpustit je pouze aproximován: prolíná bez ditheringu Photoshopu. # Dynamic labels the tl! literal scanner cannot see: filter and Filter Gallery parameter names and # choices (humanized from their keys), Camera Raw controls, render filters (Flame, Picture Frame, Tree), diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05fb3d2d18..63cce37694 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2683,6 +2683,7 @@ cameraData Lens Objektiv Wait for the current job to finish. Warten Sie, bis der aktuelle Auftrag abgeschlossen ist. The document or Smart Filter changed. Reopen its dialog. Das Dokument oder der Smartfilter wurde geändert. Öffnen Sie den Dialog erneut. Opacity must be between 0% and 100%. Die Deckkraft muss zwischen 0% und 100% liegen. + Dissolve is approximated: it blends without Photoshop's dither. Sprenkeln wird nur angenähert: Es wird ohne das Dithering von Photoshop gemischt. Loading fonts… Schriften werden geladen… Response curve Reaktionskurve diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ae57871745..32f815459e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3739,6 +3739,7 @@ cameraData Lens Φακός Wait for the current job to finish. Περιμένετε να ολοκληρωθεί η τρέχουσα εργασία. The document or Smart Filter changed. Reopen its dialog. Το έγγραφο ή το έξυπνο φίλτρο άλλαξε. Ανοίξτε ξανά το παράθυρό του. Opacity must be between 0% and 100%. Η αδιαφάνεια πρέπει να είναι μεταξύ 0% και 100%. + Dissolve is approximated: it blends without Photoshop's dither. Η Διάλυση προσεγγίζεται: η ανάμειξη γίνεται χωρίς το dither του Photoshop. Loading fonts… Φόρτωση γραμματοσειρών… Response curve Καμπύλη απόκρισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 42c0472953..7e32c1164a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2725,6 +2725,7 @@ cameraData Lens Objetivo Wait for the current job to finish. Espere a que termine la tarea actual. The document or Smart Filter changed. Reopen its dialog. El documento o el filtro inteligente ha cambiado. Vuelva a abrir su diálogo. Opacity must be between 0% and 100%. La opacidad debe estar entre 0% y 100%. + Dissolve is approximated: it blends without Photoshop's dither. Disolver es aproximado: se fusiona sin el tramado de Photoshop. Loading fonts… Cargando fuentes… Response curve Curva de respuesta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index eb27bbdd4e..99b58f9dce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3059,6 +3059,7 @@ cameraData Lens Objectif Wait for the current job to finish. Attendez la fin de la tâche en cours. The document or Smart Filter changed. Reopen its dialog. Le document ou le filtre dynamique a changé. Rouvrez sa boîte de dialogue. Opacity must be between 0% and 100%. L'opacité doit être comprise entre 0% et 100%. + Dissolve is approximated: it blends without Photoshop's dither. Le mode Fondu est approximé : il fusionne sans le tramage de Photoshop. Loading fonts… Chargement des polices… Response curve Courbe de réponse diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0f3b8cd861..9d13417b26 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4112,6 +4112,7 @@ cameraRaw Yellow Sárga Blending Options ({filter}) Keverési beállítások ({filter}) Hide the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek elrejtése ({key}+kattintás: minden réteg) Opacity must be between 0% and 100%. Az átlátszatlanságnak 0% és 100% között kell lennie. + Dissolve is approximated: it blends without Photoshop's dither. A Szétszórás csak közelítés: a Photoshop raszterezése nélkül keveredik. Show the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek megjelenítése ({key}+kattintás: minden réteg) The document or Smart Filter changed. Reopen its dialog. A dokumentum vagy az intelligens szűrő megváltozott. Nyisd meg újra a párbeszédablakát. Wait for the current job to finish. Várd meg, amíg az aktuális feladat befejeződik. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b330f96227..65128a63e0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3046,6 +3046,7 @@ cameraData Lens Lensa Wait for the current job to finish. Tunggu hingga tugas saat ini selesai. The document or Smart Filter changed. Reopen its dialog. Dokumen atau Filter Cerdas berubah. Buka kembali dialognya. Opacity must be between 0% and 100%. Opasitas harus antara 0% dan 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolve hanya didekati: pencampuran berjalan tanpa dither Photoshop. Loading fonts… Memuat font… Response curve Kurva respons diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b9935c70b9..47e1aa0d50 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2713,6 +2713,7 @@ cameraData Lens Obiettivo Wait for the current job to finish. Attendi il completamento dell'attività corrente. The document or Smart Filter changed. Reopen its dialog. Il documento o il filtro avanzato è cambiato. Riapri la sua finestra di dialogo. Opacity must be between 0% and 100%. L'opacità deve essere compresa tra 0% e 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolvi è approssimato: la fusione avviene senza il dither di Photoshop. Loading fonts… Caricamento font… Response curve Curva di risposta diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e1767912fb..73d988bd79 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3654,6 +3654,7 @@ cameraData Lens レンズ Wait for the current job to finish. 現在の処理が完了するまでお待ちください。 The document or Smart Filter changed. Reopen its dialog. ドキュメントまたはスマートフィルターが変更されました。ダイアログを開き直してください。 Opacity must be between 0% and 100%. 不透明度は0%から100%の範囲で指定してください。 + Dissolve is approximated: it blends without Photoshop's dither. ディザ合成は近似です。Photoshop のディザなしでブレンドします。 Loading fonts… フォントを読み込み中… Response curve 応答カーブ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5e0ac90aa1..8627b889c1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3227,6 +3227,7 @@ cameraData Lens 렌즈 Wait for the current job to finish. 현재 작업이 완료될 때까지 기다리세요. The document or Smart Filter changed. Reopen its dialog. 문서 또는 스마트 필터가 변경되었습니다. 대화 상자를 다시 여세요. Opacity must be between 0% and 100%. 불투명도는 0%에서 100% 사이여야 합니다. + Dissolve is approximated: it blends without Photoshop's dither. 디졸브는 근사치입니다. Photoshop의 디더링 없이 혼합됩니다. Loading fonts… 글꼴 불러오는 중… Response curve 응답 곡선 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d02b977e12..d446b92f49 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3798,6 +3798,7 @@ cameraData Lens Lens Wait for the current job to finish. Wacht tot de huidige taak is voltooid. The document or Smart Filter changed. Reopen its dialog. Het document of het slimme filter is gewijzigd. Open het dialoogvenster opnieuw. Opacity must be between 0% and 100%. De dekking moet tussen 0% en 100% liggen. + Dissolve is approximated: it blends without Photoshop's dither. Verspreiden is benaderd: het mengt zonder de dither van Photoshop. Loading fonts… Lettertypen laden… Response curve Responscurve diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 86518395b0..51170d6c74 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3656,6 +3656,7 @@ cameraData Lens Obiektyw Wait for the current job to finish. Poczekaj na zakończenie bieżącego zadania. The document or Smart Filter changed. Reopen its dialog. Dokument lub inteligentny filtr został zmieniony. Otwórz ponownie jego okno dialogowe. Opacity must be between 0% and 100%. Krycie musi wynosić od 0% do 100%. + Dissolve is approximated: it blends without Photoshop's dither. Rozpuszczanie jest przybliżone: miesza się bez ditheringu Photoshopa. Loading fonts… Wczytywanie czcionek… Response curve Krzywa odpowiedzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 296520f723..cfc7a5dd6b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3062,6 +3062,7 @@ cameraData Lens Lente Wait for the current job to finish. Aguarde a conclusão da tarefa atual. The document or Smart Filter changed. Reopen its dialog. O documento ou o Filtro Inteligente mudou. Reabra sua caixa de diálogo. Opacity must be between 0% and 100%. A opacidade deve estar entre 0% e 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolver é aproximado: a mesclagem ocorre sem o dither do Photoshop. Loading fonts… Carregando fontes… Response curve Curva de resposta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 69aa764698..35ec3e2c1a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2983,6 +2983,7 @@ cameraData Lens Объектив Wait for the current job to finish. Дождитесь завершения текущей задачи. The document or Smart Filter changed. Reopen its dialog. Документ или смарт-фильтр изменился. Откройте его диалог заново. Opacity must be between 0% and 100%. Непрозрачность должна быть от 0% до 100%. + Dissolve is approximated: it blends without Photoshop's dither. Растворение приближённое: смешивание без дизеринга Photoshop. Loading fonts… Загрузка шрифтов… Response curve Кривая отклика diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 48d43c0c9e..f00a538142 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4266,6 +4266,7 @@ shape Appearance Izgled Blending Options ({filter}) Opcije stapanja ({filter}) Hide the layer's effects and Smart Filters ({key}-click: all layers) Sakrij efekte i pametne filtere sloja ({key}-klik: svi slojevi) Opacity must be between 0% and 100%. Neprozirnost mora biti između 0% i 100%. + Dissolve is approximated: it blends without Photoshop's dither. Rastapanje je približno: mešanje se vrši bez diteringa programa Photoshop. Show the layer's effects and Smart Filters ({key}-click: all layers) Prikaži efekte i pametne filtere sloja ({key}-klik: svi slojevi) The document or Smart Filter changed. Reopen its dialog. Dokument ili pametni filter se promenio. Ponovo otvorite njegov dijalog. Wait for the current job to finish. Sačekajte da se trenutni posao završi. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2233e90ddd..e95a0e5e76 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3923,6 +3923,7 @@ cameraData Lens Lens Blending Options ({filter}) Karışım Seçenekleri ({filter}) Hide the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini gizle ({key}-tık: tüm katmanlar) Opacity must be between 0% and 100%. Opaklık %0 ile %100 arasında olmalıdır. + Dissolve is approximated: it blends without Photoshop's dither. Eritme yaklaşık olarak uygulanır: Photoshop'un dither'ı olmadan karıştırır. Show the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini göster ({key}-tık: tüm katmanlar) The document or Smart Filter changed. Reopen its dialog. Belge veya Akıllı Filtre değişti. İletişim kutusunu yeniden açın. Wait for the current job to finish. Geçerli işin bitmesini bekleyin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 759077cdd0..dc086f98d5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3730,6 +3730,7 @@ cameraData Lens Об'єктив Wait for the current job to finish. Дочекайтеся завершення поточного завдання. The document or Smart Filter changed. Reopen its dialog. Документ або смарт-фільтр змінився. Відкрийте його діалог знову. Opacity must be between 0% and 100%. Непрозорість має бути від 0% до 100%. + Dissolve is approximated: it blends without Photoshop's dither. Розчинення наближене: змішування без дизерингу Photoshop. Loading fonts… Завантаження шрифтів… Response curve Крива відгуку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0c072faa7f..36379d5155 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3679,6 +3679,7 @@ curves Smooth Làm mượt Blending Options ({filter}) Tùy chọn hòa trộn ({filter}) Hide the layer's effects and Smart Filters ({key}-click: all layers) Ẩn hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) Opacity must be between 0% and 100%. Độ mờ phải nằm trong khoảng từ 0% đến 100%. + Dissolve is approximated: it blends without Photoshop's dither. Hoà tan chỉ được mô phỏng gần đúng: hòa trộn không có dither của Photoshop. Show the layer's effects and Smart Filters ({key}-click: all layers) Hiện hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) The document or Smart Filter changed. Reopen its dialog. Tài liệu hoặc bộ lọc thông minh đã thay đổi. Hãy mở lại hộp thoại của nó. Wait for the current job to finish. Hãy chờ tác vụ hiện tại hoàn tất. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8475e4172d..57631d3c44 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2655,6 +2655,7 @@ cameraData Lens 镜头 Wait for the current job to finish. 请等待当前任务完成。 The document or Smart Filter changed. Reopen its dialog. 文档或智能滤镜已更改。请重新打开其对话框。 Opacity must be between 0% and 100%. 不透明度必须介于 0% 和 100% 之间。 + Dissolve is approximated: it blends without Photoshop's dither. “溶解”为近似效果:混合时没有 Photoshop 的抖动。 Loading fonts… 正在加载字体… Response curve 响应曲线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 96206e13b2..0ddbfbab23 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2625,6 +2625,7 @@ cameraData Lens 鏡頭 Wait for the current job to finish. 請等待目前的工作完成。 The document or Smart Filter changed. Reopen its dialog. 文件或智慧型濾鏡已變更。請重新開啟其對話框。 Opacity must be between 0% and 100%. 不透明度必須介於 0% 和 100% 之間。 + Dissolve is approximated: it blends without Photoshop's dither. 「溶解」為近似效果:混合時沒有 Photoshop 的仿色。 Loading fonts… 正在載入字體… Response curve 回應曲線 diff --git a/crates/ui-egui/src/smart_filter_dialog.rs b/crates/ui-egui/src/smart_filter_dialog.rs index b8db816939..d510ed74c5 100644 --- a/crates/ui-egui/src/smart_filter_dialog.rs +++ b/crates/ui-egui/src/smart_filter_dialog.rs @@ -130,6 +130,11 @@ pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map) -> } } +/// Dissolve has no per-pixel coverage sampling in Smart Filter blending yet (docs/smart-filters.md). +fn mode_note(blend: Option<&str>) -> Option<&'static str> { + (blend == Some(BlendMode::Dissolve.label())).then(|| tl!("Dissolve is approximated: it blends without Photoshop's dither.")) +} + pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { ui.horizontal(|ui| { ui.label(tl!("Mode")); @@ -139,6 +144,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 640.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| super::body(app, ui, fields)); + output.textures_delta.clear(); + let mut painted = Vec::new(); + output.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + painted +} + +#[test] +fn only_dissolve_shows_the_approximation_note_near_the_mode_control() { + let note = "Dissolve is approximated: it blends without Photoshop's dither."; + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + for blend in BlendMode::LAYER_MODES { + fields.insert("blend".into(), json!(blend.label())); + let painted = painted_text(&mut app, &mut fields); + assert!(painted.iter().any(|s| s == "Mode"), "the Mode control paints for {}", blend.label()); + assert_eq!(painted.iter().any(|s| s == note), blend == BlendMode::Dissolve, "note for {}", blend.label()); + } + assert_eq!(mode_note(None), None); + assert_eq!(mode_note(Some("Normal")), None); + assert_eq!(mode_note(Some("Dissolve")), Some(note)); +} + pub(crate) fn app(depth: u32, mode: &str) -> (PhotocraftApp, LayerId) { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "mode": mode})).unwrap(); From 351149f0df48d1a0c93ad56d0b0940930f50aba1 Mon Sep 17 00:00:00 2001 From: Joseph <56056764+00xJS@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:32:54 -0700 Subject: [PATCH 0454/1452] Fix the build: restore export_presets in load_presets_json (#5344 x #5616) (#6906) #5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it. Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com> --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From efa1edd079b259b0b1f77b835151ee904437a231 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:39:15 +0500 Subject: [PATCH 0455/1452] algo: gate the recorded-hash test's helpers with the test (#7053) `blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the _serial_code`, which is `#[cfg(all(target_os = "linux", target_arch = "x86_64"))]` because the hashes it compares against go through the platform's hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and the two helpers are left unused, so `-D warnings` fails on `dead_code`: error: function `blocks` is never used crates/algo/src/features.rs:704 error: function `fnv` is never used crates/algo/src/features.rs:708 They now carry the same cfg as the only thing that calls them. CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is false for both. --- crates/algo/src/features.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index e8e45bcaf2..ec74891074 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -700,11 +700,14 @@ mod parallel_tests { .collect() } - /// Blocks of random brightness: lots of strong corners, no libm in the making. + /// Blocks of random brightness: lots of strong corners, no libm in the making. Only the + /// recorded-hash test below uses it, and that one is Linux x86_64 only. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn blocks(w: usize, h: usize, seed: u64) -> Vec { (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn fnv(parts: &[u64]) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for p in parts { From 8574f8d80983cdacd8d6e27cc5ef6374ebe0ad68 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 16:39:44 +0200 Subject: [PATCH 0456/1452] Merge to HDR Pro and merged duplicates: the Background layer is a real Background (#7078) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 24 +++++++++++++++++++++++- crates/engine/src/photo_cmds.rs | 7 ++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..cfdc373795 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -735,7 +735,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let vals: Vec = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); surf.write_region(doc.bounds(), &vals); surf.prune(); - doc.layers = vec![Layer::new("Background", LayerContent::Raster(surf))]; + // As Photoshop: a source with a Background gives one (opaque, locked); otherwise a plain + // "Layer 1" that keeps the composite's transparency. + let has_background = doc.background().is_some(); + let mut merged = Layer::new(if has_background { "Background" } else { "Layer 1" }, LayerContent::Raster(surf)); + merged.locks.transparency = has_background; + merged.locks.position = has_background; + doc.layers = vec![merged]; // A merged-only copy has no original layers for saved comps to restore. // Don't carry dangling comp state into a single-layer flattened document. doc.layer_comps.clear(); @@ -1490,6 +1496,22 @@ mod tests { assert_eq!(doc(&s).depth, SampleType::U8); } + /// Duplicate › Merged Layers Only gives one locked Background when the source has one and a + /// plain "Layer 1" (keeping transparency) when it doesn't, as Photoshop does. + #[test] + fn merged_duplicate_is_a_background_only_when_the_source_has_one() { + let mut s = session(); + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = doc(&s); + assert!(d.is_background(d.layers[0].id), "source has a Background: the merged copy is one"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap(); + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = doc(&s); + assert!(d.background().is_none(), "transparent source: no Background in the copy"); + assert_eq!(d.layers[0].name, "Layer 1"); + } + #[test] fn duplicate_document() { let mut s = session(); diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 09fd474aad..d4c70504f8 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -799,7 +799,11 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result { } let mut surf = Surface::new(fmt); surf.write_region(area, &data); - doc.layers.push(Layer::new("Background", LayerContent::Raster(surf))); + // The merged result is opaque: a real Background, as Crop and Straighten makes. + let mut bg = Layer::new("Background", LayerContent::Raster(surf)); + bg.locks.transparency = true; + bg.locks.position = true; + doc.layers.push(bg); let idx = s.add_document(doc, None); let ev0 = exposures[order[0]]; Ok(json!({ @@ -1080,6 +1084,7 @@ mod tests { assert!((ev[1] - 2.0).abs() < 1e-6 && (ev[2] - 4.0).abs() < 1e-6, "{ev:?}"); let d = &s.active().unwrap().doc; assert_eq!(d.depth, SampleType::F32); + assert!(d.is_background(d.layers[0].id), "the merged layer is a real (locked) Background"); let l = d.layers[0].surface().unwrap(); // Linear output: ratios across the ramp follow the radiance. let (a, b) = (l.rgba(20, 30)[1] as f64, l.rgba(80, 30)[1] as f64); From 4fc492d2ca6a889c6f9bfc302e0833ed41e10d90 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 16:40:40 +0200 Subject: [PATCH 0457/1452] Actions: an imported Crop step in a unit other than pixels is not applied as pixels (#7084) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 8 +++++++- crates/engine/src/atn_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..96b39d8eba 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2344,7 +2344,13 @@ fn image_size_to(_: &str, p: &Value) -> Option { fn crop_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); if let Some(t) = get_obj(d, "To ") { - let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { + // Pixels only, like Canvas Size: a side in percent or points needs the document's size + // or resolution and must not be played as pixels. + let side = |k: &str| match t.get(k) { + Some(D::UnitFloat { unit: PX, value }) if value.is_finite() => Some(*value), + _ => None, + }; + let (Some(top), Some(left), Some(bottom), Some(right)) = (side("Top "), side("Left"), side("Btom"), side("Rght")) else { return Mapped::Unsupported { harmless: false }; }; p.insert("x".into(), left.round().into()); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..45e4962bd3 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -479,6 +479,20 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } +#[test] +fn crop_sides_in_other_units_are_refused_not_played_as_pixels() { + let rect = |unit: [u8; 4]| { + let side = |v: f64| D::UnitFloat { unit, value: v }; + desc("null") + .with("To ", D::Descriptor(desc("Rctn").with("Top ", side(10.0)).with("Left", side(10.0)).with("Btom", side(90.0)).with("Rght", side(90.0)))) + }; + for unit in [PCT, *b"#Rlt"] { + assert!(matches!(crop_from(&rect(unit)), Mapped::Unsupported { harmless: false }), "{}", String::from_utf8_lossy(&unit)); + } + let Mapped::Steps(steps) = crop_from(&rect(PX)) else { panic!("a crop in pixels maps") }; + assert_eq!(steps, [("image.crop".to_string(), json!({"x": 10.0, "y": 10.0, "width": 80.0, "height": 80.0}))]); +} + #[test] fn step_commands_and_bad_params_fail_gracefully() { let mut s = Session::new(); From fcd337e1fc7bd0a154c05e859a1395c3450f99f8 Mon Sep 17 00:00:00 2001 From: ebosido444-ai <316167330+ebosido444-ai@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:51 +0300 Subject: [PATCH 0458/1452] perf(text): rasterize a type layer's pixels on all cores, bit for bit the same A type render spends most of its time after the glyphs are drawn: composing each colour's coverage over the premultiplied accumulator, dividing it back to straight colour, and converting the tile bytes. All three are per pixel - no pixel reads a slot another wrote - so they ran one after another on one core for no reason. Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs), release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after (1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune 3.1 ms. The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread: the first two carry a running accumulator across pixels, and the band split that would keep them bit-identical costs a second glyph traversal per band. Native only (rayon, as the other crates): the web build keeps the serial loops. --- Cargo.lock | 2 + crates/raster/Cargo.toml | 4 ++ crates/raster/src/lib.rs | 88 +++++++++++++++++++++------------ crates/text/Cargo.toml | 2 + crates/text/src/render.rs | 47 +++++++++++++----- crates/text/src/tests.rs | 34 ++++++++++++- docs/text-raster-performance.md | 60 ++++++++++++++++++++++ 7 files changed, 193 insertions(+), 44 deletions(-) create mode 100644 docs/text-raster-performance.md diff --git a/Cargo.lock b/Cargo.lock index 0167dea3b1..b0afa75133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4944,6 +4944,7 @@ dependencies = [ "photocraft-color", "photocraft-geom", "proptest", + "rayon", "serde", ] @@ -4996,6 +4997,7 @@ dependencies = [ "photocraft-geom", "photocraft-psd", "photocraft-raster", + "rayon", "skrifa", ] diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 956d1c9545..41b188a94f 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -12,5 +12,9 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 20b809764a..883c244aa5 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -166,6 +166,55 @@ impl PartialEq for Surface { } } +/// Converts the `data` span covering one tile into that tile's bytes. +fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + let tr = tc.rect().intersect(&r); + // Convert contiguous channel samples a row at a time. Dispatching on depth here removes the + // per-pixel channel loop and lets the compiler vectorise conversion. + let span = tr.width() as usize * n; + for y in tr.y0..tr.y1 { + let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + let src = &data[o..o + span]; + let bytes = sample.bytes(); + let dst = &mut t.data[base * bytes..(base + span) * bytes]; + match sample { + SampleType::U8 => { + for (d, v) in dst.iter_mut().zip(src) { + *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + SampleType::U16 => { + for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { + let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + d.copy_from_slice(&q.to_ne_bytes()); + } + } + SampleType::F32 => { + for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { + d.copy_from_slice(&v.to_ne_bytes()); + } + } + } + } +} + +/// Converts the tiles of a region write. A tile holds no state another tile reads, so the tiles +/// are converted on all cores; each writes only its own bytes, in the same arithmetic. +#[cfg(not(target_arch = "wasm32"))] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + use rayon::prelude::*; + tiles.par_iter_mut().for_each(|(tc, t)| write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample)); +} + +/// Serial counterpart, for the web build (there are no threads to spread tiles over). +#[cfg(target_arch = "wasm32")] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + for (tc, t) in tiles.iter_mut() { + write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample); + } +} + impl Surface { /// Reads a region into a newly allocated buffer, returning an error instead of aborting if /// the requested pixel buffer cannot be represented or reserved. @@ -579,38 +628,13 @@ impl Surface { let w = r.width() as usize; assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); let sample = self.format.sample; - for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); - // Convert contiguous channel samples a row at a time. Dispatching on depth here - // removes the per-pixel channel loop and lets the compiler vectorise conversion. - let span = tr.width() as usize * n; - for y in tr.y0..tr.y1 { - let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - let src = &data[o..o + span]; - let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; - match sample { - SampleType::U8 => { - for (d, v) in dst.iter_mut().zip(src) { - *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - } - } - SampleType::U16 => { - for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { - let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; - d.copy_from_slice(&q.to_ne_bytes()); - } - } - SampleType::F32 => { - for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { - d.copy_from_slice(&v.to_ne_bytes()); - } - } - } - } - } + // Each tile's own span of `data` is converted on its own: the tiles are disjoint, so + // converting them in any order (and on any thread) writes exactly the same bytes. + // `take_tiles` reserves the whole result before removing anything, and gives the missing + // tiles as one shared default-filled tile, copied per tile by `Arc::make_mut` below. + let mut tiles = self.take_tiles(r); + convert_tiles(&mut tiles, r, data, w, n, sample); + self.put_tiles(tiles); } /// Take the tiles covering `r` out of the surface (missing ones filled with the default diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 985f627e90..0c251d1639 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -25,8 +25,10 @@ flate2 = { workspace = true } # line breaking. Native only: the CJK dictionary alone is 3.8 MB of wasm, and the web build must # stay under Cloudflare's 25 MiB per-file cap (packaging/web/package.sh). The web build falls back # to the generic Unicode rules for those scripts. +# Native only: the glyph rasteriser composites and converts its pixel buffers on all cores. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } +rayon = { workspace = true } [build-dependencies] # Deflates the bundled fonts (build.rs); `flate2` above inflates them. diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..5cf6baea12 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -12,6 +12,9 @@ use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; use crate::warp::{Placement, Warp}; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; /// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). @@ -276,35 +279,57 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); - for (i, &cv) in cov.iter().enumerate() { - let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv }; + composite(&mut acc, &cov, w, stride, &comps, a, antialias); + } + // Unpremultiply. + unpremultiply(&mut acc, n, stride, format.sample); + surface.write_region(rect, &acc); + surface.prune(); + Rendered { surface, rect } +} + +/// Composites one colour's coverage over the premultiplied accumulator, resolving `AntiAlias::None` +/// to 0/1 first. A pixel's maths reads and writes only its own slots, so the rows are done in any +/// order — one per thread — for exactly the same bytes. +fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { + let n = comps.len(); + let row = |acc: &mut [f32], cov: &[f32]| { + for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) { + let cv = if antialias == AntiAlias::None { if *cv >= 0.5 { 1.0 } else { 0.0 } } else { *cv }; let s = cv * a; if s <= 0.0 { continue; } - let px = &mut acc[i * stride..(i + 1) * stride]; let keep = 1.0 - s; for j in 0..n { px[j] = comps[j] * s + px[j] * keep; } px[n] = s + px[n] * keep; } - } - // Unpremultiply. - for px in acc.chunks_exact_mut(stride) { + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c)); + #[cfg(target_arch = "wasm32")] + acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c)); +} + +/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone. +fn unpremultiply(acc: &mut [f32], n: usize, stride: usize, sample: SampleType) { + let one = |px: &mut [f32]| { let a = px[n]; if a > 0.0 { for v in &mut px[..n] { let straight = *v / a; // Float text can carry HDR and signed colours. Keep integer output bounded // and retain the old handling of non-finite intermediate values. - *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; + *v = if sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } - } - surface.write_region(rect, &acc); - surface.prune(); - Rendered { surface, rect } + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_exact_mut(stride).for_each(one); + #[cfg(target_arch = "wasm32")] + acc.chunks_exact_mut(stride).for_each(one); } /// One element of a glyph outline in document space (see [`outlines`]). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 894ecf4e4f..c055e1881d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,6 +1,6 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; +use photocraft_doc::text::{AntiAlias, Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; @@ -13,6 +13,38 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +/// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and +/// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them +/// over threads therefore has to leave every byte as it was, which a one-thread pool checks. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_raster_matches_a_single_thread() { + let one = rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("one-thread pool"); + let mut e = TextEngine::new(); + let boxed = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 90.0 }; + let paragraph = TextLayer { + text: "A paragraph box that wraps over several lines and covers many tiles, so the whole raster is exercised.".into(), + font_family: "Inter".into(), + size_pt: 18.0, + shape: boxed, + ..Default::default() + }; + let no_aa = TextLayer { text: "No AA edge".into(), font_family: "Inter".into(), size_pt: 30.0, antialias: AntiAlias::None, ..Default::default() }; + let cases: Vec<(TextLayer, f32, PixelFormat)> = vec![ + (point("Parallel", 48.0), 72.0, PixelFormat::RGBA8), + (styled("Bold italic", CharStyle { size_pt: 40.0, faux_bold: true, faux_italic: true, ..Default::default() }), 300.0, PixelFormat::RGBA8), + (no_aa, 150.0, PixelFormat::RGBA16), + (styled("Wide gamut", CharStyle { size_pt: 36.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }), 96.0, PixelFormat::RGBA32F), + (with_para(paragraph, ParagraphStyle::default()), 144.0, PixelFormat::RGBA8), + ]; + for (layer, dpi, format) in cases { + let (_, spread) = e.render(&layer, dpi, format); + let (_, single) = one.install(|| e.render(&layer, dpi, format)); + assert_eq!(spread.rect, single.rect, "{}", layer.text); + assert_eq!(spread.surface, single.surface, "{}", layer.text); + } +} + #[test] fn raster_preserves_hdr_and_signed_float_text_colors() { let mut e = TextEngine::new(); diff --git a/docs/text-raster-performance.md b/docs/text-raster-performance.md new file mode 100644 index 0000000000..87ccdf849f --- /dev/null +++ b/docs/text-raster-performance.md @@ -0,0 +1,60 @@ +# Text rasterization: what a type render costs + +Measured 2026-10-17 on Windows 11, `i5-10300H` (4 cores / 8 threads, 16 GB), release build, +`rustc 1.98.1`, with a probe that renders one paragraph layer through +`photocraft_text::render::rasterize_warped` and times each stage of it. + +Document: 300 dpi, a 2400 × 1600 px paragraph box holding 4000 characters at 14 pt — the worst +case a type edit has (a whole page of text at print resolution; 1851 laid-out glyphs, 2329 × 1610 px +of ink = 3.75 MP). + +| Stage | ms | What it does | +|---|---:|---| +| ink rectangle (`ink_rect_warped`) | 4.1 | its own pass over every glyph, to size the surface | +| draw (`draw_pass` into `Coverage`) | 18.6–22.9 | glyph outlines, curve flattening (0.03 px tolerance), winding scanlines | +| prefix sum (`Coverage::finish`) | 7.0–8.1 | resolves the winding accumulator to coverage 0…1, one running sum over 3.75 M pixels | +| composite | 22.6–33.4 | each colour's coverage over the premultiplied accumulator | +| unpremultiply | 11.2–14.0 | divides the accumulator back to straight colour | +| `Surface::write_region` | 23.8 | converts to the document format and fills the tiles | +| `Surface::prune` | 3.1 | drops tiles that came back the layer's default | +| **total** | **86–104** | | + +So the glyph geometry — what one would expect to dominate — is under a quarter of it. The rest is +scalar work over the pixel buffers, and every one of those passes is per pixel: a pixel's maths +reads and writes only its own slots. + +## What runs in parallel now + +`composite`, `unpremultiply` and the tile conversion of `write_region` run on all cores (rayon, +native only; the web build keeps the serial loops, since wasm has no threads). Nothing about them +is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels +are identical, not merely close. + +| | before | after | +|---|---:|---:| +| the 3.75 MP case above | 98.3–103.9 ms | 60.3–61.8 ms | + +`crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline +can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) +on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing +region proptests compare `write_region` against its serial reference implementation, tile for tile. + +## What stays on one thread, and why + +- **draw** (19–23 ms): the winding accumulator carries across scanlines and rows — a pixel's value + depends on the rows above it, and one scanline can spill into the next row's first pixel. A band + split can be exact (bands keep absolute coordinates, carry the spill in band order, and are + assembled before the prefix sum), but each band re-traverses every glyph — the traversal is not + free, so the win is bounded by the band count. Worth doing when the draw matters more than it does + now. +- **prefix sum** (7–8 ms): a running f32 sum over the whole image. Splitting it per row needs the + row's starting sum, which is that same sequential chain — the only exactly-known starting sums are + the ones the chain computes. +- **ink rectangle** (4.1 ms): a second full traversal into a bounds sink. Per-glyph bounds reduce by + min/max, which is associative and exact, so this is a candidate for the same treatment as the + stages above (a glyph-range split, not a band split). + +## Related + +Byte-identical output matters here beyond taste: text pixels reach a PSD's saved cache and the +round-trip corpora. `cargo xtask test-corpus` and the Photoshop oracle tests are the gate. From 0c7c53355314b4de3bd27a9762c06489a8056d0f Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 17:44:03 +0300 Subject: [PATCH 0459/1452] Fix rustfmt on baked gradient overlay reopen test. CI Format step failed on multi-line s.execute in layer_style.rs. Co-authored-by: Cursor --- crates/ui-egui/src/layer_style.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 068903b751..8ed53afeec 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -1616,11 +1616,7 @@ mod tests { #[test] fn baked_gradient_overlay_keeps_endpoint_colours_on_reopen() { let mut s = session(); - s.execute( - "layer.layerStyle.gradientOverlay", - json!({"from": "#ff0000", "to": "#0000ff", "method": "classic"}), - ) - .unwrap(); + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "method": "classic"})).unwrap(); let st = s.active().unwrap(); let id = st.active_layer.unwrap(); let layer = st.doc.layer(id).unwrap(); From cb5b1f8e9e13493689af80dcfc1ebbe8106e886e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 16:45:16 +0200 Subject: [PATCH 0460/1452] Crop: in default mode, box Undo/Redo keeps the box in place (#7088) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_mode.rs | 36 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/crop_ui.rs | 12 +++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 676c97dbec..a5669f12c8 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -424,6 +424,42 @@ mod tests { assert!(r.iter().zip([20.0, 30.0, 80.0, 70.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}"); } + /// #7088: the box's own Undo/Redo of a move (or a turn) in default mode puts the frame back + /// and moves the image, not the box: it stays put on screen (Auto Center Preview off) or goes + /// to the middle of the canvas (on). + #[test] + fn box_undo_and_redo_keep_the_box_put() { + for auto in [false, true] { + let mut app = app(); + app.ui.tool_options.crop_shield.auto_center_preview = auto; + with_frame(&mut app); + // A quarter turn, then a move inside: two box steps. + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(370.0, 380.0)], NONE); + let turned = app.ui.crop_rect; + screen_drag(&mut app, &[pos2(370.0, 300.0), pos2(380.0, 305.0), pos2(390.0, 310.0)], NONE); + let moved = app.ui.crop_rect; + assert_ne!(moved, turned); + let want = if auto { pos2(400.0, 300.0) } else { pos2(370.0, 300.0) }; + let check = |app: &PhotocraftApp, what: &str| { + let q = screen_box(app); + assert_upright(q); + assert!(mid_of(q).distance(want) < 0.05, "auto={auto} {what}: the box moved on screen to {:?}", mid_of(q)); + }; + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.ui.crop_rect, turned, "auto={auto}: the move is undone"); + check(&app, "undo move"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.ui.crop_rect, Some([40.0, 30.0, 100.0, 70.0])); + assert!(crate::crop_ui::angle(&app).abs() < 1e-9, "the turn is undone"); + check(&app, "undo turn"); + app.run("edit.redo", json!({})).unwrap(); + check(&app, "redo turn"); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.ui.crop_rect, moved, "auto={auto}: the move is redone"); + check(&app, "redo move"); + } + } + fn mid_of(q: [Pos2; 4]) -> Pos2 { pos2((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0) } diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..0081f463b0 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -462,7 +462,19 @@ fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { } else { app.crop.redo.push(now); } + // Default mode: the box stays put on screen, so the view follows the frame's centre (#7088). + if let (true, Some(a), Some(b)) = (crate::crop_mode::moves_image(app), now.rect, s.rect) { + let (a, b) = (center(a), center(b)); + let d = [(b[0] - a[0]) as f32, (b[1] - a[1]) as f32]; + if let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) { + let c = [v.center[0] + d[0], v.center[1] + d[1]]; + if c.iter().all(|x| x.is_finite()) { + v.center = c; + } + } + } crate::crop_mode::sync_frame(app); + crate::crop_mode::auto_center(app); serde_json::Value::Null } From 45e15036bf301db855cc036dc1fc1c1d8a3df547 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 07:44:10 -0700 Subject: [PATCH 0461/1452] Presets: the damaged-set test also covers Export As presets a_damaged_preset_set_keeps_the_other_sets (#5602) checks that one damaged set in the preferences file does not discard the others. It left out export_presets, which #5344 added and #6906 put back into load_presets_json, and recent_documents. The test now saves an Export As preset and checks that it survives each damaged set, recent_documents included. It also damages export_presets and documents on their own, and gives a null set beside the Export As presets. This is brandongunderson's change to the test in #6479, unchanged. Co-authored-by: brandongunderson <22452783+brandongunderson@users.noreply.github.com> --- crates/engine/src/presets/tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b7c0a3ede2..7a29a03a9a 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -758,19 +758,36 @@ fn a_damaged_preset_set_keeps_the_other_sets() { let mut s = Session::new(); let settings = json!({"width": 1600, "height": 900, "resolution": 144}); s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); let saved = s.presets.to_json(&s); - for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] { + for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "recent_documents", "custom_shapes", "layer_defaults", "swatches"] { let mut blob = saved.clone(); blob[key] = json!("not a preset set"); let mut next = Session::new(); next.load_presets_json(blob); assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`"); + assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`"); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); } + let mut blob = saved.clone(); + blob["export_presets"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.export_presets.is_empty()); + assert_eq!(next.presets.documents, s.presets.documents); + let mut blob = saved.clone(); + blob["documents"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.documents.is_empty()); + assert_eq!(next.presets.export_presets, s.presets.export_presets); // Null sets keep their defaults, as before. let mut next = Session::new(); - next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()})); + next.load_presets_json( + json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone(), "export_presets": saved["export_presets"].clone()}), + ); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len()); assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); assert_eq!(next.presets.documents, s.presets.documents); + assert_eq!(next.presets.export_presets, s.presets.export_presets); } From a32b588ae69900bb9d7158cb8619d75ef2e46672 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 16:54:58 +0200 Subject: [PATCH 0462/1452] Crop / Canvas Size: extending keeps the Background's hidden pixels (#7086) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 111 +++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..472d458141 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -328,19 +328,55 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi && let LayerContent::Raster(surf) = &mut bg.content { let old = old_canvas.translate(dx, dy); - let fmt = surf.format(); - let px = photocraft_raster::from_rgba(&fmt, [extension[0], extension[1], extension[2], 1.0]); - let keep = surf.content_bounds().intersect(&old); - let saved = surf.to_interleaved(keep); - surf.fill_rect(Rect::from_xywh(0, 0, r.width(), r.height()), &px); - if !keep.is_empty() { - surf.write_interleaved(keep, &saved); - } + let px = photocraft_raster::from_rgba(&surf.format(), [extension[0], extension[1], extension[2], 1.0]); + fill_background_extension(surf, Rect::from_xywh(0, 0, r.width(), r.height()), old, &px); } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); Ok(()) } +/// Fills the area an extension adds to the Background's `canvas` (all of it outside the `old` +/// canvas, and any gap there) with `px`, under the pixels the Background already holds there: +/// those a crop with Delete Cropped Pixels off hid show again instead of being painted over (#7086). +/// Only the added strips are touched, so the cost follows the added area, not the canvas. +fn fill_background_extension(surf: &mut Surface, canvas: Rect, old: Rect, px: &[f32]) { + let keep = surf.content_bounds().intersect(&old).intersect(&canvas); + let strips = if keep.is_empty() { + vec![canvas] + } else { + vec![ + Rect::new(canvas.x0, canvas.y0, canvas.x1, keep.y0), + Rect::new(canvas.x0, keep.y1, canvas.x1, canvas.y1), + Rect::new(canvas.x0, keep.y0, keep.x0, keep.y1), + Rect::new(keep.x1, keep.y0, canvas.x1, keep.y1), + ] + }; + let n = surf.channels(); + let a = n.saturating_sub(1); + let pa = px.get(a).copied().unwrap_or(1.0); + // Without an alpha channel hidden pixels can't be told from empty ones: fill the strips. + let has_alpha = surf.format().alpha; + for strip in strips.into_iter().filter(|r| !r.is_empty()) { + if !has_alpha || !surf.has_tiles_in(strip) { + surf.fill_rect(strip, px); + continue; + } + // The Background's own pixels over `px` (source-over). + let mut d = surf.read_region(strip); + for p in d.chunks_exact_mut(n) { + let ta = p.get(a).copied().unwrap_or(0.0); + let oa = ta + pa * (1.0 - ta); + for (c, &f) in p.iter_mut().zip(px).take(a) { + *c = if oa > 0.0 { (*c * ta + f * pa * (1.0 - ta)) / oa } else { 0.0 }; + } + if let Some(alpha) = p.get_mut(a) { + *alpha = oa; + } + } + surf.write_region(strip, &d); + } +} + /// The nine documented canvas-size anchors, in the order the command lists them, each mapped to /// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge). const ANCHORS: [(&str, (f64, f64)); 9] = [ @@ -414,14 +450,8 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { && let Some(bg) = doc.background_mut() && let LayerContent::Raster(surf) = &mut bg.content { - let fmt = surf.format(); - let px = photocraft_raster::from_rgba(&fmt, ext); - let keep = surf.content_bounds().intersect(&old); - let saved = surf.to_interleaved(keep); - surf.fill_rect(canvas, &px); - if !keep.is_empty() { - surf.write_interleaved(keep, &saved); - } + let px = photocraft_raster::from_rgba(&surf.format(), ext); + fill_background_extension(surf, canvas, old, &px); } Ok(()) })?; @@ -1170,6 +1200,55 @@ mod tests { assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]); } + /// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the + /// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over + /// with the background colour (#7086). + #[test] + fn extending_the_canvas_keeps_the_backgrounds_hidden_pixels() { + for depth in [8, 16, 32] { + let hidden = |s: &mut Session| { + s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.background_mut().unwrap(); + bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 50, 100), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 50, "y": 0, "width": 50, "height": 100, "deleteCroppedPixels": false})).unwrap(); + s.tools.background = [0.0, 0.0, 0.0, 1.0]; + }; + let mut s = Session::new(); + hidden(&mut s); + s.execute("image.revealAll", json!({})).unwrap(); + let surf = doc(&s).background().unwrap().surface().unwrap(); + assert_eq!(doc(&s).size, Size::new(100, 100), "{depth}-bit"); + assert_eq!(surf.rgba(10, 50), [1.0, 0.0, 0.0, 1.0], "{depth}-bit: Reveal All shows the hidden red half"); + assert_eq!(surf.rgba(80, 50), [1.0, 1.0, 1.0, 1.0], "{depth}-bit"); + + // Canvas Size wider than the hidden pixels: they show, the rest takes the colour. + let mut s = Session::new(); + hidden(&mut s); + s.execute("image.canvasSize", json!({"width": 120, "height": 100, "anchor": "right"})).unwrap(); + let surf = doc(&s).background().unwrap().surface().unwrap(); + assert_eq!(surf.rgba(5, 50), [0.0, 0.0, 0.0, 1.0], "{depth}-bit: past the hidden pixels the background colour"); + assert_eq!(surf.rgba(30, 50), [1.0, 0.0, 0.0, 1.0], "{depth}-bit: Canvas Size shows the hidden red half"); + assert_eq!(surf.rgba(100, 50), [1.0, 1.0, 1.0, 1.0], "{depth}-bit"); + } + } + + /// A Background without an alpha channel (a Grayscale PSD import) has no hidden pixels to tell + /// from empty ones: the added area takes the colour, the kept area stays (#7086). + #[test] + fn an_alpha_less_background_extension_takes_the_colour() { + let mut surf = Surface::new(photocraft_color::PixelFormat::GRAY8); + surf.fill_rect(Rect::new(-50, 0, 100, 100), &[0.6]); + let kept = surf.pixel(50, 50); + fill_background_extension(&mut surf, Rect::new(-60, 0, 100, 100), Rect::new(0, 0, 100, 100), &[0.0]); + assert_eq!(surf.pixel(-55, 50), vec![0.0], "past the old pixels the colour"); + assert_eq!(surf.pixel(-30, 50), vec![0.0], "no alpha: the added area takes the colour"); + assert_eq!(surf.pixel(50, 50), kept, "the kept area is unchanged"); + } + /// A document (`mode`, `depth`) with a white Background and a layer holding a red pixel at /// (12, 8) and a blue one at (30, 3). fn marked(mode: &str, depth: u32) -> Session { From fcea66298994baf485816a29a2e23f6f591fa348 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 11:07:30 -0400 Subject: [PATCH 0463/1452] style: sort touch_twins module per rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f66ee2e24e..ac46cf3f4d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,12 +214,12 @@ mod timeline_ui; mod titlebar; pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; -pub mod touch_twins; mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; mod tool_shortcuts; +pub mod touch_twins; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; From 3d53024a999cb7f1bbd82276e32d8bd2f945ebb3 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 11:16:45 -0400 Subject: [PATCH 0464/1452] GPU canvas: test layer-page admission under a zero budget (#6563) A paged render with a zero resident budget evicts the previous cell's pages as each new page is created, stays on the GPU and matches the CPU compositor. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/tests/parity.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..02184f286f 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2571,6 +2571,38 @@ fn pages_are_evicted_under_the_budget() { check(&mut g, &d, "after eviction"); } +/// #6563: a page texture that doesn't fit the budget is admitted by first dropping the least +/// recently used pages of other cells (never the current cell's). With a zero budget every page +/// created in a cell evicts the previous cell's pages, and the refresh still stays on the GPU +/// (budget pressure alone never ends it on the CPU) and matches the CPU compositor. +#[test] +fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(600, 500); + d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 61, 0.0)); + let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 62, 0.2); + top.blend = BlendMode::Multiply; + d.layers.push(top); + // Exercise page residency itself: lower-stack checkpoints could skip binding the pages. + g.paged.set_composite_cache_budget(0); + check(&mut g, &d, "warm"); + let all = g.paged.resident_bytes(); + g.paged.set_resident_budget(0); + for round in 0..2 { + let cpu = photocraft_compose::flatten(&d); + let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()) + .unwrap_or_else(|e| panic!("round {round}: a zero budget must not end the refresh on the CPU: {e}")); + let worst = worst_diff(&cpu.px, &out); + assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1); + assert!(s.cells > 1 && s.evicted > 0, "round {round}: {s:?}"); + // Evicted pages were created again (uploaded), and only the last cell's are left. + assert!(s.tiles_uploaded > 0, "round {round}: {s:?}"); + assert!(g.paged.resident_bytes() < all, "round {round}: {} of {all}", g.paged.resident_bytes()); + d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(40 + round * 250, 60, 90 + round * 250, 120), &[0.2, 0.8, 0.3, 1.0]); + } + check(&mut g, &d, "after zero-budget renders"); +} + #[test] fn the_focused_view_stays_resident_after_a_full_refresh() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); From eab7603eada012e1cb5c636105c80f131de66889 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 11:17:37 -0400 Subject: [PATCH 0465/1452] background_redraw test: check the opacity change starts a redraw, not the perf label after one frame The label is only recorded when the redraw finishes, so after a single step it still read "kept" on slower adapters (FredApps' run on Linux's software adapter). Assert that a redraw is running instead, and that it finished as "background". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/background_redraw_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index 220681fc7f..1676491a67 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -240,11 +240,13 @@ fn showing_a_hidden_layer_again_reuses_the_redraw_from_before_it_was_hidden() { assert!(g.harness.state().redraws.is_empty(), "no redraw: {}", g.harness.state().perf.last_refresh); assert_eq!(g.harness.state().perf.last_refresh, KEPT); assert_matches_synchronous(&mut g, doc); - // A real change composites again. + // A real change composites again. `last_refresh` is only set when that redraw finishes, so + // check that one started, then that it was a composite rather than kept blocks. g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "opacity": 0.3})).expect("opacity"); g.harness.step(); - assert_ne!(g.harness.state().perf.last_refresh, KEPT); + assert!(g.harness.state().redraws.contains_key(&doc), "not served from kept blocks: {}", g.harness.state().perf.last_refresh); settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); assert_matches_synchronous(&mut g, doc); } From 45afd2e9d7c8b6391a1a72e50b1e3bfb07e4e1ba Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:37:12 +0300 Subject: [PATCH 0466/1452] CI: run panic_hunt in the Linux test job The full-registry fuzz (AGENTS.md Rule 9) is #[ignore]d and no workflow ran it, so nothing enforced "must stay green". The new step reruns the Test step's nextest selection with --run-ignored only, filtered to the panic_hunt binary, so nothing rebuilds; the ci profile terminates it if it hangs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4303f0aac4..ba8243353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,16 @@ jobs: cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash + # Every command against adversarial params must return Err, never panic or hang (AGENTS.md + # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as + # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency + # features and recompile the whole graph. The `ci` profile terminates it if it hangs. + - name: Panic hunt + if: runner.os == 'Linux' + run: >- + cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests + --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' + - name: Doctests if: matrix.part == 'all' || matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc From a4fe4ffbed79e6e4a0b15a7f7b24440e61bc2261 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 12 Oct 2026 12:17:23 +0000 Subject: [PATCH 0467/1452] Contributors: add 1Git2Clone Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..30e50bb007 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,6 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.1Git2Clone] +sync_github_name = true From 7414914290259f8a9530b830bb87522783fe7808 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Sat, 17 Oct 2026 17:53:39 +0200 Subject: [PATCH 0468/1452] test(gpu): tile growth rewrites must beat the kept overlap copy Grows a raster layer one tile per render and rewrites everything with per-step noise, on the whole-texture and the paged compositor, comparing each render against the CPU oracle. Fails at 254/255 when keep_overlap does not route the resident through encoder-ordered writes (verified by temporarily dropping new.ordered = true). --- crates/gpu/tests/parity.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..6457f99f7d 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask}; -use photocraft_geom::{Rect, Size}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_gpu::{Compositor, render_to_vec}; /// One 8-bit step, plus a few f32 ulps: two results exactly one step apart can differ by a hair @@ -195,6 +195,30 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) { diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); } +/// Growing a layer's tile footprint keeps the old texels in the overlap (`keep_overlap`) while +/// the same update rewrites tiles inside that overlap (#6243). The keep copy is recorded in the +/// frame's encoder and the changed tiles go through encoder-ordered scratch copies, so the +/// fresh texels must win; if the copy ran after the tile writes (queue writes land before the +/// encoder does), the overlap would keep the previous step's colours. Every step grows by one +/// tile and rewrites everything with per-step noise, on the whole-texture and the paged +/// compositor alike, and each render matches the CPU oracle. +#[test] +fn tile_growth_rewrite_keeps_fresh_texels_in_the_overlap() { + let Some(mut g) = gpu() else { return }; + let tile = TILE_SIZE as u32; + let steps = 4; + let mut doc = Document::new("tile growth", Size::new(steps * tile, tile), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::raster("grow", PixelFormat::RGBA8)); + for step in 1..=steps { + // Wider by one tile than at the last render, so the resident's region grows and every + // tile inside the kept overlap changes: stale texels cannot masquerade as correct ones. + let w = step * tile; + let data: Vec = (0..w * tile * 4).map(|i| rnd(1000 + step, i)).collect(); + doc.layers[0].surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, tile), &data); + check(&mut g, &doc, &format!("tile growth step {step}/{steps}")); + } +} + /// Closing a document frees what the compositor holds for it, however its canvas entry went /// (#4529): `retain_docs` drops the resident pages, effect maps and lower-stack composite of /// documents that aren't open, and keeps the open ones. From f921d9af81685ae6d35dd98ddf9adde6dc4299f9 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 21:25:40 +0530 Subject: [PATCH 0469/1452] test(engine): keep the range test's group expanded Group Layers now creates the group collapsed (#5571), so shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group expands it first, as its name says. The line is from #5605 by @nrq6993-a11y. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/layer_range_panel_order.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/tests/layer_range_panel_order.rs b/crates/engine/tests/layer_range_panel_order.rs index 0e6199d539..d442d35198 100644 --- a/crates/engine/tests/layer_range_panel_order.rs +++ b/crates/engine/tests/layer_range_panel_order.rs @@ -20,6 +20,7 @@ fn shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group() { select(&mut s, b, "replace"); select(&mut s, c, "add"); let group = s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setExpanded", json!({"layer": group, "expanded": true})).unwrap(); let d = s.execute("layer.new.layer", json!({"name": "D"})).unwrap()["layer"].as_u64().unwrap(); for (anchor, target, wanted) in From 074d4b08b6e4121e074d670d8e30fcd802c08805 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 08:53:44 -0700 Subject: [PATCH 0470/1452] Stack Mode: Kurtosis and Skewness give the same bits on Windows and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stat` raised each deviation from the mean to a power with `f32::powi`, with the exponent passed through the moment closure. On x86_64-pc-windows-msvc that call goes to the C runtime's `powf`, whose cubes and fourth powers can differ from repeated multiplication in the last bit. On Linux the same call gives the bits of repeated squaring, as compiler-builtins' `__powisf2` computes it. Kurtosis came out one ULP higher on Windows in two values of the test fixture, and `stack::tests::statistics_are_bit_identical_to_the_serial_implementation` failed there. The moments and Skewness's `sd³` now use `ipow`, which multiplies by repeated squaring: `x·x²` for the cube and `x²·x²` for the fourth power. Results on Linux do not change, and the recorded bits stay as they are. --- crates/algo/src/stack.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index aa7ff9bfa1..512e169c54 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -69,6 +69,23 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { h / n.log2() } +/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the +/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the +/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently. +fn ipow(x: f32, k: u32) -> f32 { + let (mut base, mut k, mut acc) = (x, k, 1.0f32); + loop { + if k & 1 != 0 { + acc *= base; + } + k >>= 1; + if k == 0 { + return acc; + } + base *= base; + } +} + fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { let n = v.len() as f32; if v.is_empty() { @@ -97,7 +114,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { _ => {} } let mean = v.iter().sum::() / n; - let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::() / n; + let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::() / n; match s { Stat::Mean => mean, Stat::Variance => m(2), @@ -105,7 +122,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { Stat::Skewness => { let sd = m(2).sqrt(); // Map −2..2 to 0..1 (0.5 = symmetric). - if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) } + if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) } } Stat::Kurtosis => { let var = m(2); @@ -457,4 +474,16 @@ mod tests { fn no_layers_gives_an_empty_result() { assert!(combine(&[], Stat::Median).is_empty()); } + + /// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power. + #[test] + fn ipow_squares_and_multiplies() { + for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] { + assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits()); + assert_eq!(ipow(x, 1).to_bits(), x.to_bits()); + assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits()); + assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits()); + assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits()); + } + } } From c537033b4df12396e58f074602bb00f9709e35dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 07:15:07 -0700 Subject: [PATCH 0471/1452] Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace Double-clicking a Liquify smart filter in the Layers panel opened the generic parameter form, whose only field is Mesh Size, instead of the Liquify workspace. OK on that form wrote the new mesh size into the filter and replayed the recorded strokes on a different mesh. Filter > Liquify always opened with no strokes, so the recorded strokes could not be edited again. smart_ui::open_editor now sends a Liquify smart filter to the new liquify_ui::open_smart_filter, as it does for Camera Raw and the Filter Gallery. The workspace opens on the smart object's pixels below that filter, with the filter's recorded strokes and mesh size. OK runs layer.smartFilter.setParams with the strokes and the mesh size, so the filter is updated in one history step and no second Liquify filter is added. OK after Restore All leaves the filter with no strokes. Cancel leaves the filter unchanged. --- crates/ui-egui/src/liquify_ui.rs | 143 ++++++++++++++++++++++++++++++- crates/ui-egui/src/smart_ui.rs | 8 ++ 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..8a5efb284b 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -158,6 +158,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]> { @@ -194,6 +196,7 @@ impl LiquifyDialog { "dabMs": self.dab_ms, "fullResolutionPreview": self.view_bounds.is_some(), "previewMs": self.preview_ms, + "smartFilter": self.smart_filter, }) } @@ -450,10 +453,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]; @@ -462,8 +497,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, @@ -488,6 +523,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. @@ -514,11 +550,17 @@ pub fn cancel(app: &mut PhotocraftApp) { } } -/// OK: runs `filter.liquify` with the recorded strokes (one history step). +/// 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). pub fn commit(app: &mut PhotocraftApp) { let Some(mut d) = app.distort.liquify.take() else { return }; remember(app, &d.opts); d.end(); + if let Some(index) = d.smart_filter { + let params = json!({"strokes": d.strokes, "meshSize": d.cell}); + let _ = app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})); + return; + } if d.strokes.is_empty() { return; } @@ -1434,4 +1476,97 @@ 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()); + } } diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 7f49843c53..b6783b8fc4 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -295,6 +295,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) { From 2240a9cb05516b2959e9d96db619f143709cf1d6 Mon Sep 17 00:00:00 2001 From: Jazz1-6 Date: Sat, 17 Oct 2026 21:40:34 +0530 Subject: [PATCH 0472/1452] docs: fix subject-verb agreement in downloads section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 973fb5330d..f108293053 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi **New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it. -**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows From 3bca0123b04f46f3092b348005b34dac2e9274a7 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:12:29 +0200 Subject: [PATCH 0473/1452] Type: the blink-phase test finds the caret by its strokes arrow_keys_reset_caret_blink_phase (#5512) looked for a 1.5-wide line, the old caret. The caret is now CARET_STROKES (a 3 pt white halo and a 1 pt black line), so the test matches those. --- crates/ui-egui/src/type_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 88119f917f..44c0c9b033 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2732,7 +2732,7 @@ mod tests { draw_overlay(app, &painter, &xf); }); out.textures_delta.clear(); - out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if (stroke.width - 1.5).abs() < 1e-4)) + out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if CARET_STROKES.contains(stroke))) }; // Before resetting, the caret is in the off phase at t = 10.5 s (10500 / 530 = 19, odd phase) From 263dd041a6fd9655f27b311a8c5c6805ade135e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 09:02:11 -0700 Subject: [PATCH 0474/1452] Type: register craft fonts of every script, not only the Japanese ones build.rs embeds every font in craft-fonts' fonts/manifest.txt, but FontDb::new registered only the entries whose scripts include Jpan. craft-fonts 8dcdacd5 adds Noto Sans CJK SC and Noto Sans Arabic, and 3d9ab3a1 adds Noto Sans Thai. Built with either checkout, the binary carried those fonts, but the font list did not show them, text layers did not fall back to them, and works_without_craft_fonts failed with "Noto Sans CJK SC registered under its manifest name". Chinese and Arabic set in Inter in a TextEngine::new() engine drew .notdef glyphs. FontDb::new now registers every CRAFT_FONTS entry. A single-face file registers under its manifest family, whatever family its name table gives; each face of a collection keeps the family its name table gives. With Noto Sans CJK SC built in and no Simplified Chinese system font, Japanese text set in Inter takes Noto Sans CJK SC when the UI locale is not Japanese, following cjk::script_order. craft_fonts_cover_japanese_without_system_fonts now expects the first CJK craft family in that order. New tests register in-memory craft entries made from Inter (Hans, Arab and Jpan entries, two whose name tables give another family, and a collection), and lay out Chinese, Arabic and Thai in Inter when the build has a craft font for the script. --- crates/text/src/craft_fonts.rs | 9 ++-- crates/text/src/font_names_tests.rs | 65 ++++++++++++++++++++++++++++- crates/text/src/fonts.rs | 30 ++++++++----- crates/text/src/lib.rs | 3 +- crates/text/src/tests.rs | 32 ++++++++++++-- 5 files changed, 120 insertions(+), 19 deletions(-) diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index c1c1949ea9..ed24a42035 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -1,9 +1,12 @@ //! Fonts from the optional craft-fonts build input (https://github.com/storytold/craft-fonts). //! //! `CRAFT_FONTS` is empty unless the app was built with `CRAFT_FONTS_DIR=` -//! (see `build.rs`); every user of it must work when it is empty. Today it carries the Japanese -//! fonts: BIZ UDPGothic (UI) and Shippori Mincho / BIZ UDMincho (serif document text). The web -//! build (wasm32) embeds none of them: they don't fit its size cap (see `build.rs`). +//! (see `build.rs`); every user of it must work when it is empty. It carries every font in the +//! checkout's `fonts/manifest.txt`, whatever its scripts: the Japanese fonts BIZ UDPGothic (UI) and +//! Shippori Mincho / BIZ UDMincho (serif document text) and, in newer checkouts, fonts for other +//! scripts such as Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai. +//! [`crate::fonts::FontDb::new`] registers all of them. The web build (wasm32) embeds none of +//! them: they don't fit its size cap (see `build.rs`). /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { diff --git a/crates/text/src/font_names_tests.rs b/crates/text/src/font_names_tests.rs index 0de4bf2445..8b8bcd419c 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -124,7 +124,7 @@ fn collection_isolation_preserves_requested_face() { let blob = Blob::new(Arc::new(bytes)); assert!(isolated_face(blob.clone(), 2).is_none()); let mut db = FontDb::new(); - db.register_blob(blob.clone()); + db.register_blob(blob.clone(), None); let mut style = CharStyle { font_family: "Numeric Test".into(), font_style: "30".into(), ..Default::default() }; db.select_named_face(&mut style); assert_eq!(db.faces(&style.font_family)[0].postscript_name.as_deref(), Some("Collection30")); @@ -188,3 +188,66 @@ fn localized_family_names_map_english_families_to_native_names() { assert!(!local.contains_key(DEFAULT_FAMILY)); assert!(local.values().all(|v| !v.is_empty())); } + +/// A craft-fonts manifest entry for `bytes`, as `build.rs` writes one per manifest line. +fn craft(family: &'static str, scripts: &'static [&'static str], bytes: Vec) -> crate::CraftFont { + crate::CraftFont { family, style: "Regular", scripts, bytes: bytes.leak() } +} + +/// A TrueType collection of single-face fonts. +fn collection(faces: &[Vec]) -> Vec { + let mut bytes = b"ttcf\0\x01\0\0".to_vec(); + bytes.extend((faces.len() as u32).to_be_bytes()); + let mut start = 12 + 4 * faces.len(); + for face in faces { + bytes.extend((start as u32).to_be_bytes()); + start += face.len(); + } + for face in faces { + let start = bytes.len(); + bytes.extend(face); + let count = u16::from_be_bytes(bytes[start + 4..start + 6].try_into().unwrap()) as usize; + for i in 0..count { + let r = start + 12 + i * 16 + 8; + let offset = u32::from_be_bytes(bytes[r..r + 4].try_into().unwrap()) + start as u32; + bytes[r..r + 4].copy_from_slice(&offset.to_be_bytes()); + } + } + bytes +} + +/// Single-face craft fonts of every script register under their manifest family names, whatever +/// family their `name` tables give, and the ones the fallback lists name are in the fallback +/// stack. The Hans and Arab families are fallback-list names, and no craft-fonts manifest uses +/// any of the three, so `FontDb::new` does not register them from the build's own craft fonts. +#[test] +fn craft_fonts_of_every_script_register_under_their_manifest_families() { + let fonts = [ + craft("WenQuanYi Micro Hei", &["Hans", "Latn"], localized_font(&INTER_REGULAR, "WenQuanYi Micro Hei", "WenQuanYi Micro Hei")), + // The `name` tables of these two give the family "Numeric Test". + craft("Geeza Pro", &["Arab"], numeric_font(&INTER_REGULAR, "20", "CraftTestArabic")), + craft("Craft Test Gothic", &["Jpan", "Latn"], numeric_font(&INTER_SEMIBOLD, "30", "CraftTestGothic")), + ]; + let mut db = FontDb::new(); + assert!(fonts.iter().all(|f| !db.has_family(f.family))); + db.register_craft_fonts(&fonts); + let families = db.families(); + for f in &fonts { + assert!(families.iter().any(|n| n == f.family), "{} registered under its manifest family: {families:?}", f.family); + } + assert!(!db.has_family("Numeric Test"), "{families:?}"); + for family in ["WenQuanYi Micro Hei", "Geeza Pro"] { + assert!(db.fallback_stack().any(|f| f == family), "{family} in {:?}", db.fallback_stack().collect::>()); + } +} + +/// Each face of a craft font collection keeps the family its `name` table gives. +#[test] +fn craft_font_collections_keep_their_face_families() { + let faces = [localized_font(&INTER_REGULAR, "Craft Test A", "クラフト A"), localized_font(&INTER_SEMIBOLD, "Craft Test B", "クラフト B")]; + let fonts = [craft("Craft Test Collection", &["Jpan", "Latn"], collection(&faces))]; + let mut db = FontDb::new(); + db.register_craft_fonts(&fonts); + assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families()); + assert!(!db.has_family("Craft Test Collection")); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 756ce87455..6de5d9086d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1,6 +1,6 @@ //! Font database: bundled fonts (always available, also on the web), the optional craft-fonts -//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system -//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux), +//! fonts of every script ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional +//! system fonts found by scanning platform directories (plus Fontconfig's file list on Linux), //! user-registered font data, and PostScript-name lookup for PSD import. use std::collections::HashMap; @@ -223,14 +223,22 @@ impl FontDb { db.register_static_font(bytes.as_slice()); } // The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on - // wasm32), before any system font. - for f in crate::craft_fonts::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) { - db.register_static_font(f.bytes); - } + // wasm32), every script, before any system font. + db.register_craft_fonts(crate::craft_fonts::CRAFT_FONTS); db.refresh_generics(); db } + /// Registers craft-fonts manifest entries of every script. A single-face file registers under + /// its manifest family, whatever family its `name` table gives. Each face of a collection + /// (TTC/OTC) keeps the family its `name` table gives. + fn register_craft_fonts(&mut self, fonts: &[crate::CraftFont]) { + for f in fonts { + let family = (face_count(f.bytes) == 1).then_some(f.family); + self.register_blob(Blob::new(Arc::new(f.bytes)), family); + } + } + /// Bundled fonts plus the fonts installed on this machine (no-op on the web). pub fn with_system_fonts() -> Self { let mut db = Self::new(); @@ -273,16 +281,18 @@ impl FontDb { /// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added. pub fn register_font_data(&mut self, bytes: Vec) -> Vec { - self.register_blob(Blob::new(Arc::new(bytes))) + self.register_blob(Blob::new(Arc::new(bytes)), None) } /// Registers embedded font data without copying it. fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec { - self.register_blob(Blob::new(Arc::new(bytes))) + self.register_blob(Blob::new(Arc::new(bytes)), None) } - fn register_blob(&mut self, blob: Blob) -> Vec { - let added = self.fcx.collection.register_fonts(blob, None); + /// Registers every face in `blob`, under `family` when given, else under the family each + /// face's `name` table gives. + fn register_blob(&mut self, blob: Blob, family: Option<&str>) -> Vec { + let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() })); let mut names = Vec::new(); for (id, _) in added { if let Some(n) = self.fcx.collection.family_name(id) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index af4de19f52..929f8841cc 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -4,7 +4,8 @@ //! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list, //! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional //! craft-fonts input -//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks. +//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its fonts of every script (a single-face +//! file under its manifest family name); the ones the fallback lists name are fallbacks. //! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking), //! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules. //! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 894ecf4e4f..aa31b00bb5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1516,10 +1516,17 @@ fn craft_fonts_cover_japanese_without_system_fonts() { let l = e.layout(&point(text, 24.0), 72.0); assert_eq!(l.glyphs.len(), text.chars().count()); assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts"); - // Sans (Inter) runs fall back to the Gothic UI family. - assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]); - // Serif runs fall back to a Mincho face when the build has one (not the web build). - if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) { + // Sans (Inter) runs fall back to the first CJK craft family in the UI locale's script order: + // the Gothic UI family when Japanese leads that order or the build has no other CJK craft font. + let cjk = |f: &crate::CraftFont| f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore")); + let first = e.fonts.fallback_stack().find_map(|fam| crate::CRAFT_FONTS.iter().find(|f| f.family == fam && cjk(f))).map(|f| f.family); + if !crate::CRAFT_FONTS.iter().any(|f| cjk(f) && !f.is_japanese()) || crate::cjk::ui_script_order()[0] == crate::cjk::CjkScript::Japanese { + assert_eq!(first, Some(crate::craft_fonts::UI_JAPANESE_FAMILY)); + } + assert_eq!(craft_family_of(&l), first.into_iter().collect::>()); + // Serif runs fall back to a Mincho face when the build has one (not the web build) and the + // Japanese craft fonts come first. + if first == Some(crate::craft_fonts::UI_JAPANESE_FAMILY) && crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) { let mut t = point(text, 24.0); t.font_family = "Times New Roman".into(); let l = e.layout(&t, 72.0); @@ -1532,6 +1539,23 @@ fn craft_fonts_cover_japanese_without_system_fonts() { assert!(craft_family_of(&l).is_empty()); } +/// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the +/// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts). +#[test] +fn craft_fonts_cover_their_other_scripts_without_system_fonts() { + let mut e = TextEngine::new(); + for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] { + if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) { + eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + continue; + } + let l = e.layout(&point(text, 24.0), 72.0); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{script}: no .notdef with craft-fonts"); + let fams = craft_family_of(&l); + assert!(fams.iter().any(|fam| crate::CRAFT_FONTS.iter().any(|f| f.family == *fam && f.scripts.contains(&script))), "{script}: drawn with {fams:?}"); + } +} + #[test] fn works_without_craft_fonts() { // Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics From ceb826e27097c584f825ba17e048b43760e63b52 Mon Sep 17 00:00:00 2001 From: denstu473 <63150090+denstu473@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:30:57 +0200 Subject: [PATCH 0475/1452] fix(atn): map Apply Layer Comp steps by name (#7101) --- crates/engine/src/atn_cmds.rs | 15 +++++ crates/engine/src/atn_cmds/tests.rs | 100 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 7 +- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..d2e498eb45 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1957,6 +1957,20 @@ fn duplicate_to(id: &str, p: &Value) -> Option { Some(d) } +/// Only a comp name in the active document is portable across action playback. +fn apply_comp_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null") { + Some([ReferenceItem::Name { class, name }]) if trimmed(&class.class_id) == "compsClass" => { + step("layerComp.apply", json!({"comp": name.to_string_lossy()})) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn apply_comp_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))) +} + fn play_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { Some(Target::Name(n)) => step("actions.play", json!({"action": n})), @@ -2914,6 +2928,7 @@ const EVENTS: &[Event] = &[ ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, + Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..638929d17a 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -66,6 +66,105 @@ fn sample() -> AtnFile { } } +#[test] +fn layer_comp_apply_imports_plays_and_undoes() { + let name = "合成 𠮷"; + let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(desc("null").with("null", name_ref("compsClass", name))))])]); + for depth in [8, 16, 32] { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + assert_eq!(s.actions.list[0].steps, [("layerComp.apply".into(), json!({"comp": name}))]); + + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + s.execute("layerComp.new", json!({"name": "Other"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains(name), "{played}"); + assert_eq!(*s.active().unwrap().doc, *before, "a missing comp must not apply another comp"); + + let comp = s.execute("layerComp.new", json!({"name": name})).unwrap()["comp"].clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.layer(layer).unwrap().visible); + assert_eq!(json!(s.active().unwrap().doc.last_applied_comp), comp); + assert!(s.undo()); + assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible); + assert!(s.redo()); + assert!(s.active().unwrap().doc.layer(layer).unwrap().visible); + } +} + +#[test] +fn layer_comp_apply_round_trips_by_name() { + for name in ["Comp 1", "合成 𠮷"] { + let descriptor = desc("null").with("null", name_ref("compsClass", name)); + let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(descriptor.clone()))])]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items[0].event, AtnEvent::Name("applyComp".into())); + assert_eq!(file.actions[0].items[0].descriptor, Some(descriptor)); + let mut reloaded = Session::new(); + reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(reloaded.actions.list, s.actions.list); + } + // Local comp ids and the default comp cannot be resolved to a portable name at export. + for params in [json!({}), json!({"comp": 1}), json!({"comp": null}), json!({"comp": true}), json!({"comp": []})] { + assert!(export_item("layerComp.apply", ¶ms, "Comps").is_none(), "{params}"); + } +} + +#[test] +fn layer_comp_apply_unknown_references_are_kept_and_stop_playback() { + let references = [ + None, + Some(D::Integer(1)), + Some(D::Reference(vec![])), + Some(name_ref("Lyr ", "Comp 1")), + Some(target_ref("compsClass")), + Some(class_ref("compsClass")), + Some(D::Reference(vec![ReferenceItem::Index { class: class("compsClass"), index: 1 }])), + Some(D::Reference(vec![ReferenceItem::Identifier { class: class("compsClass"), id: 1 }])), + Some(D::Reference(vec![ + ReferenceItem::Name { class: class("compsClass"), name: UnicodeString::new_nul("Comp 1") }, + ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }, + ])), + ]; + let items: Vec<_> = + references.into_iter().map(|reference| item("applyComp", "Apply Layer Comp", reference.map(|r| desc("null").with("null", r)))).collect(); + let data = set_of("Comps", vec![("Unknown", items.clone())]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), items.len()); + assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == PHOTOSHOP_STEP)); + assert!(s.actions.list[0].disabled.is_empty()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layerComp.new", json!({"name": "Comp 1"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let played = s.execute("actions.play", json!({"action": "Unknown"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("Apply Layer Comp"), "{played}"); + assert_eq!(*s.active().unwrap().doc, *before); + let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), items.len()); + for (saved, original) in file.actions[0].items.iter().zip(&items) { + assert_eq!(saved.event, original.event); + assert_eq!(saved.descriptor, original.descriptor); + } +} + #[test] fn load_maps_events_keeps_flags_and_plays() { let mut s = Session::new(); @@ -321,6 +420,7 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.bitmap", json!({"method": "threshold"})), ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), + ("layerComp.apply", json!({"comp": "Comp 1"})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..dae3057f7b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-17 · **Change:** minor (Apply Layer Comp action mapping, #7101; earlier: earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -131,7 +131,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..089b8f6818 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Apply Layer Comp action mapping, #7101; earlier: G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -90,6 +90,10 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **Done 2026-10-17 (#7101):** `.atn` Apply Layer Comp steps with a named `compsClass` + reference import as `layerComp.apply` and export to the same Photoshop event. Names resolve + in the document being played; missing comps stop playback. Other reference forms stay kept + Photoshop steps and are reported as unsupported. AUTO-219-2 remains partial. - **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. @@ -270,6 +274,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | G11: `.atn` Apply Layer Comp imports, plays and exports by comp name (#7101); other reference forms remain unsupported | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 8c1244981911217896ad29e5b8bba290470c86b7 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 10:43:09 -0600 Subject: [PATCH 0476/1452] Save: keep the replaced file's metadata (#6603, #7073, #7094, #7095, #7096) Saving writes a temporary file and renames it over the target, so everything attached to the old file went with the file that was unlinked. Several of those are security decisions, so a save quietly undid them: a download lost its Mark of the Web, an explicit ACL fell back to whatever the folder hands down. New crates/format/src/atomic/meta.rs, reached from atomic_write through two AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new and defaults to rename, so the failure-injecting test file systems are unchanged): - Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine (the Mark of the Web's macOS counterpart) and Linux user.* all ride along. Attributes the system owns refuse to be written and are skipped; com.apple.decmpfs is skipped deliberately, since it describes storage the new bytes do not have. - macOS ACL (#7073): a new macOS-only exacl dependency (MIT). fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is APFS-only and would mean writing every save as clone-then-truncate; the reasoning is in docs/decisions.md. A file with no ACL is left alone. - Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT, no dependencies, a safe wrapper; the workspace forbids writing the FFI call here). One call keeps the destination's DACL, its named streams including Zone.Identifier, and its creation time. - Automation write path (#7095): AuthorizedWorkspace::write copies the replaced file's attributes through the two cap-std descriptors, never a path, so a request path still cannot escape the write root. Permissions there remain #4963; ReplaceFileW is path-based and so is not used on that path. The order inside copy_metadata matters: attributes and ACL first, permission bits last. Writing an attribute needs the destination open for writing, so copying a 0444 mode onto the temporary file first locks you out of your own temporary file. Covered by a regression test. Atomicity is not weakened. The metadata step runs on the temporary file before the swap, never touches bytes, and never fails a save. Windows is the one place the swap itself changed, and a backup file name is passed because it is a correctness requirement, not an option: ReplaceFileW without one documents ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the replacement under its temporary name, which is a failed save that loses the user's document. With a backup name the same failure leaves both files alone. ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is unwound by hand; every other error falls back to the plain rename, i.e. to the previous behaviour. THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream, icacls output, creation time -- have never executed. They do compile and pass clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac gets. CI's Windows job is the first real run, and #7094 in particular (Mark of the Web surviving a replace) is the whole point of the change and is the part no one has observed working. Tested on macOS: a real chmod +a entry and a real Finder tag both survive a save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB attribute value, a read-only file, the mode, and the automation path. Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is not reported by listxattr, so it needs its own step; not part of these issues), and macOS creation time is still reset by a save (that half of #6603 needs setattrlist, which has no safe wrapper here). --- Cargo.lock | 41 ++-- crates/automation/Cargo.toml | 4 + crates/automation/src/workspace.rs | 47 +++- crates/format/Cargo.toml | 11 + crates/format/src/atomic.rs | 43 +++- crates/format/src/atomic/meta.rs | 273 ++++++++++++++++++++++ crates/format/src/atomic/meta/tests.rs | 305 +++++++++++++++++++++++++ crates/format/src/atomic/tests.rs | 4 +- docs/decisions.md | 27 +++ 9 files changed, 728 insertions(+), 27 deletions(-) create mode 100644 crates/format/src/atomic/meta.rs create mode 100644 crates/format/src/atomic/meta/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0167dea3b1..2a5c18084d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1891,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1929,6 +1929,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exacl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +dependencies = [ + "bitflags 2.13.2", + "log", + "scopeguard", + "uuid", +] + [[package]] name = "exr" version = "1.74.2" @@ -3028,7 +3040,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3690,7 +3702,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4436,7 +4448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4693,6 +4705,7 @@ dependencies = [ "photocraft-geom", "photocraft-io", "rmcp", + "rustix 1.1.5", "schemars", "serde", "serde_json", @@ -4818,6 +4831,7 @@ version = "0.9.0" dependencies = [ "blake3", "crc32fast", + "exacl", "flate2", "libc", "photocraft-codecs", @@ -4831,6 +4845,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "winsafe", ] [[package]] @@ -5469,7 +5484,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5988,7 +6003,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6055,7 +6070,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6693,7 +6708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6920,10 +6935,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7323,7 +7338,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8083,7 +8098,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index fb718e5668..f5e88b3591 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -30,3 +30,7 @@ cap-std = "4" [dev-dependencies] rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } + +# Reading back an extended attribute the write path should have preserved (#7095). +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index f73a702385..77f728ce04 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -74,6 +74,13 @@ impl AuthorizedWorkspace { /// as [`photocraft_format::atomic_write`], through the directory capability). On failure /// the previous file is untouched and the temporary file is removed. /// + /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` — + /// onto the file that takes its place (#7095), through the two open descriptors rather than + /// their paths, so nothing here reaches outside the write root to finish a write. The rest + /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission + /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this + /// path may not use. + /// /// The parent directory must already exist. `cap-std` performs path /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. @@ -85,10 +92,20 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let mut file = file.into_std(); file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + // Best effort, and only when there is a file being replaced: a save still + // succeeds for a document whose Finder tag could not come along. + if let Ok(old) = root.dir.open(&relative) { + let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + } + } // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) + photocraft_format::atomic::sync_file(&file) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -572,6 +589,32 @@ mod tests { assert!(temp_files(&inside).is_empty()); } + /// #7095: replacing a file through the automation path keeps the old file's extended + /// attributes, the same as a save through `atomic_write` does. + #[cfg(unix)] + #[test] + fn write_keeps_the_replaced_files_extended_attributes() { + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + let (inside, _, workspace) = roots("xattr"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() { + return; // a file system without extended attributes: nothing to assert + } + + workspace.write("doc.psd", b"replacement, longer").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer"); + let mut buf = [0u8; 64]; + let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped"); + assert_eq!(&buf[..len], b"tagged"); + assert!(temp_files(&inside).is_empty()); + } + #[cfg(unix)] #[test] fn write_into_read_only_folder_keeps_the_original() { diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..345ff73caf 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -34,5 +34,16 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } [target.'cfg(target_vendor = "apple")'.dependencies] libc = "0.2" +# Replacing a file keeps its ACL (#7073). `fcopyfile(COPYFILE_ACL)` is an `unsafe fn` and +# `fclonefileat(CLONE_ACL)` is APFS-only; exacl is a safe wrapper over acl_get_file/acl_set_file. +[target.'cfg(target_os = "macos")'.dependencies] +exacl = "0.13" + +# `ReplaceFileW` is how Windows replaces a file without dropping its DACL, named streams +# (Zone.Identifier, the Mark of the Web) and creation time (#7094, #7096). winsafe's `kernel` +# feature is a dependency-free safe wrapper; the workspace forbids writing the FFI call here. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", features = ["kernel"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..e635b88257 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -21,8 +21,15 @@ //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" //! error. //! +//! Replacing a file also carries its metadata across — permission bits, extended attributes, +//! the "downloaded from the internet" mark and the access-control list — so that saving a +//! document does not quietly undo a security decision someone made about the file. That part is +//! [`meta`], and it is best effort: it never fails a save and never touches the bytes. +//! //! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step. +pub mod meta; + use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -44,9 +51,16 @@ pub trait AtomicFs { fn remove(&self, path: &Path) -> io::Result<()>; /// Flush a directory entry change (Unix); a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; - /// Copy the destination's permissions onto the temporary file, so replacing a file keeps its - /// mode. Best effort. - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the + /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Replace the **existing** file `to` with `from`. Everywhere but Windows this is + /// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and + /// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the + /// failure-injecting test file systems want. + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + self.rename(from, to) + } } /// The real file system. @@ -87,9 +101,11 @@ impl AtomicFs for RealFs { Ok(()) } } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - let perms = std::fs::metadata(from)?.permissions(); - std::fs::set_permissions(to, perms) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::copy_metadata(from, to) + } + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::replace_existing(from, to) } } @@ -190,12 +206,19 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & } let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; - // Keep the existing file's mode (e.g. 0600) instead of the process default. - if target.exists() { - let _ = fs.copy_permissions(&target, &tmp); + // Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process + // default, its extended attributes, and its ACL. Best effort, and before the swap, so the + // file the user ends up with never passes through a more permissive state. + let replacing = target.exists(); + if replacing { + let _ = fs.copy_metadata(&target, &tmp); } - if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + // Replacing an existing file goes through `replace_existing`, which on Windows is the one + // call that can keep the destination's DACL and named streams; creating a new one is a + // plain rename, with nothing to preserve. + let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) }; + if let Err(e) = retry_rename(retry, swap) { let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs new file mode 100644 index 0000000000..6c7cfbc21e --- /dev/null +++ b/crates/format/src/atomic/meta.rs @@ -0,0 +1,273 @@ +//! What a replacement keeps from the file it replaces. +//! +//! Saving writes a new file and renames it over the old one, so the user gets a file with the +//! right bytes and none of the old file's history: everything the operating system and other +//! programs had attached to the file that was just unlinked is gone. Some of it is a security +//! decision — *this came from the internet*, *only these people may read it* — so a save that +//! drops it quietly weakens the user's protections (#6603, #7073, #7094, #7095, #7096). +//! +//! | | macOS | Linux | Windows | +//! |---|---|---|---| +//! | permission bits | [`copy_metadata`] | [`copy_metadata`] | [`copy_metadata`] (read-only flag) | +//! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | +//! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | +//! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | +//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! +//! Everything here except [`replace_existing`] is **best effort and never touches the file's +//! bytes**: a save that cannot carry a piece of metadata across is still a save, because a +//! document the user can open matters more than a Finder tag. [`replace_existing`] *is* the +//! replacement step, so it holds the atomicity guarantee instead: it never leaves the +//! destination missing, and it falls back to a plain rename rather than fail a save. +//! +//! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which +//! `listxattr` does not report, so copying the listed attributes misses them; they would need +//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. + +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::fd::BorrowedFd; + +/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a +/// megabyte of them is not something a save should spend memory on. +#[cfg(unix)] +const MAX_XATTR_NAMES: usize = 1 << 20; + +/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*` +/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth +/// holding a copy of it in memory during a save. +#[cfg(unix)] +const MAX_XATTR_VALUE: usize = 16 << 20; + +/// First read size for an attribute list or value; grown on `ERANGE`. +#[cfg(unix)] +const XATTR_CHUNK: usize = 4 << 10; + +/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it +/// is about to replace (`from`) onto the temporary file that will replace it (`to`). +/// +/// Errors are the caller's to ignore: each piece is attempted on its own, and the first failure +/// is returned only so a caller that wants to log one can. +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + // The permission bits go on last. Writing an attribute needs the destination open for + // writing, and replacing a read-only (0444) file would copy that mode onto the temporary + // file and then be unable to touch it again. + #[cfg(unix)] + let attributes = copy_xattrs_at(from, to); + #[cfg(not(unix))] + let attributes = Ok(()); + + #[cfg(target_os = "macos")] + let acl = copy_acl(from, to); + #[cfg(not(target_os = "macos"))] + let acl = Ok(()); + + // A save over a 0600 file must not widen it to whatever the process default is. + let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); + + attributes.and(acl).and(permissions) +} + +/// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the +/// descriptors to [`copy_xattrs`]. +#[cfg(unix)] +fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> { + use std::os::fd::AsFd; + let old = std::fs::File::open(from)?; + // Writing an attribute needs write access to the file, not only to its directory. + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_xattrs(old.as_fd(), new.as_fd()) +} + +/// Copy every extended attribute of `from` onto `to`, best effort, through the two open files +/// rather than their paths. +/// +/// Descriptors, not paths, because the automation write path holds capability-checked handles +/// and must never reach back out to an ambient path to finish a write (#7095). The same reason +/// makes this the right shape for [`copy_metadata`] too. +/// +/// An attribute that cannot be read or cannot be set is skipped: `com.apple.provenance` and +/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to +/// be written by an ordinary process, and failing a document save over one would be absurd. +/// `Err` means the attribute *list* could not be read, so nothing was copied. +#[cfg(unix)] +pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> { + use rustix::fs::{XattrFlags, fsetxattr}; + + for name in list_xattrs(from)?.split(|b| *b == 0) { + if name.is_empty() || skip_xattr(name) { + continue; + } + let Some(value) = read_xattr(from, name) else { continue }; + let _ = fsetxattr(to, name, &value, XattrFlags::empty()); + } + Ok(()) +} + +/// Attributes that describe how the *old* file's bytes are stored rather than anything about +/// the document, and so must never be pinned onto a file holding different bytes. +#[cfg(unix)] +fn skip_xattr(name: &[u8]) -> bool { + // `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself. + // The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming + // compressed storage it does not have would read as garbage, so never try. + name == b"com.apple.decmpfs" +} + +/// The NUL-separated attribute names of `fd`. +#[cfg(unix)] +fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::flistxattr(fd, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Ok(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_NAMES => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_NAMES), 0); + } + Err(e) => return Err(e.into()), + } + } +} + +/// The value of one attribute, or `None` if it cannot be read or is larger than +/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute +/// between the listing and the read. +#[cfg(unix)] +fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::fgetxattr(fd, name, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Some(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_VALUE => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_VALUE), 0); + } + Err(_) => return None, + } + } +} + +/// Copy an explicit macOS access-control list (`chmod +a`) from `from` onto `to` (#7073). +/// +/// `fcopyfile(COPYFILE_ACL)` is the obvious call and is an `unsafe fn`, which this workspace +/// forbids; `fclonefileat` with `CLONE_ACL` is safe but only works on APFS and would mean +/// writing every save as a clone-then-truncate. `exacl` is a small, safe wrapper over +/// `acl_get_file`/`acl_set_file` that works on every file system that has ACLs at all. +/// +/// An old file with no ACL leaves `to` alone: the new file may have picked up the folder's +/// inherited entries, and taking access away is a worse surprise than leaving it as the folder +/// says it should be. +#[cfg(target_os = "macos")] +fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { + let entries = exacl::getfacl(from, None)?; + if entries.is_empty() { + return Ok(()); + } + exacl::setfacl(&[to], &entries, None) +} + +/// Replace the **existing** file `to` with `from`, keeping what the destination owns rather than +/// its bytes. +/// +/// Everywhere but Windows this is exactly [`std::fs::rename`], and the metadata is carried by +/// [`copy_metadata`] onto `from` beforehand. On Windows it is `ReplaceFileW`, which preserves +/// the replaced file's DACL (#7096), its named streams — `Zone.Identifier`, the Mark of the Web +/// (#7094) — its creation time, object identifier, compression and encryption, in the one call +/// Microsoft documents for the save-through-a-temporary-file pattern. None of those have a safe +/// path-based API to copy by hand. +pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(windows)] + { + windows_replace(from, to) + } + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } +} + +/// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. +/// +/// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before +/// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no +/// longer exists and the replacement file exists under its original name" — the user's document +/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// leaves both files where they were. So this always passes one, and deletes it on the way out. +/// +/// The failures and what each leaves behind (from the `ReplaceFileW` reference): +/// +/// - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (1177) is the only one that moves anything: the +/// replacement keeps its name but has already inherited the destination's streams and +/// attributes, and the destination's old content is at the backup name. Finishing the rename +/// by hand completes the save *with* its metadata; if even that fails, the backup goes back to +/// the destination so the user's file is where they left it. +/// - Every other error leaves the replaced and replacement files under their own names, so a +/// plain rename still saves the document — without the metadata, which is where this started. +#[cfg(windows)] +fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { + use winsafe::co::REPLACEFILE; + + /// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the destination now lives under the backup name. + const MOVE_REPLACEMENT_2: i32 = 1177; + + let backup = backup_path(to); + let paths = (to.to_str(), from.to_str(), backup.as_deref().and_then(Path::to_str)); + // `ReplaceFileW` takes `&str`; a path that is not valid Unicode, or one past MAX_PATH, is + // the plain rename's job. + let (Some(replaced), Some(replacement), Some(backup_str)) = paths else { + return std::fs::rename(from, to); + }; + + let flags = REPLACEFILE::IGNORE_MERGE_ERRORS | REPLACEFILE::IGNORE_ACL_ERRORS; + match winsafe::ReplaceFile(replaced, replacement, Some(backup_str), flags) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(rename_failed) => { + if let Some(backup) = backup.as_deref() { + let _ = std::fs::rename(backup, to); + } + Err(rename_failed) + } + }, + Err(_) => { + remove_backup(backup.as_deref()); + std::fs::rename(from, to) + } + } +} + +/// A unique name beside `to` for `ReplaceFile` to park the replaced file under. It shares the +/// temporary-file suffix, so the save's own cleanup and the tests' leftover check cover it. +#[cfg(windows)] +fn backup_path(to: &Path) -> Option { + let name = to.file_name()?.to_string_lossy().into_owned(); + let dir = match to.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + Some(dir.join(super::temp_name(&name))) +} + +#[cfg(windows)] +fn remove_backup(backup: Option<&Path>) { + if let Some(backup) = backup { + let _ = std::fs::remove_file(backup); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs new file mode 100644 index 0000000000..8bf5689039 --- /dev/null +++ b/crates/format/src/atomic/meta/tests.rs @@ -0,0 +1,305 @@ +//! What a save keeps from the file it replaces. +//! +//! The Unix tests run for real. The Windows ones exercise `ReplaceFileW` through +//! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` +//! and are covered by the Windows CI job, not by a developer on a Mac. + +use crate::atomic::atomic_write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let d = std::env::temp_dir().join(format!("pc-meta-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Self(d) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// No temporary or backup file is left beside the saved document. +fn assert_no_temp(dir: &Path) { + let leftovers: Vec<_> = + std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect(); + assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}"); +} + +// ---------------------------------------------------------------- extended attributes (Unix) + +#[cfg(unix)] +mod xattrs { + use super::*; + + /// macOS wants a reverse-DNS name; Linux only lets an unprivileged process write `user.*`. + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + fn set(path: &Path, name: &str, value: &[u8]) -> bool { + rustix::fs::setxattr(path, name, value, rustix::fs::XattrFlags::empty()).is_ok() + } + + fn get(path: &Path, name: &str) -> Option> { + let mut buf = vec![0u8; 1 << 20]; + let len = rustix::fs::getxattr(path, name, buf.as_mut_slice()).ok()?; + buf.truncate(len); + Some(buf) + } + + /// #6603: the attributes of the replaced file — a Finder tag, a `user.*` attribute — are on + /// the file the user is left with. + #[test] + fn replacing_a_file_keeps_its_extended_attributes() { + let d = TempDir::new("xattr"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + // tmpfs and some CI file systems have no xattr support; nothing to assert. + return; + } + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "the replaced file's attribute was dropped"); + assert_no_temp(&d.0); + } + + /// A large value still comes across (and is not silently truncated). + #[test] + fn a_large_attribute_value_survives() { + let d = TempDir::new("xattr-big"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + // Comfortably past the first read buffer, so the grow-on-ERANGE path runs. + let value = vec![0xABu8; 64 << 10]; + if !set(&p, TAG, &value) { + return; + } + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(get(&p, TAG), Some(value)); + } + + /// Copying the mode must not get in the way of copying the attributes: a read-only file is + /// still writable to us *before* its mode lands on the temporary file, and only then. + #[test] + fn a_read_only_file_keeps_its_attributes_and_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("xattr-ro"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + return; + } + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); + } + + /// Creating a file that was not there sets no attributes and does not fail. + #[test] + fn creating_a_new_file_needs_no_source() { + let d = TempDir::new("xattr-new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert!(get(&p, TAG).is_none()); + } + + /// The descriptor-based copy is what the automation write path uses (#7095): it works on two + /// open files and never looks at a path. + #[test] + fn copy_xattrs_works_on_descriptors() { + use std::os::fd::AsFd; + let d = TempDir::new("xattr-fd"); + let (from, to) = (d.join("from"), d.join("to")); + std::fs::write(&from, b"a").unwrap(); + std::fs::write(&to, b"b").unwrap(); + if !set(&from, TAG, b"carried") { + return; + } + + let old = std::fs::File::open(&from).unwrap(); + let new = std::fs::OpenOptions::new().write(true).open(&to).unwrap(); + super::super::copy_xattrs(old.as_fd(), new.as_fd()).unwrap(); + + assert_eq!(get(&to, TAG).as_deref(), Some(&b"carried"[..])); + assert_eq!(std::fs::read(&to).unwrap(), b"b", "copying attributes must not touch the bytes"); + } +} + +// --------------------------------------------------------------------------- mode (Unix) + +/// The mode was already kept before any of this; it still is. +#[cfg(unix)] +#[test] +fn replacing_a_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); +} + +// ----------------------------------------------------------------------------- ACL (macOS) + +#[cfg(target_os = "macos")] +mod acl { + use super::*; + + /// #7073: an ACL entry added with `chmod +a` is still there after a save. + #[test] + fn replacing_a_file_keeps_its_acl() { + let d = TempDir::new("acl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // The same thing `chmod +a` does: one explicit entry for the user running the test, so + // no second account has to exist. + let Ok(user) = std::env::var("USER") else { return }; + let entries = [exacl::AclEntry::allow_user(&user, exacl::Perm::READ | exacl::Perm::WRITE, None)]; + if exacl::setfacl(&[&p], &entries, None).is_err() { + // A file system without ACL support (a RAM disk, some CI volumes). + return; + } + let before = exacl::getfacl(&p, None).unwrap(); + assert!(!before.is_empty(), "the test could not set an ACL to begin with"); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(exacl::getfacl(&p, None).unwrap(), before, "the replaced file's ACL was dropped"); + assert_no_temp(&d.0); + } + + /// A file with no ACL keeps having no ACL, rather than gaining an empty one. + #[test] + fn a_file_without_an_acl_is_left_alone() { + let d = TempDir::new("acl-none"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = exacl::getfacl(&p, None).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(exacl::getfacl(&p, None).unwrap(), before); + } +} + +// --------------------------------------------------------------- named streams and ACL (Windows) + +/// Unverified on a Mac: these run only on Windows (CI's Windows job). +#[cfg(windows)] +mod windows { + use super::*; + + /// Open a named stream of `path`, e.g. `Zone.Identifier`. + fn stream(path: &Path, name: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(":"); + s.push(name); + PathBuf::from(s) + } + + /// #7094: a file marked as downloaded from the internet is still marked after a save, so the + /// shell and other applications keep warning about it. + #[test] + fn replacing_a_file_keeps_its_mark_of_the_web() { + let d = TempDir::new("motw"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let motw = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(stream(&p, "Zone.Identifier"), motw).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(stream(&p, "Zone.Identifier")).unwrap(), motw, "the Mark of the Web was dropped"); + assert_no_temp(&d.0); + } + + /// Any other named stream rides along too, because `ReplaceFileW` keeps the lot. + #[test] + fn replacing_a_file_keeps_other_named_streams() { + let d = TempDir::new("ads"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::write(stream(&p, "photocraft.test"), b"side data").unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::read(stream(&p, "photocraft.test")).unwrap(), b"side data"); + } + + /// #7096: an explicit ACL entry survives, instead of the file falling back to whatever the + /// folder hands down. Compared as the string `icacls` prints, which is what a user checks. + #[test] + fn replacing_a_file_keeps_its_explicit_acl() { + let d = TempDir::new("dacl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // Grant the current user an entry that inheritance would not have produced. + let user = std::env::var("USERNAME").unwrap_or_default(); + if user.is_empty() { + return; + } + let granted = std::process::Command::new("icacls").arg(&p).arg("/grant").arg(format!("{user}:(RX)")).output(); + if !granted.map(|o| o.status.success()).unwrap_or(false) { + return; + } + let before = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + + atomic_write(&p, b"second, longer").unwrap(); + + let after = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + assert_eq!(String::from_utf8_lossy(&after), String::from_utf8_lossy(&before), "the explicit ACL was dropped"); + assert_no_temp(&d.0); + } + + /// `ReplaceFileW` also keeps the creation time, which the rename used to reset (#6603). + #[test] + fn replacing_a_file_keeps_its_creation_time() { + let d = TempDir::new("born"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = std::fs::metadata(&p).unwrap().created().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(50)); + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), before); + } + + /// Creating a file where there was none still works: there is nothing to replace, so the + /// write never reaches `ReplaceFileW`. + #[test] + fn creating_a_new_file_still_works() { + let d = TempDir::new("new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert_no_temp(&d.0); + } +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..8bf89512c2 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -69,8 +69,8 @@ impl AtomicFs for Faulty { fn sync_dir(&self, dir: &Path) -> io::Result<()> { RealFs.sync_dir(dir) } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - RealFs.copy_permissions(from, to) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } diff --git a/docs/decisions.md b/docs/decisions.md index 15519ecf74..ac44d14500 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,3 +5,30 @@ Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects. Native files persist this ID. Legacy embedded objects without an ID remain independent: identical file bytes do not prove that two objects were intended to share edits. Legacy nonempty linked paths and PSD source identifiers retain their explicit sharing relationship. PSD export keys embedded sources by contents identity as well as bytes, so independent equal-content objects stay independent. + +## 2026-10-17: keeping a replaced file's ACL and named streams (#7073, #7094, #7096) + +Saving replaces a file by renaming a new one over it, which drops everything the old file +carried. Carrying the security-relevant parts across needs platform calls that have no safe API +in `std`, and the workspace forbids `unsafe`, so each platform took a dependency rather than an +exception. + +**macOS ACLs: `exacl`, not `fclonefileat`.** The two routes that avoid `unsafe` are a macOS-only +`exacl` dependency (a safe wrapper over `acl_get_file`/`acl_set_file`) and `fclonefileat` with +the undocumented-in-`rustix` `CLONE_ACL` bit. The clone keeps the ACL, but only on APFS, and it +means writing every save as clone-then-truncate-then-write with a fallback for every other file +system — restructuring the one write path the whole app's durability rests on, to preserve an +ACL. `exacl` is a best-effort step beside the existing permission copy, it works wherever ACLs +do, and a failure cannot affect the bytes. + +**Windows: `ReplaceFileW` via `winsafe`, with a backup name.** Copying the `Zone.Identifier` +stream by hand with `std` would cover the Mark of the Web but not the DACL, and enumerating every +stream needs `FindFirstStreamW`. `ReplaceFileW` is the call Microsoft documents for the +save-through-a-temporary-file pattern and preserves the DACL, named streams, creation time and +object identifier in one step; `winsafe`'s `kernel` feature wraps it safely and pulls in nothing. + +The backup-file argument is a correctness requirement, not an option: `ReplaceFileW` without one +documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT` as leaving the replaced file deleted and the +replacement under its temporary name — a failed save that loses the user's document. With a +backup name the same failure leaves both files alone, and the one failure that does move things +(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) is unwound in `atomic/meta.rs`. From fd99c1f464dd5f026bc201782b58d80ca11ecdec Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 18:45:34 +0200 Subject: [PATCH 0477/1452] Perspective Crop: a drag without area leaves no crop box (#7061) A straight drag (no width or no height) made a zero-area quad that could neither be committed nor replaced by a new drag, only cleared with Esc. Such a drag now leaves no box. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/perspective_crop_ui.rs | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index c44b54dc4a..83df6653cf 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -130,7 +130,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { } else { app.pcrop.clicks.clear(); let (x0, y0, x1, y1) = (start[0].min(cur[0]), start[1].min(cur[1]), start[0].max(cur[0]), start[1].max(cur[1])); - app.ui.perspective_crop = Some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); + // A straight drag has no area: no box (it could neither be committed nor + // replaced by a new drag). + app.ui.perspective_crop = (x1 > x0 && y1 > y0).then_some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); } } app.pcrop.drag = None; @@ -299,6 +301,29 @@ mod tests { use super::*; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; + /// A straight drag (no width or no height) leaves no zero-area box that only Esc could clear, + /// and a plain click still counts as a corner click. + #[test] + fn a_drag_without_area_leaves_no_quad() { + let doc = Document::with_background("p", Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::PerspectiveCrop; + let ev = |app: &mut PhotocraftApp, e| assert!(pointer(app, e)); + for (x1, y1) in [(10.0, 60.0), (90.0, 10.0)] { + ev(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: x1, y: y1, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: x1, y: y1 }); + assert_eq!(app.ui.perspective_crop, None, "drag to ({x1}, {y1})"); + assert!(app.pcrop.clicks.is_empty()); + } + ev(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }); + assert_eq!(app.pcrop.clicks, vec![[30.0, 30.0]]); + assert_eq!(app.ui.perspective_crop, None); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] From 617b75597c7d2c4e2151b475eeaeddc83c1f1b30 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:08:58 +0200 Subject: [PATCH 0478/1452] Image Size: type keeps its pixel size when the resolution changes (#7124) image.imageSize set the new resolution and then resampled, which scales the type transform by the pixel factor and re-renders at the new ppi (text px = pt x dpi / 72): doubling 72 -> 144 ppi made text 4x as tall. With Resample off the pixels stayed but the next re-render doubled them. canvas_geom::set_resolution now changes the resolution keeping type the same size in pixels, as Photoshop does: text space grows by new / old, so the box and path scale with it and the layer transform undoes that. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 34 ++++++++++++++++++++++++++++++++ crates/engine/src/image_cmds.rs | 25 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0722c6bd10..ca8c6b30af 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -71,6 +71,40 @@ fn retag_text(dpi: f32, t: &mut TextLayer) { t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } +/// Sets the document resolution keeping every type layer's size in pixels, as Photoshop does (its +/// point size changes instead). Text space is in pixels at `pt x dpi / 72`, so it grows by +/// new / old: box and path scale with it, and the layer transform undoes that. +pub(crate) fn set_resolution(doc: &mut Document, dpi: f32) { + let old = std::mem::replace(&mut doc.resolution_dpi, dpi); + if !(old.is_finite() && old > 0.0 && dpi.is_finite() && dpi > 0.0) || old == dpi { + return; + } + let k = f64::from(dpi) / f64::from(old); + let grow = Affine::scale(k); + crate::image_cmds::for_each_layer(&mut doc.layers, &mut |l| { + let LayerContent::Text(t) = &mut l.content else { return }; + t.transform = t.transform.mul(&Affine::scale(1.0 / k)); + match &mut t.shape { + photocraft_doc::text::TextShape::Box { x, y, width, height } => { + for v in [x, y, width, height] { + *v = grown(*v, k); + } + } + photocraft_doc::text::TextShape::Path { path, start, .. } => { + path.knots.iter_mut().for_each(|kn| *kn = kn.transform(&grow)); + *start = grown(*start, k); + } + photocraft_doc::text::TextShape::Point => {} + } + retag_text(dpi, t); + }); +} + +/// `v x k` kept finite (a huge box from a file must not become infinite). +fn grown(v: f32, k: f64) -> f32 { + (f64::from(v) * k).clamp(-f64::from(f32::MAX), f64::from(f32::MAX)) as f32 +} + /// Applies `a` to one layer's (and its children's) non-pixel geometry. With `content` false only /// the parts Free Transform (`transform_layer`) doesn't move are touched: unlinked vector masks, /// gradient fill angles, effect reference points and artboards. diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..72e757ae5f 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -212,7 +212,8 @@ fn image_size(s: &mut Session, p: &Value) -> Result { } s.edit("Image Size", |doc, _| { if let Some(r) = dpi { - doc.resolution_dpi = r.clamp(1.0, 10_000.0); + // Type keeps its pixel size; resampling below then scales it with the pixels. + crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0)); } let filter = match resample { Method::None => return Ok(()), @@ -1000,6 +1001,28 @@ mod tests { assert_eq!(r, json!({"width": 40, "height": 20})); } + /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is + /// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump. + #[test] + fn image_size_with_a_new_resolution_scales_type_with_the_pixels_only() { + for (params, k) in [(json!({"width": 200, "height": 200, "resolution": 144}), 2), (json!({"resolution": 144, "resample": "none"}), 1)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hi", "size": 12})).unwrap(); + let height = |s: &Session| doc(s).layers.last().unwrap().surface().unwrap().content_bounds().height(); + let h0 = height(&s); + assert!(h0 > 4, "{h0}"); + s.execute("image.imageSize", params.clone()).unwrap(); + assert!(height(&s).abs_diff(h0 * k) <= 2, "{params}: {h0} -> {}", height(&s)); + let d = doc(&s); + let LayerContent::Text(t) = &d.layers.last().unwrap().content else { panic!("type layer") }; + let mut t = t.clone(); + crate::type_cmds::refresh(d, &mut t); + let after = t.cache.unwrap().content_bounds().height(); + assert!(after.abs_diff(h0 * k) <= 2, "{params}: re-rendered {h0} -> {after}"); + } + } + #[test] fn image_size_reports_the_size_it_applied() { // #1815: every path reports the document's size afterwards, at every depth. From b052fd591ef27281852e6efa4812238ce70a01e9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:15:25 +0200 Subject: [PATCH 0479/1452] Crop: Straighten is one box Undo step (#7127) The Straighten branch of crop_ui::pointer returned before the press step was recorded and pushed, although it turns the frame and makes it pending. With a crop pending, Undo then undid the document (or skipped a box step). It now records the step like every other frame gesture, through a shared push_press_step. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_straighten.rs | 15 ++++++++++++++ crates/ui-egui/src/crop_ui.rs | 29 +++++++++++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/crop_straighten.rs b/crates/ui-egui/src/crop_straighten.rs index 9f8128b80a..d3ea8e4792 100644 --- a/crates/ui-egui/src/crop_straighten.rs +++ b/crates/ui-egui/src/crop_straighten.rs @@ -231,6 +231,21 @@ mod tests { assert_eq!(frame_angle([0.0, 0.0], [f64::INFINITY, 1.0], 1.0), None); } + /// A straighten is one box Undo step (#3145): Undo turns the frame back instead of undoing the + /// document under the pending crop, and Redo turns it again. + #[test] + fn straighten_is_a_box_undo_step() { + let mut app = app(SampleType::U8); + let before = (app.ui.crop_rect, app.ui.crop_angle); + drag(&mut app, &[[0.0, 0.0], [100.0, 17.6]], CMD); + let turned = (app.ui.crop_rect, app.ui.crop_angle); + assert!(turned.1.abs() > 5.0, "{turned:?}"); + assert!(crate::crop_ui::intercept(&mut app, "edit.undo").is_some(), "Undo steps the box"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), before); + assert!(crate::crop_ui::intercept(&mut app, "edit.redo").is_some()); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), turned); + } + #[test] fn the_fitted_frame_is_the_largest_of_its_proportions_inside_the_canvas() { // Upright, the canvas's own proportions fill it. diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..bffc1b46e2 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -706,9 +706,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool if crate::crop_straighten::pointer(app, ev, mods) { claim(app); // Default mode: the image turns so the line ends up level (the press keeps its point). + // The turn is one box Undo step, like any other frame gesture. match ev { - ToolEvent::Down { .. } => crate::crop_mode::sync(app, Some(p)), - _ => crate::crop_mode::sync_frame(app), + ToolEvent::Down { .. } => { + app.crop.press_step = Some(step_of(app)); + crate::crop_mode::sync(app, Some(p)); + } + ToolEvent::Move { .. } => crate::crop_mode::sync_frame(app), + ToolEvent::Up { .. } => { + crate::crop_mode::sync_frame(app); + push_press_step(app); + } } return true; } @@ -774,18 +782,23 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } _ => {} } - if let Some(before) = app.crop.press_step.take() - && step_of(app) != before - { - app.crop.undo.push(before); - app.crop.redo.clear(); - } + push_press_step(app); } } crate::crop_mode::sync_frame(app); true } +/// On release: the frame state at the press becomes a box Undo step when the gesture changed it. +fn push_press_step(app: &mut PhotocraftApp) { + if let Some(before) = app.crop.press_step.take() + && step_of(app) != before + { + app.crop.undo.push(before); + app.crop.redo.clear(); + } +} + /// The frame follows the pointer at `p`. fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { let ratio = preset_ratio(app); From 4b660fcffa6ce3e0ecef86507eddeac2bbd967d9 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:18:21 -0400 Subject: [PATCH 0480/1452] =?UTF-8?q?perf(camera-raw):=20Shadows/Highlight?= =?UTF-8?q?s=201.5=C3=97=20faster,=20bit=20for=20bit=20the=20same?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast local Laplacian filter upsampled every remapped level in full for every reference (up to 96), but each pixel reads that level for at most two references. `llf` now samples it only where the weight is nonzero, with `up_at` (same terms, same order as `up`; row taps found once per row, `mirror` only near the edges). `quantile` selects its element instead of sorting the whole work copy (5 sorts per render, serial). 36 MP dialog, all cores: Shadows 252 -> 168 ms, Highlights 275 -> 184 ms, all 14 sliders 627 -> 411 ms. Output hashes match main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw --- crates/algo/src/camera_raw/local_tone.rs | 69 +++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index e1305c5d49..1dbf58421f 100644 --- a/crates/algo/src/camera_raw/local_tone.rs +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane { Plane { w, h, v: out } } +/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the +/// zero-stuffed row is all zeros). +fn up_rows(p: &Plane, h: usize, y: usize) -> [Option; 5] { + let lh = p.h.min(h.div_ceil(2)); + std::array::from_fn(|k| { + let m = mirror(y as isize + k as isize - 2, h); + (m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2) + }) +} + +/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`. +/// It adds the same terms in the same order as `up`, so it gives the same bits. +fn up_at(p: &Plane, w: usize, rows: &[Option; 5], x: usize) -> f32 { + let lw = p.w.min(w.div_ceil(2)); + let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() }; + if x >= 2 && x + 2 < w { + // Away from the edges no tap mirrors. + (0..5) + .filter_map(|k| { + let i = x + k - 2; + (i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2)) + }) + .sum() + } else { + (0..5) + .filter_map(|k| { + let m = mirror(x as isize + k as isize - 2, w); + (m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2)) + }) + .sum() + } +} + /// Levels down to about 2 pixels on the short side. fn depth(w: usize, h: usize) -> usize { let mut s = w.min(h); @@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { }); let gr = gaussian(m, n); for l in 0..n - 1 { - let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); - let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); + let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]); rows(&mut lap[l], lw, |y, row| { + let taps = up_rows(coarse, lh, y); for (x, o) in row.iter_mut().enumerate() { let i = y * lw + x; let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); if wgt > 0.0 { - *o += wgt * (fine[i] - coarse.v[i]); + *o += wgt * (fine[i] - up_at(coarse, lw, &taps, x)); } } }); @@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { /// The `q` quantile (0 … 1) of `v`. fn quantile(v: &[f32], q: f32) -> f32 { let mut s = v.to_vec(); - s.sort_by(f32::total_cmp); - s[((s.len() - 1) as f32 * q).round() as usize] + // Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a + // fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element. + let i = ((s.len() - 1) as f32 * q).round() as usize; + *s.select_nth_unstable_by(i, f32::total_cmp).1 } /// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. @@ -444,11 +479,33 @@ mod tests { assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}"); } for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] { - assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + let full = up(&p, uw, uh); + assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + // The filter samples `up` one pixel at a time; every sample has the same bits. + for y in 0..uh { + let taps = up_rows(&p, uh, y); + for x in 0..uw { + assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}"); + } + } } } } + #[test] + fn quantile_is_the_sorted_element() { + let p = noise_plane(301, 211, 9); + let mut v = p.v.clone(); + // Ties and signed zeros, which `total_cmp` orders by their bits. + v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]); + let mut sorted = v.clone(); + sorted.sort_by(f32::total_cmp); + for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] { + let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize]; + assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}"); + } + } + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() } From 22cacbf198682c1fce6421d5ffd15476ff4b7ec9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:21:38 +0200 Subject: [PATCH 0481/1452] Perspective Crop: switching documents drops the pending box (#7130) perspective_crop_ui::cancel_stale ran only on a pointer event or a commit, so after a tab switch the other document's quad was drawn (and reported) until the next press. sync_views now runs it next to the Crop tool's cancel_stale. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/perspective_crop_ui.rs | 28 +++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..e3a3e2a99e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1232,6 +1232,7 @@ impl PhotocraftApp { // ... and to the document it was started on. crate::canvas::polygon_cancel_stale(self); crate::crop_ui::cancel_stale(self); + crate::perspective_crop_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. let now = |i: usize| match self.view_docs.get(i) { diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index c44b54dc4a..6c181e49de 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -59,8 +59,9 @@ pub fn cancel(app: &mut PhotocraftApp) { app.pcrop.clicks.clear(); } -/// Another document became active: its pending quad is dropped. -fn cancel_stale(app: &mut PhotocraftApp) { +/// Another document became active: its pending quad is dropped (also from `sync_views`, so it is +/// never drawn on the other document). +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { let doc = app.session.active().map(|st| st.doc.id); if pending(app) && app.pcrop.doc.is_some() && app.pcrop.doc != doc { cancel(app); @@ -323,4 +324,27 @@ mod tests { assert_eq!(ordered([[10.0, 90.0], [100.0, 5.0], [0.0, 0.0], [90.0, 100.0]]), [[0.0, 0.0], [100.0, 5.0], [90.0, 100.0], [10.0, 90.0]]); } + + /// The pending quad belongs to its document (#1918): switching documents drops it, so it is + /// never drawn on the other one; on its own document it stays. + #[test] + fn switching_documents_drops_the_pending_quad() { + let mut s = photocraft_engine::Session::new(); + for name in ["a", "b"] { + s.add_document(Document::with_background(name, Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + } + s.set_active(0); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::PerspectiveCrop; + let ev = |app: &mut PhotocraftApp, e| assert!(pointer(app, e)); + ev(&mut app, ToolEvent::Down { x: 20.0, y: 10.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: 120.0, y: 90.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 120.0, y: 90.0 }); + app.sync_views(); + assert!(app.ui.perspective_crop.is_some(), "same document: the quad stays"); + app.session.set_active(1); + app.sync_views(); + assert_eq!(app.ui.perspective_crop, None); + assert!(app.pcrop.drag.is_none() && app.pcrop.clicks.is_empty()); + } } From 2a690f0f7117dea8f19c4eb8e2a698f239fc4f17 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:30:02 +0200 Subject: [PATCH 0482/1452] Crop: a refused crop keeps the frame for fixing (#7133) commit_crop cleared the box Undo, took the frame, turned the view upright and reset the angle before running image.crop, and restored nothing when the engine refused it (e.g. a resolution past 10 000 ppi). It now keeps the frame, its turn, its box Undo/Redo and the view and puts them back on an error. Leaving the tool with a refused frame cancels it instead of retrying every frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 10 ++++++---- crates/ui-egui/src/crop_size.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/crop_ui.rs | 30 +++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 914d395acc..325ed5e319 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6006,10 +6006,12 @@ pub fn commit_polygon(app: &mut PhotocraftApp) { } } -/// Apply the crop tool's rectangle. +/// Apply the crop tool's rectangle. When `image.crop` refuses it (the error is in the status bar), +/// the frame, its turn and its box Undo stay for fixing. pub fn commit_crop(app: &mut PhotocraftApp) { // Never another document's frame (#1918). crate::crop_ui::cancel_stale(app); + let kept = crate::crop_ui::keep(app); // Committing ends the box's own Undo/Redo: from here Edit › Undo reverts the document. crate::crop_ui::clear_history(app); let Some(r) = app.ui.crop_rect.take() else { return }; @@ -6041,9 +6043,9 @@ pub fn commit_crop(app: &mut PhotocraftApp) { } // W x H x Resolution (#2443): resample to that size and set the resolution. crate::crop_size::commit_params(app, &mut p); - if app.run("image.crop", p).is_ok() - && let Some(i) = app.session.active_index() - { + if app.run("image.crop", p).is_err() { + crate::crop_ui::restore(app, kept); + } else if let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; } } diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index b30cdd8c4a..d7ef2954af 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -525,6 +525,33 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); } + /// A commit `image.crop` refuses (here a resolution past its 10 000 ppi) keeps the frame, its + /// turn and its box Undo; leaving the tool then drops the frame instead of retrying. + #[test] + fn a_refused_commit_keeps_the_frame_and_its_history() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = app(SampleType::U8); + app.ui.tool_options = whr("4 in", "2 in", "20000", PX_PER_IN); + app.ui.tool_options.crop_shield.classic_mode = true; + crate::crop_ui::ensure_frame(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 110.0, y: 60.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 110.0, y: 60.0 }, egui::Modifiers::NONE); + app.ui.crop_angle = 12.0; + let frame = (app.ui.crop_rect, app.ui.crop_angle); + assert!(frame.0.is_some() && !app.crop.default_frame); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(400, 300), "refused"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), frame); + assert!(!app.crop.default_frame); + assert_eq!(crate::crop_ui::is_enabled(&app, "edit.undo"), Some(true), "the box Undo stays"); + app.ui.tool = Tool::Move; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(400, 300)); + } + #[test] fn older_tool_options_load_without_the_new_fields() { let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..a4cfd0e943 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -377,10 +377,14 @@ fn keeps_pending(app: &PhotocraftApp) -> bool { } /// Commits the drawn frame on leaving the Crop tool as ↵ would with the tool still picked (so the -/// view goes upright too, `crop_mode::upright`), then gives the new tool back. +/// view goes upright too, `crop_mode::upright`), then gives the new tool back. A refused crop +/// keeps its frame: that is cancelled, so the new tool doesn't retry it every frame. fn commit_on_leave(app: &mut PhotocraftApp) { let tool = std::mem::replace(&mut app.ui.tool, Tool::Crop); crate::canvas::commit_crop(app); + if app.ui.crop_rect.is_some() { + cancel(app); + } app.ui.tool = tool; } @@ -422,6 +426,30 @@ fn apply_step(app: &mut PhotocraftApp, s: CropStep) { app.crop.default_frame = s.default_frame; } +/// The pending frame, its box Undo/Redo and the view's turn, kept while `image.crop` runs so a +/// refused commit gives them back ([`restore`]). +pub(crate) struct Kept { + step: CropStep, + undo: Vec, + redo: Vec, + view: Option<(f32, [f32; 2])>, +} + +pub(crate) fn keep(app: &PhotocraftApp) -> Kept { + let view = app.session.active_index().and_then(|i| app.ui.views.get(i)).map(|v| (v.rotation, v.center)); + Kept { step: step_of(app), undo: app.crop.undo.clone(), redo: app.crop.redo.clone(), view } +} + +pub(crate) fn restore(app: &mut PhotocraftApp, k: Kept) { + apply_step(app, k.step); + (app.crop.undo, app.crop.redo) = (k.undo, k.redo); + if let Some((rotation, center)) = k.view + && let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) + { + (v.rotation, v.center) = (rotation, center); + } +} + /// While the Crop tool has box history, Undo/Redo step the *box*, not the document (Photoshop keeps /// Edit › Undo live during a crop, #1918/#3145). `Some(enabled)` for those commands, else `None` so /// the caller falls back to document history. From 2f038c5eb234a70ba3f3259875066d3abf1e5fc8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:37:40 +0200 Subject: [PATCH 0483/1452] Perspective Crop: W and H follow the Point/Pica Size preference (#7135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit size_params converted points at a hard-coded 72 per inch. It now uses Preferences › Point/Pica Size, as the rulers, the type tools and the Crop tool do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_size.rs | 16 ++++++++++++++++ crates/ui-egui/src/perspective_crop_ui.rs | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index b30cdd8c4a..d450ff06a0 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -530,4 +530,20 @@ mod tests { let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); assert_eq!((o.crop_width.as_str(), o.crop_resolution_unit.as_str()), ("", PX_PER_IN)); } + + /// Perspective Crop's W and H follow Preferences › Point/Pica Size, as the Crop tool's do. + #[test] + fn perspective_crop_points_follow_the_point_size_preference() { + let doc = Document::with_background("p", Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.session.edit_prefs(|p| p.units_and_rulers.point_size = photocraft_engine::prefs::PointSize::Traditional); + let o = &mut app.ui.tool_options; + (o.crop_width, o.crop_height, o.crop_resolution) = ("7227 pt".into(), "100 px".into(), "72".into()); + o.crop_resolution_unit = PX_PER_IN.into(); + let mut p = json!({}); + crate::perspective_crop_ui::size_params(&app, &mut p); + assert_eq!((p["width"].as_f64(), p["height"].as_f64()), (Some(7200.0), Some(100.0)), "{p}"); + } } diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index c44b54dc4a..3c61bcaf30 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -157,13 +157,15 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2]) { /// `image.perspectiveCrop`'s size params from W, H and the resolution (the Crop tool's fields): /// lengths in physical units convert at the typed resolution, else the document's. -fn size_params(app: &PhotocraftApp, p: &mut Value) { +pub(crate) fn size_params(app: &PhotocraftApp, p: &mut Value) { let o = &app.ui.tool_options; let res = crate::crop_size::resolution_ppi(o); let dpi = res.unwrap_or_else(|| app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi))); + // Points and picas per Preferences › Point/Pica Size. + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let px = |text: &str| { let (v, u) = crate::crop_size::parse_length(text, Unit::Pixels)?; - let px = u.to_px(v, dpi, 0.0, 72.0).round(); + let px = u.to_px(v, dpi, 0.0, pt_per_in).round(); (px.is_finite() && px >= 1.0).then_some(px) }; if let Some(w) = px(&o.crop_width) { From f7e441244f31a9a38842a2584428f3090fbd5f3b Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 17 Oct 2026 10:12:24 +0200 Subject: [PATCH 0484/1452] fix(ui): Color Picker updates the colour on press, not on release (#6937) The hue/component strip and the colour field only reacted to dragged() || clicked(), so a click-and-hold updated the selection only when the button was released. Photoshop updates immediately on the press. React to is_pointer_button_down_on() instead, which is true from the press frame onwards and covers dragging too. Regression test drives the picker with a press-and-hold on the hue strip and asserts the colour changes before release. --- crates/ui-egui/src/color_picker_ui.rs | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 19272a62ed..121b052823 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -509,7 +509,10 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); - if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) { + // Photoshop updates the colour on the press itself, not only when the button is released + // (#6937): `is_pointer_button_down_on` is true from the press frame onwards (it covers + // dragging too), while `clicked` fires on release and `dragged` only after a move. + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on() || resp.clicked()) { let (x, y) = (((p.x - field.left()) / field.width()).clamp(0.0, 1.0), ((p.y - field.top()) / field.height()).clamp(0.0, 1.0)); let c = field_color(&mode, fz, x, y); let keep = match mode.as_str() { @@ -528,7 +531,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE)); } - if let Some(p) = sresp.interact_pointer_pos().filter(|_| sresp.dragged() || sresp.clicked()) { + if let Some(p) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { let z = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0); let h = match mode.as_str() { "h" => Some([z * 360.0, hsv[1], hsv[2]]), @@ -895,6 +898,32 @@ mod tests { assert_eq!(color(&h), "#12abef", "casing is presentation only"); } + /// #6937: pressing the hue strip must update the colour on the press frame, not only when + /// the button is released (Photoshop updates immediately on click-and-hold). + #[test] + fn hue_strip_updates_the_color_on_press_not_on_release() { + let mut h = picker("#0000ff"); // pure blue: hue 240° + let web = h.get_by_label("Only Web Colors").rect(); + let pick = h.get_by_label("Pick screen color").rect(); + // The strip is 18 pt wide, 6 pt right of the 200 pt field whose left edge is the column's + // (the screen-colour button shares it); its bottom edge is just above the checkbox. + // Press near the bottom: hue ~5°, a red the blue starting colour cannot produce via the field. + let press = pos2(pick.left() + FIELD + 15.0, web.top() - 20.0); + h.event(egui::Event::PointerMoved(press)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let held = color(&h); + assert_ne!(held, "#0000ff", "holding the button on the strip must already change the colour"); + // The colour is the red-ish hue near the strip's bottom, not the old blue: had the press + // landed on the field instead of the strip, the hue (and the blue channel) would survive. + let [r, _, b] = parse_hex(&held).unwrap(); + assert!(r > 0.5 && b < 0.2, "expected a red-ish hue from the strip, got {held}"); + h.event(egui::Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(color(&h), held, "releasing must not move the colour again"); + } + #[test] fn hex_field_takes_typed_digits() { let mut h = picker("#000000"); From c05d827fdea5ee0c96231b83936d4994fa25afbc Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:40:31 -0400 Subject: [PATCH 0485/1452] perf(type): refresh type layers without cloning the document (#7092) `type_cmds::refresh` read only the document's resolution and pixel format, but took `&Document`, so 15 call sites that hold the layer mutably cloned the whole document to pass it, once per keystroke and pointer move. The new `refresh_at(dpi, format, t)` takes the two values; those sites read them before borrowing the layer. `refresh(doc, t)` stays as a wrapper for callers that already hold the document. `with_text_layer` and `with_text` hand their closures the resolution, the only thing any of them read from the copy. type.edit on a 6000 x 4000 document: 60 filled layers 5.60 -> 5.14 ms, 20 layers 4.33 -> 3.90 ms (median of 300). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw --- crates/engine/src/edit_menu_cmds.rs | 8 ++--- crates/engine/src/type_cmds.rs | 37 +++++++++++++--------- crates/engine/src/type_extra_cmds.rs | 44 +++++++++++++++------------ crates/engine/src/type_spell_cmds.rs | 14 ++++----- crates/engine/src/type_styles_cmds.rs | 10 +++--- crates/engine/src/variables_cmds.rs | 4 +-- crates/engine/src/warp_cmds.rs | 4 +-- 7 files changed, 67 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 11e2273601..12cb62a415 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -902,13 +902,13 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { return Ok(json!({"count": 0, "layers": 0})); } s.edit("Replace All Text", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for (id, matches) in &plan { if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) { for (a, b) in matches.iter().rev() { crate::type_cmds::replace_text(t, *a, *b, &replace); } - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } } Ok(()) @@ -926,10 +926,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { { crate::type_cmds::check_text_unlocked(&doc, id)?; s.edit("Replace Text", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { crate::type_cmds::replace_text(t, a, b, &replace); - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } Ok(()) })?; diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..b31820dec7 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::Color; +use photocraft_color::{Color, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, }; @@ -534,9 +534,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { /// Re-renders the layer's pixels and regenerates its PSD `TySh` data. pub fn refresh(doc: &Document, t: &mut TextLayer) { - let dpi = doc.resolution_dpi; + refresh_at(doc.resolution_dpi, doc.pixel_format(), t); +} + +/// [`refresh`] given only the two things it reads from the document: its resolution and its +/// pixel format. A caller holding the layer mutably reads them first instead of cloning the whole +/// document to pass it, once per keystroke and pointer move while typing (#7092). +pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - let (layout, r) = eng.render(t, dpi, doc.pixel_format()); + let (layout, r) = eng.render(t, dpi, format); t.cache = Some(r.surface); t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } @@ -553,11 +559,12 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. -fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { +/// `f` gets the document's resolution (dpi). +fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { @@ -565,8 +572,8 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let mut rename = None; - let r = f(t, &snapshot, &mut rename)?; - refresh(&snapshot, t); + let r = f(t, dpi, &mut rename)?; + refresh_at(dpi, format, t); // Only this layer's pixels changed: the canvas recomposites their old and new area // instead of the whole document (#124). Unknown old pixels mean a full refresh. let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b)); @@ -629,14 +636,14 @@ pub(crate) fn with_text_targets( ) -> Result> { let ids = format_targets(s, p)?; let damage = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let mut damage = Some(photocraft_geom::Rect::EMPTY); for id in &ids { let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) else { return Err(EngineError::NoLayer(*id)) }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let range = if p.get("layers").is_none() || p.get("layer").is_some() { range_param(&t.text, p) } else { (0, t.text.len()) }; f(t, range)?; - refresh(&snapshot, t); + refresh_at(dpi, format, t); // An unknown old/new cache retains Session.edit's full-refresh fallback. damage = damage.zip(before).zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|((all, a), b)| all.union(&a).union(&b)); } @@ -856,7 +863,7 @@ pub fn specs() -> Vec { } }; let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" }; - with_text_layer(s, p, label, |t, doc, rename| { + with_text_layer(s, p, label, |t, dpi, rename| { if let Some((at, by)) = kern_pair { // Photoshop's Alt+←/→: the pair before the caret becomes manually kerned, // starting from what it shows now (its metrics/optical or manual value). @@ -868,7 +875,7 @@ pub fn specs() -> Vec { let b = byte_at(&t.text, at); let now = { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - eng.pair_kerning(t, doc.resolution_dpi, a) + eng.pair_kerning(t, dpi, a) } .ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?; let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32; @@ -984,16 +991,16 @@ pub fn specs() -> Vec { run: |s, p| { let id = layer_id(s, p)?; s.edit("Rasterize Type", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is not a type layer", id.0))); }; if t.cache.is_none() { - refresh(&snapshot, t); + refresh_at(dpi, format, t); } - let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(snapshot.pixel_format())); - let surface = if surface.format() == snapshot.pixel_format() { surface } else { surface.convert(snapshot.pixel_format()) }; + let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(format)); + let surface = if surface.format() == format { surface } else { surface.convert(format) }; l.content = LayerContent::Raster(surface); l.psd_blocks.retain(|(k, _)| k != b"TySh"); Ok(()) diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index efa2d791cc..2755315016 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -15,7 +15,8 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; use crate::type_cmds::{ - check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range, + with_text_targets, }; use crate::{EngineError, Result, Session}; @@ -68,19 +69,20 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { // ---------- helpers ---------- -/// Edits a type layer as one history step and re-renders it. -fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { +/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution +/// (dpi). +fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { let id = layer_param(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name()))); }; - let r = f(t, &snapshot)?; - refresh(&snapshot, t); + let r = f(t, dpi)?; + refresh_at(dpi, format, t); let name = auto_named.then(|| layer_name(&t.text)); if let Some(n) = name { l.name = n; @@ -90,7 +92,11 @@ fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex } fn layout(doc: &Document, t: &TextLayer) -> photocraft_text::TextLayout { - photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, doc.resolution_dpi) + layout_at(doc.resolution_dpi, t) +} + +fn layout_at(dpi: f32, t: &TextLayer) -> photocraft_text::TextLayout { + photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, dpi) } /// `m ∘ translate(tx, ty)`. @@ -144,11 +150,11 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result // ---------- point ⇄ paragraph ---------- fn to_paragraph(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| { - let l = layout(doc, t); - let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0; + let r = with_text(s, p, "Convert to Paragraph Text", |t, dpi| { + let l = layout_at(dpi, t); + let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * dpi / 72.0; let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default(); - let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0; + let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * dpi / 72.0; if l.vertical { // Line space: u along the columns, v across them (first column centred on v = 0). let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]); @@ -178,7 +184,7 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { probe.shape = TextShape::Box { x, y: y0, width, height }; // Box text puts the first baseline at the box top plus the ascender height: measure it and // shift the box so that baseline stays at the point-text anchor (y = 0). - let b0 = layout(doc, &probe).lines.first().map_or(0.0, |ln| ln.baseline); + let b0 = layout_at(dpi, &probe).lines.first().map_or(0.0, |ln| ln.baseline); let y = y0 - b0; t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height }; t.transform = then_translate(&t.transform, f64::from(x), f64::from(y)); @@ -188,9 +194,9 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { } fn to_point(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Point Text", |t, doc| { + let r = with_text(s, p, "Convert to Point Text", |t, dpi| { let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) }; - let l = layout(doc, t); + let l = layout_at(dpi, t); // Vertical type: columns run along the box height. let (x, width) = if l.vertical { (y, height) } else { (x, width) }; let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline); @@ -496,11 +502,11 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result { fn update_all(s: &mut Session) -> Result { let mut n = 0; s.edit("Update All Text Layers", |doc, _| { - let snapshot = doc.clone(); - let ids: Vec = snapshot.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); for id in ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(id) { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } @@ -582,7 +588,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo } let mut n = 0; s.edit("Replace Missing Fonts", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for id in &ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(*id) { let mut changed = false; @@ -603,7 +609,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo at += r.len; } if changed { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index 12a63a364c..915b21229a 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh_at, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -102,10 +102,10 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("the text at {start}..{end} is {:?}, not {w:?}", &t[a..b]))); } s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, a, b, &to); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; @@ -129,13 +129,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result { let count: usize = plan.iter().map(|(_, h)| h.len()).sum(); if count > 0 { s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for (id, hits) in &plan { if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) { for (a, b) in hits.iter().rev() { replace_text(t, *a, *b, &to); } - refresh(&snapshot, t); + refresh_at(dpi, format, t); } } Ok(()) @@ -215,10 +215,10 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let (ba, bb) = (byte_at(t, a), byte_at(t, b)); let label = p.get("label").and_then(Value::as_str).unwrap_or("Insert Glyph").to_string(); s.edit(&label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, ba, bb, &new); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 797dfe7f8a..5d5ade404b 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -17,7 +17,7 @@ use photocraft_doc::{Document, LayerContent, LayerId, TextLayer, TextStyles}; use serde_json::{Map, Value, json}; use crate::commands::CommandSpec; -use crate::type_cmds::{apply_char_props, apply_para_props, refresh}; +use crate::type_cmds::{apply_char_props, apply_para_props, refresh_at}; use crate::{EngineError, Result, Session}; const FAMILY: &str = photocraft_text::fonts::DEFAULT_FAMILY; @@ -291,7 +291,7 @@ fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op: fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize { let old = doc.text_styles.clone(); doc.text_styles = new.clone(); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); let mut changed = 0; for id in ids { @@ -302,7 +302,7 @@ fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) None => restyle(t, &old, new, Some((0, 0)), Op::default()), }; if did { - refresh(&snapshot, t); + refresh_at(dpi, format, t); changed += 1; } } @@ -516,7 +516,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } else { doc.text_styles.character.retain(|d| d.id != id); } - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); for lid in ids { let Some(LayerContent::Text(t)) = doc.layer_mut(lid).map(|l| &mut l.content) else { continue }; @@ -537,7 +537,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } } if hit { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index f42b744844..bd76d9c8f6 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -165,7 +165,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( } } (VarKind::TextReplacement, VarValue::Text(text)) => { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(l) = doc.layer_mut(def.layer) && let LayerContent::Text(t) = &mut l.content { @@ -180,7 +180,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( t.sync_summary(); // The compositor draws a type layer from its cache: clearing it left the // layer blank until some other edit re-rendered it (#990). - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } } (VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => { diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..452d600ece 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -321,7 +321,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } s.edit("Warp", |doc, _| { let group = crate::transform_cmds::group_locks(doc, id); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let locks = l.locks.union(group); if locks.position || locks.all { @@ -329,7 +329,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) }; t.warp = tw.clone(); - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); Ok(()) })?; Ok(json!({"layer": id.0, "changed": true, "warp": tw})) From 193f495e5a0fe20fd1b125d29bc30561faa14f8f Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:44:29 +0200 Subject: [PATCH 0486/1452] Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138) Both filled the Background's uncovered area with the background colour including its alpha (tools.setColors accepts one), leaving see-through pixels on a Background. They now use alpha 1, as crop_doc does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 3 ++- crates/engine/src/mode_cmds/tests.rs | 9 +++++++++ crates/engine/src/perspective_crop_cmds.rs | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..9c51731a2c 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -75,7 +75,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { if deg.rem_euclid(360.0).abs() < 1e-9 { return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } - let bg = s.tools.background; + // The Background stays opaque, whatever the background colour's alpha. + let bg = [s.tools.background[0], s.tools.background[1], s.tools.background[2], 1.0]; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..fad300e27d 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,15 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_background_opaque_with_a_translucent_background_colour() { + let mut s = session(40, 20, 8, "rgb"); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 30})).unwrap(); + let corner = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0], "the uncovered corner is opaque background colour"); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 8acaf42ce2..776b2b4d07 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -188,7 +188,8 @@ fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> { let (w, ht) = size; let clip = Rect::from_xywh(0, 0, w, ht); - let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg); + // The Background stays opaque, whatever the background colour's alpha. + let bg = photocraft_raster::from_rgba(&doc.pixel_format(), [bg[0], bg[1], bg[2], 1.0]); let background_id = doc.background().map(|l| l.id); warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?; for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { @@ -298,6 +299,18 @@ mod tests { } } + /// Corners past the canvas: the uncovered Background is the background colour, opaque even + /// when that colour has alpha (the Background stays opaque). + #[test] + fn uncovered_background_is_opaque() { + let q = [[-20.0, -20.0], [180.0, -20.0], [180.0, 140.0], [-20.0, 140.0]]; + let mut s = skewed_ramp(SampleType::U8, &q, 100.0, 80.0); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute(CMD, json!({"corners": q, "width": 100, "height": 80})).unwrap(); + let corner = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { From 1dcaa28b962f94356b945b2673c60bc1a934d5df Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:53:10 -0500 Subject: [PATCH 0487/1452] Quick Export: Same Folder asks before replacing the document's own file (#6599) (#6632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Export Preferences › Location = Same Folder, Quick Export wrote . beside the document's file with no check, so a document opened from photo.png was exported over photo.png itself. The app now opens the save dialog for that case, and file.export.quickExport refuses it without a `path`. Overwriting an earlier export beside a PSD is kept. Adds file_cmds::same_file (text, dev+inode on Unix, case-insensitive canonical path on Windows). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 23 ++++++++++++ crates/engine/src/web_cmds.rs | 41 +++++++++++++++------ crates/engine/src/web_cmds/tests.rs | 48 +++++++++++++++++++++++++ crates/ui-egui/src/export_dialog.rs | 5 ++- crates/ui-egui/src/file_dialog/tests.rs | 31 ++++++++++++++++ 5 files changed, 137 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..bfaca603de 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -219,6 +219,29 @@ pub(crate) fn stem(path: &str) -> String { } } +/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on +/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them). +pub fn same_file(a: &str, b: &str) -> bool { + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) { + return x.dev() == y.dev() && x.ino() == y.ino(); + } + } + #[cfg(windows)] + { + // NTFS and FAT compare names without case. + if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); + } + } + false +} + pub(crate) fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..25cd235b2c 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1024,27 +1024,48 @@ fn export_preferences(s: &mut Session, p: &Value) -> Result { Ok(json!({"section": "export", "values": s.prefs().get("export").unwrap_or(Value::Null)})) } +/// The Quick Export format's extension (Export Preferences › Format). +fn quick_export_ext(s: &Session) -> String { + serde_json::to_value(s.prefs().export.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()) +} + +/// Where Quick Export writes the active document with no `path` given: `.` beside +/// its file, when Export Preferences › Location is Same Folder and the document has a file. +pub fn quick_export_target(s: &Session) -> Option { + let same = s.prefs().export.quick_export_location == crate::prefs::ExportLocation::SameFolder; + let dp = s.active()?.path.as_deref().filter(|_| same)?; + let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + Some(join(&dir, &format!("{}.{}", stem(dp), quick_export_ext(s)))) +} + +/// Whether [`quick_export_target`] is the active document's own file (a document opened from +/// `photo.png` and exported as PNG), which Quick Export must not replace without asking (#6599). +pub fn quick_export_target_is_own_file(s: &Session) -> bool { + let own = s.active().and_then(|d| d.path.as_deref()); + quick_export_target(s).zip(own).is_some_and(|(target, own)| crate::file_cmds::same_file(&target, own)) +} + /// File › Export › Quick Export as : the format, quality, metadata, colour space and /// location from Export Preferences. `path` overrides the location ("ask" needs one). fn quick_export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.quickExport"; let prefs = s.prefs().export.clone(); - let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()); - let d = s.active().ok_or(EngineError::NoDocument)?; - let doc = d.doc.clone(); + let fmt = quick_export_ext(s); let path = match p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()) { Some(v) => v.to_string(), None => { - let same = serde_json::to_value(prefs.quick_export_location).ok().and_then(|v| v.as_str().map(str::to_string)).is_some_and(|v| v == "sameFolder"); - match (&d.path, same) { - (Some(dp), true) => { - let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); - join(&dir, &format!("{}.{fmt}", stem(dp))) - } - _ => return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")), + let Some(target) = quick_export_target(s) else { + return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")); + }; + // The document's own file is never replaced by an export the user didn't name (#6599). + if quick_export_target_is_own_file(s) { + return Err(bad(cmd, format!("the export would replace the document's own file {target}; give a `path`"))); } + target } }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let doc = d.doc.clone(); let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..1b91dcf726 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -437,6 +437,54 @@ fn export_preferences_drive_quick_export() { s.execute("prefs.reset", json!({"path": "export"})).unwrap(); } +/// #6599: with Location = Same Folder, a document opened from `photo.png` would be exported over +/// `photo.png` itself. Without a `path` that is refused and the file is left alone; named +/// explicitly (the save dialog's answer) it is written. +#[test] +fn quick_export_never_replaces_the_documents_own_file_unasked() { + let dir = tmp("quick_own_file"); + let mut s = session(8); + s.execute("file.export.exportPreferences", json!({"quickExportLocation": "sameFolder", "quickExportFormat": "png"})).unwrap(); + let own = format!("{dir}/photo.png"); + std::fs::write(&own, b"original").unwrap(); + s.active_mut().unwrap().path = Some(own.clone()); + assert_eq!(quick_export_target(&s), Some(own.clone())); + assert!(quick_export_target_is_own_file(&s)); + let e = s.execute("file.export.quickExport", json!({})).unwrap_err().to_string(); + assert!(e.contains("own file"), "{e}"); + assert_eq!(std::fs::read(&own).unwrap(), b"original", "the source is untouched"); + s.execute("file.export.quickExport", json!({"path": own})).unwrap(); + assert_eq!(decode(&own).dimensions(), (64, 48)); + // Another format, or a PSD's earlier export beside it, is still written without asking. + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg"})).unwrap(); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(format!("{dir}/photo.jpg"))); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "png"})).unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/photo.psd")); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(own)); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +#[test] +fn same_file_sees_through_other_names_for_one_file() { + let dir = tmp("same_file"); + let (a, b, c) = (format!("{dir}/a.png"), format!("{dir}/b.png"), format!("{dir}/c.png")); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&c, b"c").unwrap(); + assert!(crate::file_cmds::same_file(&a, &a)); + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); + assert!(!crate::file_cmds::same_file(&a, &c)); + assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); + #[cfg(unix)] + { + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); + } + #[cfg(windows)] + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); +} + #[test] fn webp_export_preferences_select_lossless_or_lossy_output() { let dir = tmp("quick_webp_quality"); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 9367045aff..275221e050 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -907,7 +907,10 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result { let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()); let same = serde_json::to_value(prefs.quick_export_location).ok().is_some_and(|v| v == "sameFolder"); let saved = app.session.active().is_some_and(|d| d.path.is_some()); - if same && saved { + // Same Folder writes beside the document's file without asking, except when that would + // replace the file itself (a document opened from photo.png): then the save dialog asks, + // so replacing it is the user's choice (#6599). + if same && saved && !photocraft_engine::web_cmds::quick_export_target_is_own_file(&app.session) { return quick_export(app, json!({})); } let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..bb827274ff 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -838,6 +838,37 @@ fn quick_export_selected_layers_writes_separate_pngs_with_one_prompt() { assert!(!app.ui.status_error, "{}", app.ui.status); } +/// #6599: Quick Export as PNG with Location = Same Folder asks before replacing the document's +/// own file (a document opened from photo.png), and keeps writing beside a PSD without a prompt. +#[test] +fn same_folder_quick_export_asks_before_replacing_the_documents_own_file() { + let dir = std::env::temp_dir().join(format!("photocraft-quick-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join("photo.png").to_string_lossy().into_owned(); + std::fs::write(&own, b"original").unwrap(); + let (mut app, open, _) = app(); + app.run("prefs.set", json!({"path": "export.quickExportLocation", "value": "sameFolder"})).unwrap(); + app.session.active_mut().unwrap().path = Some(own.clone()); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.export.quickExportAsPng", Value::Null).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(open.borrow().len(), 1, "the save dialog asks"); + assert_eq!(std::fs::read(&own).unwrap(), b"original", "nothing written before the answer"); + answer(&open, Some(FileDialogAnswer::SaveTo(own.clone()))); + app.poll_file_dialog(&ctx, None); + assert!(std::fs::read(&own).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"), "replaced once confirmed: {}", app.ui.status); + + // Beside a PSD, Same Folder still writes without a prompt. + app.session.active_mut().unwrap().path = Some(dir.join("photo.psd").to_string_lossy().into_owned()); + std::fs::write(&own, b"earlier export").unwrap(); + menus::invoke(&mut app, &ctx, "file.export.quickExportAsPng", Value::Null).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(open.borrow().is_empty(), "no dialog"); + assert!(std::fs::read(&own).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn cancelling_multi_layer_quick_export_writes_nothing() { let (mut app, open, written) = app(); From e196d8e47c24f60b34ce336bba467416797bf912 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:53:21 +0200 Subject: [PATCH 0488/1452] Opening a file that is already open shows its tab instead of a second copy (fixes #6598) (#6630) --- crates/ui-egui/src/file_open.rs | 47 ++++++++++++++++++++++ crates/ui-egui/src/file_open/tests.rs | 58 +++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 6 ++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index d752f7ce76..53e908bec9 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -107,6 +107,9 @@ impl PhotocraftApp { /// Read and open the file at `path` (see [`open_file`](Self::open_file)). #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) -> Result, String> { + if self.show_if_open(path) { + return Ok(Vec::new()); + } // Documents opening in the background are read on the worker too (a 2 GB PSB read // would block the window). Preset files (brushes, gradients) go the usual way. let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); @@ -133,6 +136,10 @@ impl PhotocraftApp { let batch = crate::jobs_ui::begin_batch(self); let mut opened = 0; for (seq, abs) in paths.iter().enumerate() { + // Not a member of the set: its tab is already in place. + if self.show_if_open(abs) { + continue; + } let order = crate::jobs_ui::OpenOrder { batch, seq }; let pending = self.jobs.opens.len(); match self.open_path(abs) { @@ -152,6 +159,26 @@ impl PhotocraftApp { opened } + /// Show the document already open from the file at `path`, or its tab while it is still + /// opening in the background, as Photoshop does, rather than open a second copy bound to the + /// same file (#6598). True when there was one. + pub(crate) fn show_if_open(&mut self, path: &str) -> bool { + let canon = canonical(path); + let same = |p: &str| same_file(p, path, canon.as_deref()); + if let Some(i) = self.session.documents().iter().position(|d| d.path.as_deref().is_some_and(same)) { + self.session.set_active(i); + self.jobs.focus = None; + } else if let Some(o) = self.jobs.opens.iter().find(|o| o.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { + // A template still loading opens as another Untitled, as it does once loaded. + self.jobs.focus = Some(o.job); + } else { + return false; + } + // The document count didn't change, so the Home screen wouldn't give way by itself. + self.ui.chrome.home = None; + true + } + /// Show "Couldn't open : " as an error (status bar + notice). pub fn open_failed(&mut self, name: &str, err: &str) { self.failed({ @@ -338,5 +365,25 @@ fn absolute(p: &str) -> String { p.to_string() } +/// `path` with symlinks, `.` and `..` and (on case-insensitive volumes) letter case resolved; +/// `None` when it can't be (a missing file). +#[cfg(not(target_arch = "wasm32"))] +fn canonical(path: &str) -> Option { + std::fs::canonicalize(path).ok() +} + +#[cfg(target_arch = "wasm32")] +fn canonical(_path: &str) -> Option { + None +} + +/// Whether an open document's path `p` names the file at `path` (`canon`: its [`canonical`] +/// form). The cheap comparisons come first: `p` is resolved on disk only when they fail, so an +/// open of a file that isn't open costs one resolution per document. A file moved or deleted +/// since it was opened only matches its old path as written. +fn same_file(p: &str, path: &str, canon: Option<&std::path::Path>) -> bool { + p == path || canon.is_some_and(|c| std::path::Path::new(p) == c || canonical(p).is_some_and(|q| q == c)) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 4d6581cd6d..b43a10a642 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -928,3 +928,61 @@ fn opening_type_in_a_missing_font_warns() { // The importer's own warning for the same font isn't doubled. assert_eq!(app.open_bytes("warned.psd", b"x").unwrap(), vec![warning]); } + +/// #6598: opening a file that is already open shows its document, as Photoshop does, rather than +/// a second copy bound to the same file (saving one would overwrite the other). The path is +/// compared as the file it names: another spelling of it, a symlink to it or, with background +/// opens, a file still opening finds the same tab. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn opening_an_open_file_again_shows_its_tab() { + let (dir, paths) = files("already-open", &["a.psd", "b.psd"], b"x"); + let (a, b) = (&paths[0], &paths[1]); + let (mut app, _) = app_with(Vec::new()); + app.open_path(a).unwrap(); + app.open_path(b).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.chrome.home = Some(2); + // Open Recent, File › Open, a drop: all go through `open_path`. + #[allow(unused_mut)] + let mut spellings = vec![a.clone(), dir.join(".").join("a.psd").to_string_lossy().into_owned()]; + #[cfg(unix)] + { + let link = dir.join("link.psd"); + std::os::unix::fs::symlink(a, &link).unwrap(); + spellings.push(link.to_string_lossy().into_owned()); + } + for again in spellings { + app.session.set_active(1); + app.ui.chrome.home = Some(2); + app.open_path(&again).unwrap(); + assert_eq!(doc_names(&app), ["a.psd", "b.psd"], "{again}"); + assert_eq!(app.session.active_index(), Some(0), "{again}"); + assert_eq!(app.ui.chrome.home, None, "the Home screen gives way to the document"); + } + assert!(app.session.documents()[1].is_dirty(), "b keeps its unsaved changes"); + // A set holding an open file opens only the others. + let (dir2, more) = files("already-open-set", &["c.psd"], b"x"); + assert_eq!(app.open_paths(&[b.clone(), more[0].clone()]), 1); + assert_eq!(doc_names(&app), ["a.psd", "b.psd", "c.psd"]); + // In the background: an open document, then a file still opening, show their tabs. + app.background_jobs = true; + app.open_path(a).unwrap(); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.active_index(), Some(0)); + let (dir3, late) = files("already-open-bg", &["d.psd"], b"x"); + app.open_path(&late[0]).unwrap(); + let job = app.jobs.opens[0].job; + app.jobs.focus = None; + app.open_path(&late[0]).unwrap(); + assert_eq!(app.jobs.opens.len(), 1, "one tab for the file still opening"); + assert_eq!(app.jobs.focus, Some(job)); + // A template still loading opens again as another Untitled, as it does once loaded. + let (dir4, template) = files("already-open-template", &["e.psdt"], b"x"); + app.open_path(&template[0]).unwrap(); + app.open_path(&template[0]).unwrap(); + assert_eq!(app.jobs.opens.len(), 3, "two Untitleds from the template"); + for d in [dir, dir2, dir3, dir4] { + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d322e26418..074d87a8b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2090,9 +2090,11 @@ mod open_recent_tests { let ctx = egui::Context::default(); invoke(&mut app, &ctx, "file.open", json!({ "path": p })).unwrap(); assert_eq!(app.ui.recent_files.first().map(String::as_str), Some(p.as_str())); - // Re-open via the recent entry. + // Re-open via the recent entry once closed (an open file shows its tab instead, #6598). + app.session.close(0); invoke(&mut app, &ctx, "file.openRecent.0", json!({})).unwrap(); - assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().and_then(|d| d.path.as_deref()), Some(p.as_str())); let _ = std::fs::remove_file(&path); } } From c9899e9c2942323b6c830bdb5f37005bb4d99125 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:55:31 +0200 Subject: [PATCH 0489/1452] Duotone: ink curve points must be finite (#7049) (#7050) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 4 ++++ crates/engine/src/mode_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..840f1c6096 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -444,6 +444,10 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result> { if let Some(pts) = ink.get("curve").and_then(Value::as_array) { let mut curve: Vec = pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect(); + // 1e39 is a valid JSON number but inf as f32, and a .pcraft with an inf curve can't be saved. + if curve.iter().any(|p| !p.input.is_finite() || !p.output.is_finite()) { + return Err(bad(cmd, "curve points must be finite numbers 0..100")); + } curve.sort_by(|a, b| a.input.total_cmp(&b.input)); if curve.len() >= 2 { d.curve = curve; diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..a78a11db7d 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -353,6 +353,20 @@ fn duotone_inks_and_display() { assert!(!session(4, 4, 8, "rgb").is_enabled("image.mode.duotone")); } +#[test] +fn duotone_curve_points_must_be_finite() { + let mut s = session(4, 4, 8, "gray"); + // 1e39 overflows f32 to inf, which the .pcraft writer refuses to save. + for curve in [json!([[0, 0], [100, 1e39]]), json!([[0, 0], [1e39, 50]]), json!([[-1e39, 0], [100, 100]])] { + let r = s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": curve}]})); + assert!(r.is_err(), "{curve} accepted"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); + } + s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": [[0, 0], [50, 70], [100, 100]]}]})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Duotone); + photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); +} + #[test] fn modes_round_trip_through_pcraft() { let mut s = session(12, 12, 8, "rgb"); From 589941a536bff8bd7bdea6d4b9be765ab94644a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:55:43 +0200 Subject: [PATCH 0490/1452] Save: refuse to replace a read-only file (#6600) (#6643) Saving writes a temporary file and renames it over the destination. A rename only needs the folder to be writable, so on Linux and macOS a read-only (chmod 444) file was silently replaced, while on Windows the rename failed with a vague "locked or read-only" access error. atomic_write now asks the AtomicFs whether the existing destination is read-only (new `is_readonly`, false by default for other implementors) and refuses before anything is written: "cannot save : the file is read-only; the original file was not changed". New files and writable files save as before. Photoshop refuses the same way; the user saves a copy with Save As. Tests: a fake AtomicFs reporting read-only gets the error with nothing written or renamed; a real file marked read-only keeps its bytes and mode and gets the clear message on every platform. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/format/src/atomic.rs | 16 +++++++ crates/format/src/atomic/tests.rs | 75 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..348374d3cf 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -13,6 +13,10 @@ //! if it does anyway, the save fails with a clear error. It never falls back to writing the //! destination in place. //! - **Unwritable folder:** the save fails with a clear error instead of writing in place. +//! - **Read-only file:** the save is refused before anything is written (#6600). A rename only +//! needs the folder to be writable, so without this check Linux and macOS would silently replace +//! a `chmod 444` file while Windows refuses; now every platform refuses, as Photoshop does, and +//! the user saves a copy with Save As. //! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters //! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync` //! works. [`sync_file`] falls back to the plain one there (#1336). @@ -30,6 +34,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests /// wrap it to inject failures. pub trait AtomicFs { + /// Whether the existing file at `path` is marked read-only (a missing file is not). A + /// read-only destination is never replaced (#6600). By default nothing is read-only. + fn is_readonly(&self, path: &Path) -> bool { + let _ = path; + false + } /// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]). fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>; /// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to @@ -91,6 +101,9 @@ impl AtomicFs for RealFs { let perms = std::fs::metadata(from)?.permissions(); std::fs::set_permissions(to, perms) } + fn is_readonly(&self, path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) + } } /// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush @@ -169,6 +182,9 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & _ => PathBuf::from("."), }; std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + if fs.is_readonly(&target) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("cannot save {}: the file is read-only; {UNCHANGED}", path.display()))); + } // Create a fresh temporary file next to the target (same directory, so the rename never // crosses a device). The name is unique per process and call; retry a few on collisions. diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..a85f998a16 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -176,6 +176,81 @@ fn lock_that_outlasts_the_retries_keeps_original() { assert_no_temp(&d.0); } +/// A file system that reports the target as read-only and records every write and rename. +#[derive(Default)] +struct ReadOnlyTarget { + writes: Cell, + renames: Cell, +} + +impl AtomicFs for ReadOnlyTarget { + fn is_readonly(&self, _path: &Path) -> bool { + true + } + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + self.writes.set(self.writes.get() + 1); + RealFs.write_new(tmp, bytes) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.renames.set(self.renames.get() + 1); + RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + RealFs.sync_dir(dir) + } + fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_permissions(from, to) + } +} + +/// #6600: a read-only destination is refused before anything is written, with an error that says +/// so, instead of being replaced by the rename (which only needs the folder to be writable). +#[test] +fn read_only_target_is_refused_before_anything_is_written() { + let d = TempDir::new("rofake"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = ReadOnlyTarget::default(); + let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("a.psd") && e.to_string().contains("read-only") && e.to_string().contains("not changed"), "{e}"); + assert_eq!((fs.writes.get(), fs.renames.get()), (0, 0), "nothing is written or renamed"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// #6600 on the real file system: a file marked read-only (`chmod 444`, or the Windows read-only +/// attribute) keeps its bytes and the save says why; a writable one still saves. +#[test] +fn read_only_file_on_disk_is_never_replaced() { + let d = TempDir::new("rofile"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&p, perms.clone()).unwrap(); + let r = atomic_write(&p, b"new"); + let unchanged = std::fs::read(&p).unwrap(); + let still_readonly = std::fs::metadata(&p).unwrap().permissions().readonly(); + // Make it writable again so the folder can be removed (Windows refuses to delete it otherwise). + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&p, perms).unwrap(); + let e = r.unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("the file is read-only;"), "a clear reason, not a failed rename: {e}"); + assert_eq!(unchanged, ORIGINAL); + assert!(still_readonly); + assert_no_temp(&d.0); + // New files and writable files are unaffected. + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + atomic_write(&d.0.join("fresh.psd"), b"x").unwrap(); +} + #[cfg(unix)] #[test] fn read_only_folder_errors_clearly_and_never_writes_in_place() { From 796ee3b1039fa35bac67639c5542669333478299 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:55:54 -0500 Subject: [PATCH 0491/1452] Image Assets: two layers naming the same file no longer overwrite each other (#6614) (#6668) * Image Assets: two layers naming the same file no longer overwrite each other (#6614) [WIP] Co-Authored-By: Claude Opus 5.5 * Image Assets: two layers naming the same file no longer overwrite each other (#6614) generate_assets wrote every requested asset, so two layers (or two default variants) naming one file silently replaced each other and the result listed the file twice. Each file is now written once per run: layers go top first, as the Layers panel lists them, the first writer keeps the name, and later ones are reported in errors. Names are compared ignoring case, since Logo.png and logo.png are one file on Windows and macOS. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 21 +++++++++++---- crates/engine/src/web_cmds/tests.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 25cd235b2c..e5006daa8f 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1358,10 +1358,15 @@ fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { } /// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors). +/// +/// Each file is written once per run (#6614): when two layers (or two default variants) name the +/// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`. +/// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and +/// macOS. pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); let mut defaults: Option> = None; - let mut jobs: Vec<(LayerId, AssetSpec)> = Vec::new(); + let mut by_layer: Vec> = Vec::new(); for (_, _, l) in doc.walk() { if let Some(d) = parse_defaults(&l.name, dpi) { defaults = Some(d); @@ -1370,22 +1375,26 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if matches!(l.content, LayerContent::Adjustment(_)) { continue; } - for spec in parse_asset_name(&l.name, dpi) { - jobs.push((l.id, spec)); - } + by_layer.push(parse_asset_name(&l.name, dpi).into_iter().map(|spec| (l.id, spec)).collect()); } + // Top layer first, as the Layers panel lists them (`walk` goes bottom to top). + let jobs: Vec<(LayerId, AssetSpec)> = by_layer.into_iter().rev().flatten().collect(); let variants: Vec = match defaults { Some(d) if !d.is_empty() => d, _ => vec![AssetDefault::default()], }; let mut files = Vec::new(); let mut errors = Vec::new(); + let mut written = std::collections::HashSet::new(); for (id, spec) in &jobs { for v in &variants { let r = (|| -> Result { let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); - asset_path_components(&rel)?; + let key = asset_path_components(&rel)?.join("/").to_lowercase(); + if written.contains(&key) { + return Err(other(format!("{rel}: another layer already writes this file; rename one of the layers"))); + } let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); @@ -1413,12 +1422,14 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if spec.format == "webp" { let (out, reported) = asset_output_path(dir, &rel)?; crate::file_cmds::save_doc(&wdoc, &out, None)?; + written.insert(key); return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; + written.insert(key); Ok(reported) })(); match r { diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 1b91dcf726..7bb6fe5646 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -594,6 +594,47 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// #6614: two layers, or two default variants, naming one file never overwrite each other: the +/// layer higher in the Layers panel keeps the file, whatever the case of its name, and the +/// others are reported. +#[test] +fn image_assets_never_write_one_file_twice() { + let dir = tmp("asset-collisions"); + let mut s = session(8); + s.edit("paint", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("Top", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + let mut d = (*s.active().unwrap().doc).clone(); + let id = |d: &photocraft_doc::Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + let (top, logo) = (id(&d, "Top"), id(&d, "Logo")); + d.layer_mut(top).unwrap().name = "Logo.PNG".into(); + d.layer_mut(logo).unwrap().name = "logo.png, logo.png".into(); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!(files.iter().map(|p| std::path::PathBuf::from(p.as_str())).collect::>(), vec![std::path::Path::new(&dir).join("Logo.png")]); + assert_eq!(decode(&files[0]).dimensions(), (4, 4), "the top layer keeps the file"); + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("another layer already writes this file")), "{errors:?}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "one file, not a second one under another case"); + + // Two default variants that land on one name: the first keeps it. + let dir = tmp("asset-variant-collisions"); + d.layer_mut(top).unwrap().name = "icon.png".into(); + d.layer_mut(logo).unwrap().name = "Logo".into(); + d.layers + .push(Layer::new("default 50% + 200% + 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }))); + let (files, errors) = generate_assets(&d, &dir); + let names: Vec<_> = files.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["icon.png", "icon@2x.png"]); + assert_eq!(decode(&files[0]).dimensions(), (2, 2), "the 50% variant keeps icon.png"); + assert_eq!(errors.len(), 1, "{errors:?}"); +} + #[test] fn image_assets_reject_traversal_from_names_and_defaults() { let base = tmp("asset-path-guard"); From 378231214a69c779150be82e4ee89760a166d165 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 23:27:27 +0530 Subject: [PATCH 0492/1452] Desktop app: `photocraft --help` prints usage; unknown options are usage errors (#6431) (#6435) * fix(desktop): `photocraft --help` prints usage; unknown options are usage errors (#6431) The desktop binary's argument loop took every argument it didn't know as a file, so `photocraft --help`, `-h` or a typo such as `--safegpu` opened the window and logged "Couldn't open --help". - `--help` / `-h` print the usage (every option and environment variable) and exit 0. - Any other argument starting with `-` (except macOS `-psn_`) is named on stderr and the launch exits 2 before the log file or window, along with any bad `--control` port (`control_args_exit_code` becomes `usage_exit_code`). - `--` ends the options, so a file whose name starts with `-` opens. - `--help` and `--version` write without `println!`, so a closed pipe (`photocraft --help | head -1`) can't panic. Tests: apps/photocraft/tests/command_line_usage.rs runs the binary with no display; 4 of its 6 tests fail on the old code. Co-Authored-By: Claude Opus 5.5 * test(desktop): usage tests exit before the window opens; fmt smart_filter_authorization `arguments_after_double_dash_are_files` and `macos_process_serial_number_is_still_ignored` passed only valid arguments, so the app went on to open its window. Removing DISPLAY makes that fail fast on Linux, but Windows runners have a desktop and both tests hung until nextest's 600 s timeout. They now add a bad `--control` value so the launch always fails with the usage errors, and assert stderr names only that error (no "unknown option"). Also `cargo fmt` crates/automation/tests/smart_filter_authorization.rs, left unformatted on main by #5684, which failed the Format step of every PR's ubuntu test job. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/main.rs | 102 ++++++++++++++++---- apps/photocraft/tests/command_line_usage.rs | 95 ++++++++++++++++++ 2 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 apps/photocraft/tests/command_line_usage.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index abd70eb418..721bbc7349 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -3,7 +3,12 @@ //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] //! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] -//! [--frame-latency ] [--stroke-trace ] [--new-instance] [files…]` +//! [--frame-latency ] [--stroke-trace ] [--new-instance] [--] [files…]` +//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version). +//! +//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo +//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file +//! whose name starts with `-` goes after `--`. //! //! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new //! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any @@ -149,21 +154,63 @@ fn parse_control_port(value: &str, source: &str) -> Result { value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) } -/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None` -/// when there are none, else 2 (a command-line usage error), never 0. -fn control_args_exit_code(errors: &[String]) -> Option { +/// Process exit status for the collected usage errors (a bad `--control` / +/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a +/// command-line usage error), never 0. +fn usage_exit_code(errors: &[String]) -> Option { if errors.is_empty() { None } else { Some(2) } } +/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. +const USAGE: &str = "\ +Usage: photocraft [options] [--] [files…] + +Opens PhotoCraft with the given files. + +Options: + -h, --help Print this help and exit + --version Print the version, commit and build date and exit + --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) + --in-window-menus macOS: keep the menus inside the window instead of the menu bar + --new-instance Windows and Linux: start a separate PhotoCraft instead of opening + the files in the one already running + --present-mode How frames reach the display: fifo, autovsync or novsync + --frame-latency Frames queued for the display: 1 to 3 + --stroke-trace + Time each brush stroke's frames + --control Start the localhost JSON-lines control server on this port + --control-token <64-hex> Control server token (default: a new random one, printed) + --control-token-file Read the control server token from this file, or create it + with a new one + --automation-read-root Directory the control server may read files from + --automation-write-root + Directory the control server may write files to + -- Treat every later argument as a file, even one starting with `-` + +A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. +Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, +PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, +PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, +PHOTOCRAFT_CONFIG_DIR. +"; + +/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when +/// stdout is closed or a pipe (`photocraft --help | head -1`). +fn print_stdout(text: &str) { + use std::io::Write as _; + let mut out = std::io::stdout().lock(); + let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); +} + #[cfg(test)] mod control_port_tests { - use super::{control_args_exit_code, parse_control_port}; + use super::{parse_control_port, usage_exit_code}; #[test] fn control_port_errors_exit_non_zero() { - assert_eq!(control_args_exit_code(&[]), None); + assert_eq!(usage_exit_code(&[]), None); let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(control_args_exit_code(&errors), Some(2)); + assert_eq!(usage_exit_code(&errors), Some(2)); } #[test] @@ -191,11 +238,11 @@ fn main() -> eframe::Result { // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); let mut control_port: Option = None; - let mut control_arg_errors: Vec = Vec::new(); + let mut usage_errors: Vec = Vec::new(); if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Err(error) => usage_errors.push(error), } } let mut control_token = None; @@ -214,18 +261,32 @@ fn main() -> eframe::Result { let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args_os().skip(1); + // After `--`, every argument is a file, even one that starts with `-`. + let mut only_files = false; while let Some(a) = args.next() { + if only_files { + match a.into_string() { + Ok(s) => files.push(s), + Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()), + } + continue; + } match a.to_str() { + Some("--") => only_files = true, + Some("--help" | "-h") => { + print_stdout(USAGE); + return Ok(()); + } Some("--control") => match args.next() { Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Err(error) => usage_errors.push(error), }, - None => control_arg_errors.push("--control: missing port value (expected `--control `)".to_string()), + None => usage_errors.push("--control: missing port value (expected `--control `)".to_string()), }, Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { Some(Ok(token)) => control_token = Some(token), - Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), None => control_token = None, }, Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), @@ -264,23 +325,28 @@ fn main() -> eframe::Result { } Some("--new-instance") => new_instance = true, Some("--version") => { - println!("photocraft {}", photocraft_engine::build_info::long_version()); + print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); return Ok(()); } // Old macOS passes a process serial number when launched from Finder. Some(s) if s.starts_with("-psn_") => {} + // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window + // and then fail to open the flag as a file (#6431). + Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), Some(s) => files.push(s.to_owned()), None => unreadable_paths.push(a.to_string_lossy().into_owned()), } } - // A malformed control port must not silently drop the control server (issue #701): name the - // bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo. - // Exit status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = control_args_exit_code(&control_arg_errors) { - for error in &control_arg_errors { + // A malformed control port must not silently drop the control server (issue #701), and an + // unknown option must not open the window (#6431): name the bad argument and fail the launch, + // like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage + // error, so a launcher sees the failure. + if let Some(code) = usage_exit_code(&usage_errors) { + for error in &usage_errors { eprintln!("photocraft: {error}"); } + eprintln!("Run `photocraft --help` for usage."); std::process::exit(code); } diff --git a/apps/photocraft/tests/command_line_usage.rs b/apps/photocraft/tests/command_line_usage.rs new file mode 100644 index 0000000000..c4c745ea9a --- /dev/null +++ b/apps/photocraft/tests/command_line_usage.rs @@ -0,0 +1,95 @@ +//! `photocraft --help` and unknown options must not open the window (#6431): before the fix, +//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as +//! `--safegpu` launched the GUI and logged "Couldn't open --help". +//! +//! These run the real binary, and every case must exit before the window opens: Windows and +//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. +//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the +//! other usage errors, so stderr shows whether those arguments were taken as unknown options. + +/// Run `photocraft` with `args` in an isolated config dir; return the exit +/// status, stdout and stderr. +fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + .args(args) + .env("PHOTOCRAFT_CONFIG_DIR", &dir) + .env_remove("PHOTOCRAFT_CONTROL_PORT") + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .output() + .expect("failed to run photocraft binary"); + std::fs::remove_dir_all(&dir).ok(); + (output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned()) +} + +#[test] +fn help_prints_usage_and_exits_0() { + for flag in ["--help", "-h"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); + assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); + for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { + assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); + } + assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); + } +} + +#[test] +fn help_wins_over_later_arguments() { + let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); +} + +#[test] +fn unknown_options_exit_2_naming_them() { + for flag in ["--safegpu", "-x", "--help=yes"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); + assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); + assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + } +} + +#[test] +fn unknown_option_errors_are_reported_with_control_port_errors() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); +} + +#[test] +fn arguments_after_double_dash_are_files() { + // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage + // nor is an unknown option; only the bad port is reported. + let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn macos_process_serial_number_is_still_ignored() { + // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. + let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn new_instance_is_a_known_option() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} From ba40454b18952ee595662a826396e81dac958eae Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 19:57:39 +0200 Subject: [PATCH 0493/1452] Logging: a second launch leaves the running instance's log alone (#6432) (#6478) logging::rotate renamed photocraft.log up the chain on every start without knowing whether another instance still wrote to it. The running instance kept its handle, so after a second launch its live log was .1.log, after a third .2.log, and a fourth launch deleted it. The instance that writes photocraft.log now holds an exclusive lock on photocraft.lock until it exits (a separate file, since Windows locks are mandatory and would keep the log itself from being opened). A start that finds that lock held leaves the chain alone and writes photocraft-.log, marked by photocraft-.lock. Those side-by-side logs are pruned to the newest KEEP once their instances have exited; a held one is never touched. Rotation and pruning run under rotate.lock so two starts at once can't interleave. On a file system without locks every start rotates as before. --- apps/photocraft/src/logging.rs | 186 ++++++++++++++++++++++++++++++--- docs/development.md | 2 +- 2 files changed, 175 insertions(+), 13 deletions(-) diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 292428b8ce..33b7df2462 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -4,7 +4,9 @@ //! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no //! terminal, so the file is what a bug report can attach: `/logs/photocraft.log` //! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to -//! `.2`), so the log of a run that crashed survives the next launch. +//! `.2`), so the log of a run that crashed survives the next launch. A start while another +//! instance is still running leaves that instance's log alone and writes `photocraft-.log` +//! (see [`rotate`]). //! //! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are //! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, @@ -15,7 +17,7 @@ //! written once the file is attached. Writing never panics: a file that can't be created or //! written leaves standard error as the only sink. -use std::fs::File; +use std::fs::{File, OpenOptions, TryLockError}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf { if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) } } -/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) -/// and create a fresh, empty log file in `dir` (created if missing). -pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { +/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock +/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could +/// not be opened for a bug report while the app runs. +pub const OWNER_LOCK: &str = "photocraft.lock"; +/// Held while one start rotates and prunes the logs, so two starts at once can't interleave. +pub const ROTATE_LOCK: &str = "rotate.lock"; + +/// The log of an instance started while another one was writing [`LOG_FILE`]. +fn instance_log(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.log")) +} + +/// The lock file that marks [`instance_log`] as in use. +fn instance_lock(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.lock")) +} + +/// The process id in an [`instance_log`] file name. +fn instance_pid(name: &str) -> Option { + let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn open_lock(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path) +} + +/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it. +fn try_hold(file: &File) -> std::io::Result { + match file.try_lock() { + Ok(()) => Ok(true), + Err(TryLockError::WouldBlock) => Ok(false), + Err(TryLockError::Error(e)) => Err(e), + } +} + +/// Open a fresh, empty log file in `dir` (created if missing). +/// +/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `..log`, the oldest +/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the +/// chain is left alone and this start writes `photocraft-.log` instead: rotating would move +/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side +/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system +/// without locks every start rotates, as before. +pub fn rotate(dir: &Path) -> std::io::Result { std::fs::create_dir_all(dir)?; + // Waits only for another start's few file operations; without locking support, go on. + let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok()); + let log = rotate_locked(dir); + drop(guard); + log +} + +fn rotate_locked(dir: &Path) -> std::io::Result { + prune_instance_logs(dir); + let owner = open_lock(&dir.join(OWNER_LOCK))?; + let state = try_hold(&owner); + if let Ok(false) = state { + let pid = std::process::id(); + let lock = open_lock(&instance_lock(dir, pid))?; + let held = try_hold(&lock).unwrap_or(false); + let path = instance_log(dir, pid); + let file = File::create(&path)?; + return Ok(LogFile { path, file, owner: held.then_some(lock) }); + } + // This start holds the owner lock (or the file system has no locks). + let held = state.unwrap_or(false); for n in (1..=KEEP).rev() { match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), @@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } let path = numbered(dir, 0); let file = File::create(&path)?; - Ok((path, file)) + Ok(LogFile { path, file, owner: held.then_some(owner) }) +} + +/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`]. +/// A log whose lock file is held belongs to a running instance and is never touched. Failures +/// are ignored: pruning only saves disk space. +fn prune_instance_logs(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut finished: Vec<(SystemTime, u32)> = Vec::new(); + for entry in entries.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue }; + // A missing lock file means nobody holds it; an unknown lock state counts as running. + let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) { + Ok(lock) => !try_hold(&lock).unwrap_or(false), + Err(_) => false, + }; + if !running { + let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); + finished.push((modified, pid)); + } + } + finished.sort_unstable_by(|a, b| b.cmp(a)); + for &(_, pid) in finished.iter().skip(KEEP) { + let _ = std::fs::remove_file(instance_log(dir, pid)); + let _ = std::fs::remove_file(instance_lock(dir, pid)); + } +} + +/// A log file opened by [`rotate`]. +pub struct LogFile { + pub path: PathBuf, + pub file: File, + /// The held lock that marks `path` as in use; `None` where locking isn't supported. + owner: Option, } /// One formatted log line: ` [] : \n`. @@ -205,17 +306,20 @@ pub struct AppLogger { filter: Filter, stderr: bool, sink: Mutex, + /// The lock that marks the attached log file as in use (see [`rotate`]); held until exit. + owner: Mutex>, } impl AppLogger { pub fn new(filter: Filter, stderr: bool) -> AppLogger { - AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) } } /// Rotate the logs in `dir` and send records to the fresh file; returns its path. pub fn attach_dir(&self, dir: &Path) -> Result { - let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + *self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner; Ok(path) } } @@ -318,7 +422,7 @@ mod tests { fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { let dir = temp_dir("rotate"); for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); + let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate"); assert_eq!(path, dir.join(LOG_FILE)); assert_eq!(std::fs::read_to_string(&path).expect("read"), ""); std::fs::write(&path, format!("run {run}")).expect("write"); @@ -330,6 +434,64 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #6432: a second launch moved the running instance's live log to `.1.log`, a third to + /// `.2.log`, and a fourth deleted it. + #[test] + fn a_second_start_leaves_the_running_instance_log_alone() { + let dir = temp_dir("second"); + let mut first = rotate(&dir).expect("first start"); + assert_eq!(first.path, dir.join(LOG_FILE)); + first.file.write_all(b"first, before\n").expect("write"); + for _ in 0..KEEP + 1 { + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + assert_eq!(read(&second.path), ""); + } + first.file.write_all(b"first, after\n").expect("write"); + assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n"); + assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated"); + // Once the first instance is gone, the next start rotates as before. + drop(first); + let third = rotate(&dir).expect("third start"); + assert_eq!(third.path, dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n"); + drop(third); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn instance_logs_are_pruned_only_when_no_one_holds_them() { + let dir = temp_dir("prune"); + let first = rotate(&dir).expect("first start"); + // Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file + // (as if its lock file had been removed), the others an unlocked one. + let now = SystemTime::now(); + for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] { + let path = instance_log(&dir, pid); + std::fs::write(&path, format!("run {pid}")).expect("write"); + File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime"); + if pid != 1 { + std::fs::write(instance_lock(&dir, pid), "").expect("lock file"); + } + } + // One older still running: its log is never removed, however old. + let live = instance_log(&dir, 5); + std::fs::write(&live, "live").expect("write"); + File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime"); + let held = File::create(instance_lock(&dir, 5)).expect("lock file"); + held.try_lock().expect("hold"); + + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + // The newest KEEP finished ones stay, the rest go with their lock files. + assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists()); + assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists()); + assert!(!instance_lock(&dir, 2).exists()); + assert_eq!(read(&live), "live"); + drop((first, second, held)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { let dir = temp_dir("blocked"); @@ -355,7 +517,7 @@ mod tests { #[test] fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); sink.write("early 1\n"); sink.write("early 2\n"); @@ -368,7 +530,7 @@ mod tests { #[test] fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); for i in 0..MAX_PENDING + 5 { sink.write(&format!("line {i}\n")); @@ -384,7 +546,7 @@ mod tests { #[test] fn the_file_stops_at_its_size_cap_with_one_note() { let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(100); sink.attach(file); for i in 0..50 { diff --git a/docs/development.md b/docs/development.md index 035efbbcf0..239f9456bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,7 +99,7 @@ If the device is lost while running (#243), every GPU entry point checks the dev ## Logs -The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` and command-line errors write no file. +The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. By default PhotoCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,photocraft_gpu=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`photocraft*=debug`). The logger is `apps/photocraft/src/logging.rs`; the web build logs to the browser console instead. From 482fd899cfd6c6a55fbd68fbb80cee6c2bc8a9cc Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 12:02:56 -0600 Subject: [PATCH 0494/1452] Build: take craft-docpath from the merged craft-libs rev craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07, which predates resolve_absolute and the Windows colon refusal. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c3369fdec..a949941fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-print" diff --git a/Cargo.toml b/Cargo.toml index 1d65deac3a..5965edc389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From f94fb6cd595f187bbedf6dc00bc3468457139d77 Mon Sep 17 00:00:00 2001 From: RehanKhatkar Date: Sun, 18 Oct 2026 01:37:26 +0530 Subject: [PATCH 0495/1452] Open EXR Parts: the menu asks for a file and shows the parts dialog (#7169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open EXR Parts… ran the engine command with no params, failing with `missing "path"` and never showing a dialog: the picker and parts form in view_cmds were never reached because the id was missing from `wraps()`. Add it, with a regression test that drives the menu through the file picker, the parts dialog and OK. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog/tests.rs | 31 +++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 1 + 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bb827274ff..ed7f5ad419 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -161,6 +161,37 @@ fn answer(open: &Open, answer: Option) { std::thread::spawn(move || reply.send(answer)).join().unwrap(); } +#[test] +fn menu_open_exr_parts_asks_for_a_file_then_shows_the_parts_dialog() { + // Before the fix the menu ran the bare command: `missing "path"`, and no dialog (#7169). + let (mut app, open, _) = app(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "render.exr", &Default::default()).unwrap().bytes; + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("photocraft-menu-exr-parts-{}-{unique}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("render.exr"); + std::fs::write(&path, &bytes).unwrap(); + let path = path.to_string_lossy().into_owned(); + let ctx = egui::Context::default(); + let r = menus::invoke(&mut app, &ctx, "file.openExrParts", json!({})).unwrap(); + assert_eq!(r["fileDialog"], "open", "{r}"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["exr"])); + answer(&open, Some(FileDialogAnswer::Paths(vec![path.clone()]))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + let dialog = app.ui.dialogs.iter().find(|d| d.fields.get("__command") == Some(&json!("file.openExrParts"))).expect("the parts dialog is open"); + assert_eq!(dialog.fields["path"], json!(path)); + let parts: Vec<&String> = dialog.fields.keys().filter(|k| k.starts_with("part/")).collect(); + assert_eq!(parts.len(), 1, "{:?}", dialog.fields); + assert_eq!(dialog.fields[parts[0].as_str()], json!(true), "the first colour part is preselected"); + let id = dialog.id; + let documents = app.session.documents().len(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.documents().len(), documents + 1); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn menu_place_starts_transform_for_each_chosen_image() { for command in ["file.placeEmbedded", "file.placeLinked"] { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..117586d060 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -579,6 +579,7 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" | "file.placeLinked" From 1d65bd605b25e325a12ab3e4d846d94ba41eedb7 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:20:44 -0400 Subject: [PATCH 0496/1452] Web: Place Embedded and canvas drops place images (#5749) (#5817) * Web: Place Embedded and canvas drops place images (#5749) File > Place Embedded was disabled in the browser (the engine command reads a path), though the menu path reads the picked file's bytes. Files dropped on the canvas were always opened as new documents. The web shell now tracks the drag position, and drops on the canvas are placed from their bytes. Co-Authored-By: Claude Opus 5.5 * Format (cargo fmt) Co-Authored-By: Claude Opus 5.5 * Narrow to the web canvas-drop half: Place Embedded enablement landed in #5585 Drop the picker test that covered the part #5585 now owns. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 74 ++++++++++++++++++++++----- crates/ui-egui/src/file_open.rs | 50 +++++++++++++++--- crates/ui-egui/src/file_open/tests.rs | 29 +++++++++++ crates/ui-egui/src/lib.rs | 18 +++++-- 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 786165b532..4ecad80ae4 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..6fd3620483 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -1,6 +1,8 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -79,11 +81,13 @@ pub fn start() { Box::new(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); + let place_inbox: Inbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); preset_warnings.extend(warnings); - let mut app = PhotocraftApp::new(session, services(inbox.clone())); + let drag = listen_drag(&pen_target); + let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag))); if !preset_warnings.is_empty() { photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None); } @@ -97,7 +101,7 @@ pub fn start() { } let unsaved = Arc::new(AtomicBool::new(false)); guard_unload(unsaved.clone()); - Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets })) + Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets })) }), ) .await; @@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty } } +/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`, +/// `clientY`): the last `dragover` or `drop` event's. +type DragPos = Rc>>; + +/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the +/// page gets no pointer events during a drag, so this is where a drop lands: on the document's +/// canvas it places the files, elsewhere it opens them (#5749). +fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos { + use wasm_bindgen::closure::Closure; + let pos = DragPos::default(); + for kind in ["dragover", "drop"] { + let pos = pos.clone(); + // A DragEvent is a MouseEvent: only its client position is read. + let cb = Closure::::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32)))); + if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() { + cb.forget(); + } + } + pos +} + +/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe +/// maps pointer events (relative to the canvas, divided by the zoom factor). +fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn { + Box::new(move |ctx: &egui::Context| { + let (x, y) = pos.get()?; + let rect = canvas.get_bounding_client_rect(); + let zoom = ctx.zoom_factor(); + (zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom)) + }) +} + /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is /// refreshed every frame by [`WebShell`]. @@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, Stri } /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously, -/// so the app's own drop path can't handle them) and feed them through the inbox. +/// so the app's own drop path can't handle them) and feed them through the inboxes. struct WebShell { app: PhotocraftApp, + /// Dropped files to open as documents. inbox: Inbox, + /// Dropped files to place on the active document's canvas. + place_inbox: Inbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -285,17 +324,26 @@ impl WebShell { impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); + if !dropped.is_empty() { + let names: Vec = + dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect(); + // Decided now, where the drop landed: on the document's canvas the files are placed + // as layers (as on the desktop), elsewhere opened. + let places = self.app.dropped_on_canvas(ctx, &names); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); + // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); + for ((f, name), place) in files { + match f.bytes_async().await { + Ok(bytes) => { + let queue = if place { &place_inbox } else { &inbox }; + queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -333,7 +381,7 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox) -> Services { +fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { @@ -384,6 +432,8 @@ fn services(inbox: Inbox) -> Services { photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) })), inbox: Some(inbox), + place_inbox: Some(place_inbox), + cursor_pos: Some(cursor_pos), // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 53e908bec9..1cf61e98d3 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -33,6 +33,15 @@ pub enum DropTarget { Elsewhere, } +/// A file dropped on the canvas, waiting its turn to be placed. +pub(crate) enum DropPlace { + /// Dropped onto the window (desktop): read from its path, or through egui. + File(egui::DroppedFileHandle), + /// Read by the shell itself (the browser reads dropped files asynchronously): its name and + /// contents, delivered through [`Services::place_inbox`](crate::Services::place_inbox). + Bytes(String, Vec), +} + /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. @@ -211,7 +220,7 @@ impl PhotocraftApp { for f in files { let name = dropped_name(&f); if target == DropTarget::Canvas && !crate::preset_files_ui::is_preset_file(&name) { - self.drop_places.push_back(f); + self.drop_places.push_back(DropPlace::File(f)); continue; } match self.open_dropped_file(&f, &name, slot) { @@ -222,6 +231,24 @@ impl PhotocraftApp { } } + /// Which of the files called `names`, dropped onto the window just now, are placed on the + /// active document's canvas rather than opened, for a shell that reads dropped files itself + /// (the browser reads them asynchronously, so the web shell can't hand them to + /// [`open_dropped`](Self::open_dropped)). It delivers those through + /// [`Services::place_inbox`](crate::Services::place_inbox) and the rest through + /// [`Services::inbox`](crate::Services::inbox). The pointer comes from + /// [`Services::cursor_pos`](crate::Services::cursor_pos). As with a desktop canvas drop, a Free + /// Transform still open is applied first (#4833). + pub fn dropped_on_canvas(&mut self, ctx: &egui::Context, names: &[String]) -> Vec { + let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; + let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); + if places.contains(&true) && self.ui.transform.is_some() { + crate::transform_tool::commit(self); + } + places + } + /// Where a drop at `at` lands. Over a window or dialog it's neither the canvas nor the tabs. pub(crate) fn drop_target(&self, ctx: &egui::Context, at: Option) -> DropTarget { let Some(p) = at.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order == egui::Order::Background)) else { @@ -259,11 +286,20 @@ impl PhotocraftApp { /// transform is in progress (so several files are placed and transformed one after another). pub(crate) fn place_next_dropped(&mut self, ctx: &egui::Context) { while self.ui.transform.is_none() { - let Some(f) = self.drop_places.pop_front() else { return }; - let placed = if f.path().is_absolute() { - self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()})) - } else { - crate::read_dropped(&*f).and_then(|bytes| self.place_bytes(&dropped_name(&f), bytes, None)) + let Some(next) = self.drop_places.pop_front() else { return }; + let (name, placed) = match next { + DropPlace::File(f) if f.path().is_absolute() => { + (dropped_name(&f), self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()}))) + } + DropPlace::File(f) => { + let name = dropped_name(&f); + let placed = crate::read_dropped(&*f).and_then(|bytes| self.place_bytes(&name, bytes, None)); + (name, placed) + } + DropPlace::Bytes(name, bytes) => { + let placed = self.place_bytes(&name, bytes, None); + (name, placed) + } }; match placed { Ok(_) => { @@ -271,7 +307,7 @@ impl PhotocraftApp { self.ui.status = e; } } - Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &dropped_name(&f)), ("error", &e)])), + Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &name), ("error", &e)])), } } } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index b43a10a642..00db3ef96e 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -173,6 +173,35 @@ fn the_web_opens_every_selected_file() { assert!(app.ui.notices.iter().any(|n| n.error && n.title.contains("broken.png")), "{:?}", app.ui.notices); } +/// #5749: the web shell reads dropped files itself, so it asks where they landed: image files +/// dropped on the canvas are placed from their bytes, presets and drops elsewhere are opened. +#[test] +fn web_drops_onto_the_canvas_are_placed_from_their_bytes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let names = vec!["photo.png".to_string(), "brushes.abr".to_string()]; + // No pointer known: nothing is placed. + assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false]); + let at = Rc::new(std::cell::Cell::new(egui::pos2(400.0, 300.0))); + let reported = at.clone(); + app.services.cursor_pos = Some(Box::new(move |_: &egui::Context| Some(reported.get()))); + assert_eq!(app.dropped_on_canvas(&ctx, &names), [true, false]); + at.set(egui::pos2(20.0, 20.0)); + assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false], "off the canvas: opened"); + let layers = layer_count(&app); + app.drop_places.push_back(crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3))); + app.drop_places.push_back(crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec())); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + crate::transform_tool::commit(&mut app); + // The undecodable one is reported, not placed. + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png")), "{:?}", app.ui.notices); + assert!(app.drop_places.is_empty()); +} + #[test] fn pcraft_documents_save_in_place_but_flat_files_ask() { let (mut app, written) = app_with(vec![None]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 496b208288..5adfd78860 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -485,8 +485,13 @@ pub struct Services { /// Preferences ▸ General ▸ Beep When Done: the platform's default beep, played when a /// background job finishes successfully. `None` in the web build (and in tests). pub beep: Option, - /// Files delivered asynchronously (web drag-and-drop): drained every frame. + /// Files delivered asynchronously (web drag-and-drop): drained every frame and opened as + /// documents. pub inbox: Option, + /// Files delivered asynchronously for a drop onto the active document's canvas (web + /// drag-and-drop, routed by [`PhotocraftApp::dropped_on_canvas`]): placed as layers like a + /// desktop canvas drop (Place Embedded, each in Free Transform). + pub place_inbox: Option, /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, @@ -513,8 +518,9 @@ pub struct Services { pub append_text: Option, /// OS requests (macOS open-documents / quit Apple events), polled every frame. pub os_events: Option, - /// The pointer position read from the OS (desktop): winit 0.30's file drops carry none, and - /// the window gets no pointer events during an OS drag (see `file_open::DropTarget`). + /// The pointer position read from the OS (desktop) or from the page's drag events (web): + /// file drops carry none, and the window gets no pointer events during an OS drag (see + /// `file_open::DropTarget`). pub cursor_pos: Option, /// Caps Lock toggled on (desktop; `None` on Wayland and the web). Read once per frame, so /// the canvas can show the precise crosshair for painting tools, whatever the preference. @@ -672,7 +678,7 @@ pub struct PhotocraftApp { /// The document tab strip last frame: a drop there opens the file at the slot under it. pub(crate) tab_strip: Option, /// Files dropped on the canvas still to place, one Free Transform at a time. - pub(crate) drop_places: std::collections::VecDeque, + pub(crate) drop_places: std::collections::VecDeque, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, pub fps: f32, @@ -1917,6 +1923,10 @@ impl eframe::App for PhotocraftApp { self.open_failed(&name, &e); } } + // Web drops onto the canvas, read since the last frame: placed below, one at a time. + let to_place: Vec<(String, Vec)> = + self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + self.drop_places.extend(to_place.into_iter().map(|(name, bytes)| file_open::DropPlace::Bytes(name, bytes))); // Finder double-click / Open With / Dock drops (macOS open-documents events). self.drain_os_events(ctx); // While files are dragged over the window it gets no pointer events: keep frames coming so From 2cf274f991aa147846f714c1efb7829fb688a07a Mon Sep 17 00:00:00 2001 From: Derek Gliwa <2328690+dgliwa@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:38:11 -0500 Subject: [PATCH 0497/1452] Brushes: accept .abr files up to 1 GiB Import Brushes refused .abr files over 512 MiB: the engine's preset file cap and the .abr decoder's sample budget were both 512 MiB. Raise both to 1 GiB for brush files only; gradient, swatch, action and image-tip imports keep the 512 MiB cap. A compile-time assert keeps the reader and decoder caps equal. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/preset_import_cmds.rs | 35 ++++++++++++++++++++++--- crates/io/examples/gen_abr_library.rs | 2 +- crates/psd/src/abr.rs | 2 +- crates/psd/src/patterns.rs | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8da6cee8a0..2dcac20dfa 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -27,12 +27,19 @@ fn always(_: &Session) -> std::result::Result<(), String> { /// Largest accepted file (Photoshop's biggest bundled sets are well under this). pub const MAX_FILE_BYTES: u64 = 512 << 20; +/// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`]. +pub const MAX_ABR_FILE_BYTES: u64 = 1 << 30; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { + file_bytes_within(p, cmd, what, MAX_FILE_BYTES) +} + +/// [`file_bytes`] with a size cap of `max` bytes. +fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec, String)> { if let Some(d) = p.get("data") { let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; - if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 { + if s.len() as u64 > max / 3 * 4 + 4 { return Err(bad(cmd, "`data` is too large")); } let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; @@ -41,7 +48,7 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); - if len > MAX_FILE_BYTES { + if len > max { return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; @@ -72,7 +79,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { move |ctx| { ctx.progress(0.0, "Importing brushes"); let p = ¶ms; - let (bytes, stem) = file_bytes(p, cmd, ".abr file")?; + let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let group = match p.get("group") { Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), None if !stem.is_empty() => stem, @@ -526,6 +533,28 @@ mod tests { assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); } + #[test] + fn abr_files_get_a_larger_size_cap() { + // The decoder must not refuse what the reader accepts. + const _: () = assert!(MAX_ABR_FILE_BYTES == 1 << 30 && photocraft_psd::abr::MAX_TOTAL_BYTES as u64 == MAX_ABR_FILE_BYTES); + let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cap.abr"); + std::fs::write(&path, [0u8; 10]).unwrap(); + let p = json!({"path": path.to_string_lossy()}); + assert!(file_bytes_within(&p, "t", "f", 10).is_ok()); + assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); + // A sparse file between the two caps: refused as a .grd, which is checked before reading. + let big = dir.join("big.grd"); + std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); + let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); + let data = json!({"data": "A".repeat(16)}); + assert!(file_bytes_within(&data, "t", "f", 12).is_ok()); + assert!(file_bytes_within(&data, "t", "f", 8).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn unique_names() { let taken = vec!["A".to_string(), "a 2".to_string()]; diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index 2736e49d06..a72b5ed995 100644 --- a/crates/io/examples/gen_abr_library.rs +++ b/crates/io/examples/gen_abr_library.rs @@ -10,7 +10,7 @@ //! //! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000], //! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100], -//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap. +//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 1 GiB decoded cap. #![allow(clippy::unwrap_used, clippy::expect_used)] use photocraft_paint::procedural::*; diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index c7cf3eb3cc..025aad294f 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -44,7 +44,7 @@ pub const MAX_EDGE: u32 = 8192; /// Most brushes read from one file. pub const MAX_BRUSHES: usize = 20_000; /// Cap on decoded sample bytes over the whole file. -pub const MAX_TOTAL_BYTES: usize = 512 << 20; +pub const MAX_TOTAL_BYTES: usize = 1 << 30; /// A sampled tip: one grayscale plane where the maximum value is full paint. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 39132df9b0..7f845f525e 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -29,7 +29,7 @@ const MAX_EDGE: u32 = 30_000; /// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat` /// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel /// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern -/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`. +/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20; // A PackBits run packet emits at most 128 bytes from a 2-byte packet. const MAX_RLE_EXPANSION: u64 = 64; From 86f6199b01471fa2bf8834db43e14a3d4705ef49 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 16:53:11 -0400 Subject: [PATCH 0498/1452] Print: load Windows.Data.Pdf.dll before using it; fix the error hint (#5884) Review follow-up on #6395: - Check that the DLL actually loads (winsafe `LoadLibrary`, no unsafe) rather than that the file exists: a DLL that is present but cannot load still aborts at the delay-load stub. - The hint no longer says to clear Print Settings; that doesn't take a page off the PDF path. It now points to Save as PDF, or printing an RGB image without page text or labels. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 5 ++++ crates/engine/src/print_cmds.rs | 35 ++++++++++++++++----------- crates/engine/src/print_cmds/tests.rs | 26 ++++++++++++++++---- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..99799dc20f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4810,6 +4810,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "wat", + "winsafe", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..e881dca4cf 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding +# ui-egui and craft-print already use, pinned to the same version. +[target.'cfg(target_os = "windows")'.dependencies] +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 88ed6c7883..72af145782 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -437,24 +437,31 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { } /// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their -/// `build.rs`) so a Windows image without it still starts. Calling into it when it is missing -/// would abort the process, so check that it is installed first (#5884). +/// `build.rs`) so a Windows image without it still starts. Calling into it when it cannot load +/// would abort the process, so check that it loads first (#5884). fn require_windows_pdf() -> Result<()> { - if !cfg!(windows) { - return Ok(()); - } - let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); - if windows_pdf_installed(std::path::Path::new(&root)) { - Ok(()) - } else { - Err(other( - "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.", - )) + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); + if !windows_pdf_loads(&windows_pdf_dll(std::path::Path::new(&root))) { + return Err(other( + "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is missing or cannot load. Save as PDF and print it from another app, or print an RGB image without page text or labels to print directly.", + )); + } } + Ok(()) } -fn windows_pdf_installed(system_root: &std::path::Path) -> bool { - system_root.join("System32").join("Windows.Data.Pdf.dll").is_file() +#[cfg(target_os = "windows")] +fn windows_pdf_dll(system_root: &std::path::Path) -> std::path::PathBuf { + system_root.join("System32").join("Windows.Data.Pdf.dll") +} + +/// Loads the DLL rather than checking the file exists: one that is present but cannot load (a +/// dependency stripped too) would still abort at the delay-load stub. +#[cfg(target_os = "windows")] +fn windows_pdf_loads(dll: &std::path::Path) -> bool { + dll.to_str().is_some_and(|p| winsafe::HINSTANCE::LoadLibrary(p).is_ok()) } /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 677fdec6ba..431b83343a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -435,12 +435,28 @@ fn printers_lists_the_spooler_destinations_without_a_document() { } } +#[cfg(target_os = "windows")] #[test] -fn windows_pdf_component_is_detected_by_its_dll() { - // #5884: a Windows image without Windows.Data.Pdf.dll reports an error instead of aborting. +fn windows_pdf_component_is_detected_by_loading_its_dll() { + // #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of + // aborting at the delay-load stub. let dir = tempfile::tempdir().unwrap(); - assert!(!windows_pdf_installed(dir.path())); + let dll = windows_pdf_dll(dir.path()); + assert!(!windows_pdf_loads(&dll)); + // Present but not loadable counts as missing. std::fs::create_dir(dir.path().join("System32")).unwrap(); - std::fs::write(dir.path().join("System32").join("Windows.Data.Pdf.dll"), b"MZ").unwrap(); - assert!(windows_pdf_installed(dir.path())); + std::fs::write(&dll, b"MZ").unwrap(); + assert!(!windows_pdf_loads(&dll)); + let root = std::env::var_os("SystemRoot").unwrap(); + let system = windows_pdf_dll(std::path::Path::new(&root)); + if system.is_file() { + assert!(windows_pdf_loads(&system)); + assert!(require_windows_pdf().is_ok()); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn the_windows_pdf_check_is_a_no_op_elsewhere() { + assert!(require_windows_pdf().is_ok()); } From 235e7578f397d5fb3962d0c72bb0d22d7a1ab7c8 Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Sat, 17 Oct 2026 18:55:54 -0300 Subject: [PATCH 0499/1452] =?UTF-8?q?Print=20through=20CUPS=20over=20IPP?= =?UTF-8?q?=20and=20the=20print=20portal,=20so=20File=20=E2=80=BA=20Print?= =?UTF-8?q?=20works=20in=20the=20Flatpak=20(#2728)=20(#3017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`, so File › Print fails there. This keeps craft-print as the route wherever `lp` is on the path (and on Windows) and adds two more behind it: - `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/ Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or TCP; the Flatpak manifests grant --socket=cups for it. A `printServer` named in the request always takes this route. `file.print.printers` lists the queues. - The XDG Print portal as the app's spooler (`Session::print_spooler`), for a sandbox without the CUPS socket or a system without a printer: the system print dialog, seeded with copies, paper and orientation. PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it. A route that exists but fails (unknown printer, refused job) is an error; only a missing route moves on. Results gain `backend` (lp, windows, ipp, portal). docs/printing.md describes the routes. --- Cargo.lock | 6 + Cargo.toml | 1 + README.md | 2 + apps/photocraft/Cargo.toml | 2 + apps/photocraft/src/main.rs | 13 +- apps/photocraft/src/print_portal.rs | 239 +++++++ crates/engine/Cargo.toml | 4 + crates/engine/src/lib.rs | 3 + crates/engine/src/print_cmds.rs | 235 ++++++- crates/engine/src/print_cmds/tests.rs | 291 ++++++++- crates/ipp/Cargo.toml | 12 + crates/ipp/README.md | 22 + crates/ipp/src/lib.rs | 600 ++++++++++++++++++ crates/ipp/src/tests.rs | 395 ++++++++++++ docs/development.md | 1 + docs/printing.md | 67 ++ docs/scorecard.md | 4 +- .../ai.storyteller.photocraft.bundle.yml | 3 + .../flatpak/ai.storyteller.photocraft.yml | 3 + xtask/src/layers.rs | 2 + 20 files changed, 1884 insertions(+), 21 deletions(-) create mode 100644 apps/photocraft/src/print_portal.rs create mode 100644 crates/ipp/Cargo.toml create mode 100644 crates/ipp/README.md create mode 100644 crates/ipp/src/lib.rs create mode 100644 crates/ipp/src/tests.rs create mode 100644 docs/printing.md diff --git a/Cargo.lock b/Cargo.lock index 43a4a44f65..fefc7e5771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4651,6 +4651,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "tempfile", "winit", "winresource", "winsafe", @@ -4800,6 +4801,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ipp", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4899,6 +4901,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ipp" +version = "0.9.0" + [[package]] name = "photocraft-ops" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4a9b20133c..25a7a101c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } +photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } diff --git a/README.md b/README.md index 973fb5330d..12e2ef4b12 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,8 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a flatpak run ai.storyteller.photocraft ``` +The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md). + The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 8b7c832bb2..505947e222 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } +# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done. +tempfile = "3" x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for # image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 721bbc7349..05355e55bf 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -63,6 +63,9 @@ mod photoshop_settings; #[cfg(target_os = "linux")] mod portal_save; mod presentation; +// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere. +#[cfg(any(target_os = "linux", test))] +mod print_portal; mod screen_color; mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). @@ -565,7 +568,15 @@ fn main() -> eframe::Result { services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some()); } } - let mut app = PhotocraftApp::new(Session::new(), services); + #[cfg(target_os = "linux")] + let session = { + let mut session = Session::new(); + session.print_spooler = print_portal::spooler(); + session + }; + #[cfg(not(target_os = "linux"))] + let session = Session::new(); + let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; diff --git a/apps/photocraft/src/print_portal.rs b/apps/photocraft/src/print_portal.rs new file mode 100644 index 0000000000..0eef02a01c --- /dev/null +++ b/apps/photocraft/src/print_portal.rs @@ -0,0 +1,239 @@ +//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print +//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP +//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and +//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the +//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a +//! `Request` object's `Response` signal, which this module waits for on its own thread so the +//! window keeps painting while the dialog is open; the outcome goes to the log. +//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html + +#[cfg(target_os = "linux")] +use photocraft_engine::print_cmds::{PrintJob, Spooler}; + +/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself +/// is looked up per job, so a missing one costs nothing at start-up. +#[cfg(target_os = "linux")] +pub fn spooler() -> Option { + let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok(); + if setting.as_deref() == Some("0") { + return None; + } + Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) }) +} + +/// Opens the system print dialog for the job and returns as soon as it is up. +#[cfg(target_os = "linux")] +fn print(job: &PrintJob) -> Result { + let conn = zbus::blocking::connection::Builder::session() + .and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) + .map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?; + let proxy = portal(&conn)?; + let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?; + // The document goes to a private temporary file (0600, created atomically by `tempfile`), + // not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal + // thread until the dialog is done, and a failure to start that thread drops it here. + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?; + std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?; + let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job)); + std::thread::Builder::new() + .name("print-portal".into()) + .spawn(move || { + let outcome = dialog(&conn, &title, &settings, &page_setup, file.path()); + drop(file); + match outcome { + Ok(what) => log::info!("print portal (v{version}): {what}"), + Err(e) => log::warn!("print portal (v{version}): {e}"), + } + }) + .map_err(|e| format!("cannot start the print thread: {e}"))?; + Ok("the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn portal(conn: &zbus::blocking::Connection) -> Result, String> { + zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print") + .map_err(|e| e.to_string()) +} + +/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal. +#[cfg(target_os = "linux")] +fn dialog( + conn: &zbus::blocking::Connection, + title: &str, + settings: &[(&'static str, Seed)], + page_setup: &[(&'static str, Seed)], + pdf: &std::path::Path, +) -> Result { + use std::collections::HashMap; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + type Dict<'a> = HashMap<&'a str, Value<'a>>; + fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> { + pairs + .iter() + .map(|(k, v)| { + let value = match v { + Seed::Text(t) => Value::from(t.clone()), + Seed::Mm(mm) => Value::from(*mm), + }; + (*k, value) + }) + .collect() + } + let proxy = portal(conn)?; + let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?; + let wait = |token: &str| -> Result, String> { + let path = request_path(&sender, token); + let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + request.receive_signal("Response").map_err(|e| e.to_string()) + }; + let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result, String> { + let message = signals.next().ok_or("the print portal went away")?; + let (code, results): (u32, HashMap) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?; + match code { + 0 => Ok(results), + 1 => Err("cancelled in the print dialog".into()), + _ => Err("the print dialog failed".into()), + } + }; + + let token = format!("photocraft_prepare_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + let _: OwnedObjectPath = + proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?; + let results = response(signals)?; + let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?; + + let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?; + let token = format!("photocraft_print_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + options.insert("token", Value::from(prepared)); + let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file)); + let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?; + response(signals)?; + Ok("printed through the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0); + t ^ N.fetch_add(1, Ordering::Relaxed) +} + +/// The request object the portal answers on: the sender's unique name without its colon and +/// with dots as underscores, then the handle token. +fn request_path(unique_name: &str, token: &str) -> String { + format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_")) +} + +/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are +/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum Seed { + Text(String), + Mm(f64), +} + +fn text(s: &str) -> Seed { + Seed::Text(s.to_string()) +} + +/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's +/// print settings take the PWG name; its page setup finds the size by the PPD name. +const PAPERS: [(&str, &str, f64, f64); 8] = [ + ("na_letter", "Letter", 215.9, 279.4), + ("na_legal", "Legal", 215.9, 355.6), + ("na_ledger", "Ledger", 279.4, 431.8), + ("iso_a3", "A3", 297.0, 420.0), + ("iso_a4", "A4", 210.0, 297.0), + ("iso_a5", "A5", 148.0, 210.0), + ("na_index-4x6", "4x6", 101.6, 152.4), + ("na_5x7", "5x7", 127.0, 177.8), +]; + +const MM_PER_PT: f64 = 25.4 / 72.0; + +type Paper = (&'static str, &'static str, f64, f64); + +/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in +/// millimetres. +fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) { + let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT); + let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0); + (row, w, h) +} + +fn orientation(landscape: bool) -> Seed { + text(if landscape { "landscape" } else { "portrait" }) +} + +/// `PreparePrint`'s settings (GtkPrintSettings keys, string values). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))]; + match row { + Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))), + None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]), + } + v +} + +/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD +/// name when it has one, else a custom size named by its dimensions. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))]; + match row { + Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))), + None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))), + } + v +} + +#[cfg(target_os = "linux")] +fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> { + settings_for(job.copies, job.paper, job.landscape) +} + +#[cfg(target_os = "linux")] +fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> { + page_setup_for(job.paper, job.landscape) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_follows_the_portal_rule() { + assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7"); + } + + #[test] + fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() { + // A4 landscape as the engine lays it out: width > height, still A4. + let s = settings_for(2, (841.89, 595.28), true); + assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]); + let p = page_setup_for((841.89, 595.28), true); + assert_eq!(p[0], ("Orientation", text("landscape"))); + let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") }; + let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") }; + assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(p[3], ("PPDName", text("A4"))); + assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter"))); + assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter"))); + assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6"))); + // 100 x 200 mm is nobody's standard. + let custom = settings_for(1, (283.46, 566.93), false); + assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]); + assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm"))); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..65dd1e16e0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,10 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. +[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] +photocraft-ipp = { workspace = true } + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e6486c2a1b..b076769040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -486,6 +486,9 @@ pub struct Session { /// the same check a top-level request sees. `None` runs everything, which is what a local /// UI and `photocraft-cli run` do. pub authorize: Option Result<()>>, + /// A further route for File › Print when neither `lp` nor a CUPS server over IPP is + /// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`. + pub print_spooler: Option, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index de16a94ac4..ed0f2cefce 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -8,6 +8,12 @@ //! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an //! explicit output path. Shared spooler transport files are private and automatically removed. //! +//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process +//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no +//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`; +//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for +//! one job and always takes the IPP route; `file.printers` lists the queues (#2728). +//! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` //! compound paths), following the public PostScript Language Reference and the AI3 format notes. @@ -31,6 +37,191 @@ fn other(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } +// ---------- Spooling ---------- + +/// A rendered page on its way to the printer: what the IPP route and the app's spooler need. +#[derive(Debug, Clone)] +pub struct PrintJob { + pub pdf: Vec, + /// The printer asked for; `None` means the default. + pub printer: Option, + pub copies: u32, + /// The queue entry's name: the document's. + pub title: String, + /// A print server named in the request (`printServer`), else the system's. + pub server: Option, + /// The page size in points, as laid out (landscape already applied). + pub paper: (f64, f64), + pub landscape: bool, +} + +/// A spooler's work: the job in, where it went out. +pub type SpoolFn = std::sync::Arc std::result::Result + Send + Sync>; + +/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG +/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs +/// no other route can (no print server reachable, no printer set up), or every job when +/// `preferred`. +#[derive(Clone)] +pub struct Spooler { + /// Reported as the job's `backend`. + pub name: &'static str, + pub preferred: bool, + /// Returns where the job went ("the system print dialog"). + pub run: SpoolFn, +} + +/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a +/// failure on a route that exists (a wrong printer name, a rejected job). +enum SpoolError { + NoRoute(String), + Failed(String), +} + +/// Sends the job and says where it went and through what. A named print server takes the IPP +/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the +/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had +/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// error, never a fall-through. +fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { + let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); + if job.server.is_none() { + if let Some(sp) = spooler.filter(|sp| sp.preferred) { + return app(sp); + } + if let Some(name) = system_spooler() { + return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); + } + } + match cups_spool(job) { + Ok(m) => Ok((m, "ipp".into())), + Err(SpoolError::NoRoute(why)) => match spooler { + Some(sp) => app(sp), + None => Err(other(why)), + }, + Err(SpoolError::Failed(why)) => Err(other(why)), + } +} + +/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command +/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the +/// browser. +fn system_spooler() -> Option<&'static str> { + match craft_print::backend()? { + craft_print::Backend::WindowsPdf => Some("windows"), + craft_print::Backend::Cups => { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp") + } + } +} + +/// The printer a job goes to: the one asked for when the server knows it, else the default, +/// else the only one; otherwise an error that names what there is. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result { + let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect(); + let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string()); + if let Some(r) = requested { + let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") }; + return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}"))); + } + if printers.is_empty() { + return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into())); + } + if let Some(d) = default.and_then(known) { + return Ok(d); + } + match names.as_slice() { + [only] => Ok((*only).to_string()), + _ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))), + } +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_server(named: Option<&str>) -> std::result::Result { + match named { + Some(server) => { + let ep = photocraft_ipp::Endpoint::parse(server); + if !ep.reachable() { + return Err(SpoolError::Failed(format!("no print server at {ep}"))); + } + Ok(ep) + } + None => photocraft_ipp::Endpoint::detect() + .ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())), + } +} + +/// The IPP route: the job to the system's (or the named) CUPS server. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string()); + let ep = cups_server(job.server.as_deref())?; + let printers = photocraft_ipp::printers(&ep).map_err(failed)?; + let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?; + let is_class = printers.iter().any(|p| p.name == printer && p.is_class); + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into()); + let id = photocraft_ipp::print_job( + &ep, + &photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" }, + ) + .map_err(failed)?; + Ok(format!("job {id} on {printer}")) +} + +/// No IPP route here: a named server is a failure, the system's a missing route (the app's +/// spooler may have one). +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let why = if cfg!(target_arch = "wasm32") { + "printing needs the desktop app" + } else { + "a print server over IPP is not available on Windows yet; print to PDF instead" + }; + match job.server { + Some(_) => Err(SpoolError::Failed(why.into())), + None => Err(SpoolError::NoRoute(why.into())), + } +} + +/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From +/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named +/// server and where there is no `lp` (a sandbox). +fn system_printers(_s: &mut Session, p: &Value) -> Result { + let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()); + if named.is_none() && system_spooler().is_some() { + let list = craft_print::printers(); + let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); + let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); + return Ok(json!({"server": Value::Null, "default": default, "printers": printers})); + } + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] + { + let ep = match cups_server(named) { + Ok(ep) => ep, + Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => { + return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why})); + } + }; + let list = photocraft_ipp::printers(&ep).map_err(other)?; + let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printers: Vec = list + .iter() + .map(|p| { + json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class}) + }) + .collect(); + Ok(json!({"server": ep.to_string(), "default": default, "printers": printers})) + } + #[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))] + { + let _ = named; + Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"})) + } +} + // ---------- PDF ---------- /// Paper sizes in points (portrait). @@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); + let spooler = s.print_spooler.clone(); if send && !dry { // Session::execute remains synchronous; the desktop's Session::start snapshots here // and runs PDF composition and the potentially slow driver on a worker. @@ -352,7 +544,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { false, move |ctx| { ctx.progress(0.0, "Print"); - render_and_spool(&doc, &worker_params, &command) + render_and_spool(&doc, &worker_params, &command, spooler.as_ref()) }, move |s, result| { remember_print(s, ¶ms); @@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }, ); } - let result = render_and_spool(&doc, p, cmd)?; + let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?; remember_print(s, p); crate::automate_cmds::fire_event(s, "print"); Ok(result) } -fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { +fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; let description = p @@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); - if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + // A named print server always takes the IPP route and a preferred app spooler takes every + // job (the route order in `spool_job`), on Windows too: neither may enter the native path + // below, which would print on the local default printer instead. + let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ @@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } - if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() { return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); + let pdf_len = pdf.len(); let job = craft_print::Job { printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), copies, @@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { }; let mut sent = false; let mut spool = Value::Null; + let mut backend = Value::Null; if send && !dry { require_printing_enabled()?; - spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); + let print_job = PrintJob { + printer: job.printer.clone(), + copies, + title: doc.name.clone(), + server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + paper: (pw, ph), + landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"), + pdf, + }; + let (where_, via) = spool_job(spooler, &print_job, &job)?; + spool = json!(where_); + backend = json!(via); sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}), ) } @@ -573,15 +783,6 @@ fn require_printing_enabled() -> Result<()> { } } -/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless -/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser. -fn system_printers(_: &mut Session, _: &Value) -> Result { - let list = craft_print::printers(); - let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); - let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); - Ok(json!({"printers": printers, "default": default})) -} - fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } @@ -759,7 +960,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..34c1cda6fa 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -360,7 +360,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document"); assert_eq!(out["sent"], true); - assert_eq!(out["spooler"], "request id is Office-42"); + assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp"))); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -434,3 +434,292 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above +/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs +/// the test body; the parent starts the child and checks that it passed. +fn in_child(test: &str, envs: &[(&str, &str)]) -> bool { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let mut cmd = std::process::Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT"); + for (k, v) in envs { + cmd.env(k, v); + } + // Bounded: a child stuck in a printer dialog or a driver must not hang the suite. + let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + let out = child.wait_with_output().unwrap(); + assert!( + status.is_some_and(|s| s.success()), + "{}\nstdout: {}\nstderr: {}", + if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" }, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + false +} + +/// A spooler that keeps every job it gets. +fn recording_spooler(preferred: bool, seen: &std::sync::Arc>>) -> Spooler { + let seen = std::sync::Arc::clone(seen); + Spooler { + name: "test", + preferred, + run: std::sync::Arc::new(move |job: &PrintJob| { + seen.lock().unwrap().push(job.clone()); + Ok("queued".into()) + }), + } +} + +#[test] +fn a_preferred_app_spooler_takes_the_job() { + if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) { + return; + } + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = session("rgb", 8); + s.print_spooler = Some(recording_spooler(true, &seen)); + let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued"))); + assert!(r["pdf"].is_null(), "{r}"); + let jobs = seen.lock().unwrap(); + let job = &jobs[0]; + assert!(job.pdf.starts_with(b"%PDF-1.4")); + assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true)); + assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01); + drop(jobs); + // A named print server asks for the IPP route, preferred spooler or not. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}"); + assert_eq!(seen.lock().unwrap().len(), 1); + // "output" keeps the file and does not spool unless asked. + let out = format!("{}/out.pdf", tmp("output")); + let r = s.execute("file.print", json!({"output": out})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str()))); + assert!(std::path::Path::new(&out).exists()); +} + +#[cfg(not(target_os = "windows"))] +mod cups { + use super::*; + use photocraft_ipp::{ + Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP, + }; + use std::io::{BufRead, BufReader, Read, Write}; + use std::sync::mpsc; + + fn printer(name: &str, is_default: bool) -> Printer { + Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false } + } + + #[test] + fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() { + let two = [printer("Office", false), printer("Lab", true)]; + assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into())); + assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into())); + assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into())); + match resolve_printer(None, None, &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"), + _ => panic!("several printers and no default must fail"), + } + match resolve_printer(Some("Nope"), Some("Lab"), &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"), + _ => panic!("an unknown printer must fail"), + } + match resolve_printer(None, None, &[]) { + Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"), + _ => panic!("no printers is no route"), + } + // A printer asked for by name on a server without any is a failure, not a missing route. + match resolve_printer(Some("Office"), None, &[]) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"), + _ => panic!("a named printer never falls through"), + } + // A stale default that the server no longer lists falls through to the only printer. + assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into())); + } + + /// What the fake received per request: op, attributes, document. + type Seen = (u16, Vec, Vec); + + /// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs + /// as id 42. Answers for as long as the test process lives. + fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let mut reader = BufReader::new(stream); + let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if first.is_empty() { + first = line.clone(); + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + continue; // a reachability probe + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap(); + let list = |names: &[&str]| { + if names.is_empty() { + return Request::new(0x0406, 1).end(); + } + let mut r = Request::new(0, 1); + for p in names { + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", p.as_bytes()); + } + r.end() + }; + let reply = match op { + OP_CUPS_GET_PRINTERS => list(printers), + OP_CUPS_GET_CLASSES => list(classes), + OP_CUPS_GET_DEFAULT => match default { + Some(d) => { + let mut r = Request::new(0, 1); + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", d.as_bytes()); + r.end() + } + None => Request::new(0x0406, 1).end(), + }, + OP_PRINT_JOB => { + let mut r = Request::new(0, 1); + r.group(TAG_JOB_GROUP); + r.integer("job-id", 42); + r.end() + } + _ => Request::new(0x0501, 1).end(), + }; + let _ = tx.send((op, attrs, doc)); + let mut out = reader.into_inner(); + let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + let _ = out.write_all(&reply); + let _ = out.flush(); + } + }); + (server, rx) + } + + #[test] + fn a_named_server_is_printed_to_over_ipp() { + if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) { + return; + } + let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab")); + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab"))); + assert!(r["pdf"].is_null()); + let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request"); + let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}")); + assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text()); + assert_eq!( + (attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()), + (Some(3), "Print Me.psd", "application/pdf") + ); + assert!(job.2.starts_with(b"%PDF-1.4")); + // The requested printer, in any case; an unknown one names the choices. + let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap(); + assert_eq!(r["spooler"], "job 42 on Office"); + let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job"); + assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office"))); + let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string(); + assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"); + // Printers can be listed. + let r = s.execute("file.printers", json!({"printServer": server})).unwrap(); + let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab"))); + assert_eq!( + (r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()), + (Some(true), Some(true), Some(false)) + ); + } + + #[test] + fn without_a_printer_the_app_spooler_takes_over() { + if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) { + return; + } + let (server, _rx) = fake_cups(&[], &[], None); + let mut s = session("gray", 8); + let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string(); + assert!(e.contains("no printer is set up"), "{e}"); + // Asking for a printer by name never falls through to another route. + let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string(); + assert!(e.contains("`Office`"), "{e}"); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + let r = s.execute("file.print", json!({"printServer": server})).unwrap(); + assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued"))); + assert_eq!(seen.lock().unwrap().len(), 1); + // An unreachable named server is a failure, not a missing route. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at"), "{e}"); + let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap(); + assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server")); + } + + /// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP + /// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler. + /// Runs in a child process, since the path is process-wide. + #[test] + fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP"; + if std::env::var_os(CHILD).is_some() { + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"copies": 2})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office"))); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + // The IPP route first, then the app's spooler only when there is no server. + let r = s.execute("file.print", json!({})).unwrap(); + assert_eq!(r["backend"], "ipp"); + assert!(seen.lock().unwrap().is_empty()); + // (CUPS_SERVER is read per job; an unreachable one is no route.) + let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(r.contains("no print server at"), "{r}"); + println!("child ok"); + return; + } + let (server, rx) = fake_cups(&["Office"], &[], Some("Office")); + let no_lp = tempfile::tempdir().unwrap(); + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"]) + .env(CHILD, "1") + .env("PATH", no_lp.path()) + .env("CUPS_SERVER", &server) + .env_remove("PHOTOCRAFT_NO_PRINT") + .output() + .unwrap(); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}"); + let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count(); + assert_eq!(jobs, 2, "both prints reached the fake CUPS"); + } +} diff --git a/crates/ipp/Cargo.toml b/crates/ipp/Cargo.toml new file mode 100644 index 0000000000..cc9f6aec31 --- /dev/null +++ b/crates/ipp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "photocraft-ipp" +description = "A small IPP client for CUPS (list printers, default printer, print a PDF), pure Rust, no dependencies" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/ipp/README.md b/crates/ipp/README.md new file mode 100644 index 0000000000..a6c03d1113 --- /dev/null +++ b/crates/ipp/README.md @@ -0,0 +1,22 @@ +# photocraft-ipp + +A small IPP (Internet Printing Protocol, RFC 8010/8011) client for the local CUPS server, in pure +Rust with no dependencies. It does in-process what `lp` does from the command line, so PhotoCraft +can print where there is no `lp` binary (a Flatpak sandbox with `--socket=cups`, a minimal +container) and can read the printer list and the default printer first instead of handing +CUPS's errors to the user. + +```rust +let ep = photocraft_ipp::Endpoint::detect().ok_or("no CUPS server")?; // socket, then localhost:631 +let printers = photocraft_ipp::printers(&ep)?; // name, info, state, default +let id = photocraft_ipp::print_job(&ep, &photocraft_ipp::Job { printer: "Office", title: "photo", user: "me", copies: 2, document: &pdf, format: "application/pdf" })?; +``` + +Three operations: `CUPS-Get-Printers`, `CUPS-Get-Default`, `Print-Job`. HTTP/1.1 over the CUPS +domain socket (`/run/cups/cups.sock`, or `CUPS_SERVER` as a path) or TCP (`localhost:631`, or +`CUPS_SERVER` as `host[:port]`), chunked replies included. Replies are capped at 16 MiB and +parsed with bounds checks; an IPP error status becomes `Error::Status` with the server's own +message. Tests run against an in-process fake server over TCP and a Unix socket. + +Standalone: it depends on no other PhotoCraft crate. Used by `photocraft-engine`'s print +commands on Unix; Windows printing lives in `photocraft-winprint`. diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs new file mode 100644 index 0000000000..9f1209df45 --- /dev/null +++ b/crates/ipp/src/lib.rs @@ -0,0 +1,600 @@ +//! `photocraft-ipp`: a small IPP client for CUPS, in pure Rust with no dependencies. +//! +//! Talks to the local print server the way `lp` does, but in-process: over the CUPS domain +//! socket (`/run/cups/cups.sock`, or `CUPS_SERVER` when it names a path) or TCP (`localhost:631`, +//! or `CUPS_SERVER` as `host[:port]`). Three operations, from RFC 8010 / 8011 and CUPS's own +//! extensions: list the printers, read the default, and submit a PDF as a job. +//! +//! Never panics: every read is bounded and every malformed reply is an error. Works on any Unix +//! and on Windows for TCP (CUPS rarely runs there; the Windows print path is separate). + +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::time::Duration; + +/// Where the print server listens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Endpoint { + /// A Unix domain socket (CUPS's default, also what a Flatpak sees with `--socket=cups`). + Unix(PathBuf), + /// A TCP host and port. + Tcp(String, u16), +} + +/// Why a request failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// No server could be reached. + Unreachable(String), + /// The HTTP or IPP reply could not be read. + Protocol(String), + /// The server answered with an IPP error status (the code and its message). + Status(u16, String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Unreachable(m) => write!(f, "no print server: {m}"), + Error::Protocol(m) => write!(f, "print server reply: {m}"), + Error::Status(code, m) => write!(f, "print server refused ({code:#06x}): {m}"), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// A printer the server knows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Printer { + pub name: String, + /// `printer-info`, the human description. + pub info: String, + /// `printer-state`: 3 idle, 4 printing, 5 stopped. + pub state: u32, + pub accepting_jobs: bool, + pub is_default: bool, + /// A CUPS class (a pool of printers) rather than one printer. + pub is_class: bool, +} + +/// Timeouts: connecting, and each request (a Print-Job carries the whole PDF). +const CONNECT: Duration = Duration::from_secs(3); +const REQUEST: Duration = Duration::from_secs(60); +/// Largest reply read. +const MAX_REPLY: usize = 16 << 20; +/// Largest number of attributes taken from a reply. +const MAX_ATTRIBUTES: usize = 100_000; + +pub const OP_PRINT_JOB: u16 = 0x0002; +pub const OP_CUPS_GET_DEFAULT: u16 = 0x4001; +pub const OP_CUPS_GET_PRINTERS: u16 = 0x4002; +pub const OP_CUPS_GET_CLASSES: u16 = 0x4005; + +pub const TAG_OPERATION_GROUP: u8 = 0x01; +pub const TAG_JOB_GROUP: u8 = 0x02; +pub const TAG_END: u8 = 0x03; +pub const TAG_PRINTER_GROUP: u8 = 0x04; +pub const TAG_INTEGER: u8 = 0x21; +pub const TAG_BOOLEAN: u8 = 0x22; +pub const TAG_ENUM: u8 = 0x23; +pub const TAG_TEXT: u8 = 0x41; +pub const TAG_NAME: u8 = 0x42; +pub const TAG_KEYWORD: u8 = 0x44; +pub const TAG_URI: u8 = 0x45; +pub const TAG_CHARSET: u8 = 0x47; +pub const TAG_LANGUAGE: u8 = 0x48; +pub const TAG_MIME: u8 = 0x49; + +impl Endpoint { + /// The server this machine uses: `CUPS_SERVER` (a socket path, or `host[:port]`), else the + /// CUPS socket, else `localhost:631`. `None` when nothing answers. + pub fn detect() -> Option { + if let Some(server) = std::env::var_os("CUPS_SERVER").filter(|v| !v.is_empty()) { + let ep = Endpoint::parse(&server.to_string_lossy()); + return ep.reachable().then_some(ep); + } + [ + Endpoint::Unix(PathBuf::from("/run/cups/cups.sock")), + Endpoint::Unix(PathBuf::from("/var/run/cups/cups.sock")), + // macOS + Endpoint::Unix(PathBuf::from("/private/var/run/cupsd")), + Endpoint::Tcp("localhost".into(), 631), + ] + .into_iter() + .find(Endpoint::reachable) + } + + /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). + pub fn parse(server: &str) -> Endpoint { + if server.starts_with('/') { + return Endpoint::Unix(PathBuf::from(server)); + } + let (host, port) = server.rsplit_once(':').and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); + Endpoint::Tcp(host, port) + } + + /// Whether a connection opens. + pub fn reachable(&self) -> bool { + self.connect().is_ok() + } + + fn connect(&self) -> Result { + match self { + #[cfg(unix)] + Endpoint::Unix(path) => { + if !path.exists() { + return Err(Error::Unreachable(format!("{} does not exist", path.display()))); + } + let s = std::os::unix::net::UnixStream::connect(path).map_err(|e| Error::Unreachable(format!("{}: {e}", path.display())))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Unix(s)) + } + #[cfg(not(unix))] + Endpoint::Unix(path) => Err(Error::Unreachable(format!("{}: Unix sockets are not available here", path.display()))), + Endpoint::Tcp(host, port) => { + let addr = (host.as_str(), *port).to_socket_addrs().map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?.next(); + let addr = addr.ok_or_else(|| Error::Unreachable(format!("{host}:{port}: no address")))?; + let s = TcpStream::connect_timeout(&addr, CONNECT).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Tcp(s)) + } + } + } + + fn host(&self) -> String { + match self { + Endpoint::Unix(_) => "localhost".into(), + Endpoint::Tcp(host, port) => format!("{host}:{port}"), + } + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Endpoint::Unix(p) => write!(f, "{}", p.display()), + Endpoint::Tcp(h, p) => write!(f, "{h}:{p}"), + } + } +} + +enum Stream { + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), + Tcp(TcpStream), +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.read(buf), + Stream::Tcp(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.write(buf), + Stream::Tcp(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.flush(), + Stream::Tcp(s) => s.flush(), + } + } +} + +/// Builds an IPP request body. +pub struct Request { + buf: Vec, +} + +impl Request { + pub fn new(op: u16, id: u32) -> Self { + let mut buf = vec![2, 0]; + buf.extend_from_slice(&op.to_be_bytes()); + buf.extend_from_slice(&id.to_be_bytes()); + buf.push(TAG_OPERATION_GROUP); + let mut r = Request { buf }; + r.attr(TAG_CHARSET, "attributes-charset", b"utf-8"); + r.attr(TAG_LANGUAGE, "attributes-natural-language", b"en"); + r + } + + /// Appends an attribute to the current group. + pub fn attr(&mut self, tag: u8, name: &str, value: &[u8]) { + // IPP lengths are 16-bit; longer names and values are cut, both the length and the bytes. + let name = name.as_bytes().get(..name.len().min(0xffff)).unwrap_or_default(); + let value = value.get(..value.len().min(0xffff)).unwrap_or_default(); + self.buf.push(tag); + self.buf.extend_from_slice(&(name.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(name); + self.buf.extend_from_slice(&(value.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(value); + } + + /// An attribute with several values (the extra ones carry an empty name). + pub fn attr_many(&mut self, tag: u8, name: &str, values: &[&str]) { + for (i, v) in values.iter().enumerate() { + self.attr(tag, if i == 0 { name } else { "" }, v.as_bytes()); + } + } + + pub fn integer(&mut self, name: &str, v: i32) { + self.attr(TAG_INTEGER, name, &v.to_be_bytes()); + } + + /// Starts a new attribute group. + pub fn group(&mut self, tag: u8) { + self.buf.push(tag); + } + + /// The message with its end tag. + pub fn end(mut self) -> Vec { + self.buf.push(TAG_END); + self.buf + } +} + +/// One attribute of a reply: the group it sits in, its tag, name and values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribute { + pub group: u8, + pub tag: u8, + pub name: String, + pub values: Vec>, +} + +impl Attribute { + /// The first value as text. + pub fn text(&self) -> String { + self.values.first().map(|v| String::from_utf8_lossy(v).into_owned()).unwrap_or_default() + } + /// The first value as a big-endian integer. + pub fn int(&self) -> Option { + self.values.first().and_then(|v| <[u8; 4]>::try_from(v.as_slice()).ok()).map(i32::from_be_bytes) + } +} + +/// A decoded reply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reply { + pub status: u16, + pub request_id: u32, + pub attributes: Vec, +} + +impl Reply { + /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. + pub fn parse(b: &[u8]) -> Result { + Self::parse_at(b).map(|(r, _)| r) + } + + /// Parses a message and returns where its attributes end (document data may follow). + fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { + let bad = |m: &str| Error::Protocol(m.to_string()); + if b.len() < 9 { + return Err(bad("reply shorter than an IPP header")); + } + let status = u16::from_be_bytes([b[2], b[3]]); + let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]); + let mut pos = 8; + let mut group = 0u8; + let mut attributes: Vec = Vec::new(); + loop { + let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?; + pos += 1; + if tag == TAG_END { + return Ok((Reply { status, request_id, attributes }, pos)); + } + if tag < 0x10 { + group = tag; + continue; + } + let name_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?; + pos += name_len; + let value_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec(); + pos += value_len; + if name_len == 0 { + // An additional value of the previous attribute. + match attributes.last_mut() { + Some(last) if last.group == group => last.values.push(value), + _ => return Err(bad("an additional value with no attribute before it")), + } + } else { + attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] }); + } + if attributes.len() > MAX_ATTRIBUTES { + return Err(bad("too many attributes")); + } + } + } + + /// The server's own message for an error status, or a name for the code. + fn status_message(&self) -> String { + self.attributes.iter().find(|a| a.name == "status-message").map(Attribute::text).filter(|m| !m.is_empty()).unwrap_or_else(|| { + match self.status { + 0x0400 => "bad request", + 0x0401 => "forbidden", + 0x0402 => "not authenticated", + 0x0403 => "not authorized", + 0x0406 => "not found", + 0x0409 => "unsupported document format", + 0x0500 => "internal server error", + 0x0506 => "the printer is not accepting jobs", + _ => "error", + } + .to_string() + }) + } +} + +/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply. +fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { + let mut stream = endpoint.connect()?; + let head = format!( + "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + endpoint.host(), + body.len() + ); + stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()) + .map_err(|e| Error::Protocol(format!("sending: {e}")))?; + let raw = read_response(&mut stream)?; + let (status, headers, body) = http_response(&raw)?; + if status != 200 { + return Err(Error::Protocol(match status { + 401 => "the print server asks for authentication (HTTP 401)".to_string(), + 403 => "the print server forbids this request (HTTP 403)".to_string(), + s => format!("HTTP {s}"), + })); + } + let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() }; + let reply = Reply::parse(&body)?; + if reply.status >= 0x0400 { + return Err(Error::Status(reply.status, reply.status_message())); + } + Ok(reply) +} + +/// Lowercase HTTP headers. +type Headers = Vec<(String, String)>; + +/// Reads one HTTP response: the headers, then as much body as `Content-Length` says, or up to +/// the last chunk, or to the end of the stream when the server says neither. A server that keeps +/// the connection open would otherwise hold the reply until the read times out. +fn read_response(stream: &mut Stream) -> Result> { + let mut raw = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + let mut body_at = None; + let mut expected: Option = None; + let mut chunked = false; + loop { + let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?; + if n == 0 { + return Ok(raw); + } + raw.extend_from_slice(buf.get(..n).unwrap_or_default()); + if raw.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + if body_at.is_none() + && let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") + { + body_at = Some(end + 4); + let (_, headers, _) = http_response(&raw)?; + expected = headers.iter().find(|(k, _)| k == "content-length").and_then(|(_, v)| v.parse().ok()); + chunked = headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")); + } + let Some(at) = body_at else { continue }; + let body = raw.get(at..).unwrap_or_default(); + let complete = match expected { + Some(len) if !chunked => body.len() >= len, + _ if chunked => chunked_complete(body), + _ => false, + }; + if complete { + if let Some(len) = expected.filter(|_| !chunked) { + raw.truncate(at + len); + } + return Ok(raw); + } + } +} + +/// Splits an HTTP/1.1 response into status, lowercase headers and body. +fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> { + let end = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| Error::Protocol("no HTTP header".into()))?; + let head = String::from_utf8_lossy(raw.get(..end).unwrap_or(&[])); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or_default(); + let status: u16 = + status_line.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| Error::Protocol(format!("bad status line `{status_line}`")))?; + let headers = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())).collect(); + Ok((status, headers, raw.get(end + 4..).unwrap_or(&[]))) +} + +/// Whether a chunked body has reached its last (zero-size) chunk, walking the chunk sizes so a +/// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A +/// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of +/// waiting for more. +fn chunked_complete(mut b: &[u8]) -> bool { + loop { + let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false }; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true }; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return true; + } + // A size past the reply cap can never be satisfied: complete, so `dechunk` rejects it + // (checked, so `ffffffffffffffff` cannot overflow the slice arithmetic). + let Some(end) = size.checked_add(2).filter(|_| size <= MAX_REPLY) else { return true }; + let Some(rest) = b.get(end..) else { return false }; + b = rest; + } +} + +/// Joins the chunks of a chunked transfer encoding. +fn dechunk(mut b: &[u8]) -> Result> { + let mut out = Vec::new(); + loop { + let line_end = b.windows(2).position(|w| w == b"\r\n").ok_or_else(|| Error::Protocol("cut chunk size".into()))?; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let size = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16).map_err(|_| Error::Protocol("bad chunk size".into()))?; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return Ok(out); + } + let chunk = b.get(..size).ok_or_else(|| Error::Protocol("cut chunk".into()))?; + out.extend_from_slice(chunk); + if out.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + b = b.get(size + 2..).ok_or_else(|| Error::Protocol("cut chunk end".into()))?; + } +} + +/// Where `/path` lands on the server: `/printers/` or `/classes/`. +fn queue_path(name: &str, is_class: bool) -> String { + format!("/{}/{name}", if is_class { "classes" } else { "printers" }) +} + +/// The printers the server knows (CUPS-Get-Printers), then its classes (CUPS-Get-Classes, which +/// `lp -d` accepts just the same), the default one flagged. +pub fn printers(endpoint: &Endpoint) -> Result> { + let mut out = queues(endpoint, OP_CUPS_GET_PRINTERS, false)?; + // A server without classes answers "not found"; an older server may not know the operation. + out.extend(queues(endpoint, OP_CUPS_GET_CLASSES, true).unwrap_or_default()); + let default = default_printer(endpoint).unwrap_or(None); + for p in &mut out { + p.is_default = default.as_deref() == Some(p.name.as_str()); + } + Ok(out) +} + +fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result> { + let mut r = Request::new(op, 1); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]); + let reply = match post(endpoint, "/", &r.end()) { + Ok(reply) => reply, + // CUPS answers "not found" ("No destinations added.") when no printer is set up. + Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut out = Vec::new(); + let mut current: Option = None; + for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { + match a.name.as_str() { + "printer-name" => { + if let Some(p) = current.take() { + out.push(p); + } + current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); + } + "printer-info" => { + if let Some(p) = &mut current { + p.info = a.text(); + } + } + "printer-state" => { + if let Some(p) = &mut current { + p.state = a.int().unwrap_or(3).clamp(0, 5) as u32; + } + } + "printer-is-accepting-jobs" => { + if let Some(p) = &mut current { + p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)); + } + } + _ => {} + } + } + out.extend(current); + Ok(out) +} + +/// The server's default printer (CUPS-Get-Default), `None` when there is none. +pub fn default_printer(endpoint: &Endpoint) -> Result> { + let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); + match post(endpoint, "/", &r.end()) { + Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), + // CUPS answers "not found" when no default is set. + Err(Error::Status(0x0406, _)) => Ok(None), + Err(e) => Err(e), + } +} + +/// What to print. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Job<'a> { + pub printer: &'a str, + /// `printer` names a class. + pub is_class: bool, + /// Shown in the queue. + pub title: &'a str, + pub user: &'a str, + pub copies: u32, + /// The document, as `format` (`application/pdf`). + pub document: &'a [u8], + pub format: &'a str, +} + +/// Submits `job` (Print-Job) and returns the job id. +pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { + let mut r = Request::new(OP_PRINT_JOB, 3); + let path = queue_path(job.printer, job.is_class); + r.attr(TAG_URI, "printer-uri", format!("ipp://{}{path}", endpoint.host()).as_bytes()); + r.attr(TAG_NAME, "requesting-user-name", job.user.as_bytes()); + r.attr(TAG_NAME, "job-name", job.title.as_bytes()); + r.attr(TAG_MIME, "document-format", job.format.as_bytes()); + if job.copies > 1 { + r.buf.push(TAG_JOB_GROUP); + r.integer("copies", job.copies.clamp(1, 999) as i32); + } + let mut body = r.end(); + body.extend_from_slice(job.document); + let reply = post(endpoint, &path, &body)?; + reply + .attributes + .iter() + .find(|a| a.name == "job-id") + .and_then(Attribute::int) + .map(|id| id.max(0) as u32) + .ok_or_else(|| Error::Protocol("no job id in the reply".into())) +} + +/// A request as a server sees it: the operation (the request's status field), its attributes +/// and the document data after the end tag. For fakes in tests and tools. +pub fn parse_request(b: &[u8]) -> Result<(u16, Vec, Vec)> { + let (reply, end) = Reply::parse_at(b)?; + Ok((reply.status, reply.attributes, b.get(end..).unwrap_or(&[]).to_vec())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs new file mode 100644 index 0000000000..ff3ccb6a49 --- /dev/null +++ b/crates/ipp/src/tests.rs @@ -0,0 +1,395 @@ +//! The client against an in-process fake CUPS: TCP and Unix socket, content-length and chunked +//! replies, every operation, error statuses and broken replies. + +use super::*; +use std::io::{BufRead, BufReader}; +use std::sync::mpsc; + +/// What the fake received: op, attributes, document. +type Seen = (u16, Vec, Vec); + +/// A fake server answering one request with `reply_body` (IPP bytes), chunked or not, under an +/// HTTP status. Returns the endpoint and a channel with what it received. +fn fake(reply: Vec, chunked: bool, http_status: u16) -> (Endpoint, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Tcp(stream), reply, chunked, http_status, &tx); + }); + (Endpoint::Tcp("127.0.0.1".into(), port), rx) +} + +fn serve(stream: Stream, reply: Vec, chunked: bool, http_status: u16, tx: &mpsc::Sender) { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let mut content_length = 0usize; + let mut first = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if first.is_empty() { + first = line.clone(); + } + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + return; // a reachability probe: connect and close + } + assert!(first.starts_with("POST "), "{first}"); + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).unwrap(); + let _ = tx.send(parse_request(&body).unwrap()); + let mut out = reader.into_inner(); + let status_text = if http_status == 200 { "OK" } else { "Nope" }; + if chunked { + let mut resp = format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n").into_bytes(); + for chunk in reply.chunks(7) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + out.write_all(&resp).unwrap(); + } else { + out.write_all(format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()) + .unwrap(); + out.write_all(&reply).unwrap(); + } + out.flush().unwrap(); +} + +/// An attribute to write: tag, name, values. +type Attr<'a> = (u8, &'a str, &'a [&'a [u8]]); +/// A group to write: tag and attributes. +type Group<'a> = (u8, &'a [Attr<'a>]); + +/// An IPP reply body. +fn ipp(status: u16, groups: &[Group<'_>]) -> Vec { + let mut b = vec![2, 0]; + b.extend_from_slice(&status.to_be_bytes()); + b.extend_from_slice(&1u32.to_be_bytes()); + for (group, attrs) in groups { + b.push(*group); + for (tag, name, values) in *attrs { + for (i, v) in values.iter().enumerate() { + b.push(*tag); + let n = if i == 0 { name.as_bytes() } else { b"" }; + b.extend_from_slice(&(n.len() as u16).to_be_bytes()); + b.extend_from_slice(n); + b.extend_from_slice(&(v.len() as u16).to_be_bytes()); + b.extend_from_slice(v); + } + } + } + b.push(TAG_END); + b +} + +fn ok_header() -> Group<'static> { + (TAG_OPERATION_GROUP, &[(TAG_CHARSET, "attributes-charset", &[b"utf-8"]), (TAG_LANGUAGE, "attributes-natural-language", &[b"en"])]) +} + +#[test] +fn printers_are_listed_with_state_and_default() { + let three = ipp( + 0, + &[ + ok_header(), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Office"]), + (TAG_TEXT, "printer-info", &[b"HP in the office"]), + (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[1]]), + ], + ), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Stopped"]), + (TAG_ENUM, "printer-state", &[&5u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[0]]), + ], + ), + ], + ); + for chunked in [false, true] { + let (ep, rx) = fake(three.clone(), chunked, 200); + // `printers` asks for the default too; that second request reaches a server that has + // closed, so the default is simply unknown here. + let list = printers(&ep).unwrap(); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_CUPS_GET_PRINTERS); + assert!(attrs.iter().any(|a| a.name == "requested-attributes" && a.values.len() == 4 && a.tag == TAG_KEYWORD)); + assert!(doc.is_empty()); + assert_eq!(list.len(), 2, "chunked {chunked}"); + assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("Office", "HP in the office", 3, true)); + assert_eq!((list[1].name.as_str(), list[1].state, list[1].accepting_jobs), ("Stopped", 5, false)); + assert!(!list[0].is_default && !list[1].is_default); + } +} + +#[test] +fn default_printer_is_read_and_absence_is_not_an_error() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), Some("Office".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No default printer"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), None); +} + +#[test] +fn a_print_job_carries_the_document_and_returns_its_id() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&42u32.to_be_bytes()])])]), true, 200); + let pdf = b"%PDF-1.4 fake".to_vec(); + let id = + print_job(&ep, &Job { printer: "Office", is_class: false, title: "photo.psd", user: "diego", copies: 2, document: &pdf, format: "application/pdf" }) + .unwrap(); + assert_eq!(id, 42); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(doc, pdf); + let get = |n: &str| attrs.iter().find(|a| a.name == n).cloned().unwrap_or_else(|| panic!("{n}")); + assert_eq!(get("printer-uri").values[0], format!("ipp://{}/printers/Office", ep.host()).as_bytes()); + assert_eq!(get("job-name").values[0], b"photo.psd"); + assert_eq!(get("requesting-user-name").values[0], b"diego"); + assert_eq!(get("document-format").values[0], b"application/pdf"); + let copies = get("copies"); + assert_eq!((copies.group, copies.tag, copies.int()), (TAG_JOB_GROUP, TAG_INTEGER, Some(2))); + // One copy: no job attributes group at all. + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&7u32.to_be_bytes()])])]), false, 200); + print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap(); + assert!(!rx.recv().unwrap().1.iter().any(|a| a.name == "copies")); +} + +#[test] +fn error_statuses_and_broken_replies_are_errors() { + let (ep, _rx) = fake(ipp(0x0506, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"Printer is not accepting jobs"])])]), false, 200); + let e = print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap_err(); + assert_eq!(e, Error::Status(0x0506, "Printer is not accepting jobs".into())); + assert!(e.to_string().contains("0x0506")); + // A CUPS with no printer at all answers "not found": that is an empty list, not an error. + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No destinations added."])])]), false, 200); + assert_eq!(printers(&ep).unwrap(), Vec::::new()); + // HTTP 401, a reply cut short, garbage. + let (ep, _rx) = fake(ipp(0, &[ok_header()]), false, 401); + assert!(matches!(printers(&ep), Err(Error::Protocol(m)) if m.contains("401"))); + let (ep, _rx) = fake(vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 5], true, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + let (ep, _rx) = fake(b"not ipp at all".to_vec(), false, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + // Nobody listening. + let e = printers(&Endpoint::Tcp("127.0.0.1".into(), 1)).unwrap_err(); + assert!(matches!(e, Error::Unreachable(_)), "{e}"); +} + +#[test] +fn reply_parsing_is_bounded() { + assert!(Reply::parse(&[]).is_err()); + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1]).is_err(), "no end tag"); + let mut many = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP]; + for _ in 0..MAX_ATTRIBUTES + 1 { + many.extend_from_slice(&[TAG_NAME, 0, 1, b'a', 0, 0]); + } + many.push(TAG_END); + assert!(matches!(Reply::parse(&many), Err(Error::Protocol(m)) if m.contains("too many"))); + // An additional value before any attribute. + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 0, 0, 1, b'x', TAG_END]).is_err()); + assert!(dechunk(b"zz\r\n").is_err() && dechunk(b"5\r\nab").is_err()); + assert_eq!(dechunk(b"3\r\nabc\r\n0\r\n\r\n").unwrap(), b"abc"); +} + +#[cfg(unix)] +#[test] +fn the_unix_socket_endpoint_works_and_detect_honours_cups_server() { + let dir = std::env::temp_dir().join(format!("pc-ipp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("cups.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let (tx, rx) = mpsc::channel(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Sock"])])]); + std::thread::spawn(move || { + for _ in 0..3 { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Unix(stream), reply.clone(), false, 200, &tx); + } + }); + let ep = Endpoint::Unix(sock.clone()); + assert!(ep.reachable()); + assert_eq!(default_printer(&ep).unwrap(), Some("Sock".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + assert!(!Endpoint::Unix(dir.join("missing.sock")).reachable()); + assert_eq!(ep.to_string(), sock.display().to_string()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn requests_start_with_the_mandatory_attributes() { + let r = Request::new(OP_PRINT_JOB, 9).end(); + let (op, attrs, doc) = parse_request(&r).unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(Reply::parse(&r).unwrap().request_id, 9); + assert_eq!(attrs[0].name, "attributes-charset"); + assert_eq!(attrs[1].name, "attributes-natural-language"); + assert!(doc.is_empty()); +} + +/// A server that keeps the connection open after its reply (a printer reached directly, a proxy) +/// must not hold the client until the read times out: the reply ends at Content-Length, or at +/// the last chunk. +#[test] +fn replies_end_at_content_length_or_the_last_chunk_without_waiting_for_close() { + for chunked in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let one = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]); + let none = ipp(0x0406, &[ok_header()]); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + if len == 0 { + continue; + } + let mut body = vec![0u8; len]; + if reader.read_exact(&mut body).is_err() { + continue; + } + let op = parse_request(&body).map(|r| r.0).unwrap_or(0); + let reply = if op == OP_CUPS_GET_PRINTERS { one.clone() } else { none.clone() }; + let mut out = reader.into_inner(); + let mut resp = Vec::new(); + if chunked { + resp.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n"); + for chunk in reply.chunks(5) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + } else { + resp.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + resp.extend_from_slice(&reply); + } + if out.write_all(&resp).and_then(|()| out.flush()).is_err() { + continue; + } + // Keep this connection open for a while, on its own thread, and go on serving. + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(10)); + drop(out); + }); + } + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let started = std::time::Instant::now(); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1, "chunked {chunked}"); + assert!(started.elapsed() < Duration::from_secs(5), "waited for the server to close (chunked {chunked}): {:?}", started.elapsed()); + } +} + +/// A chunked reply whose payload contains the bytes of a last chunk (`\r\n0\r\n\r\n`, here inside +/// a printer name) and arrives split right after them: the reader must wait for the real last +/// chunk instead of returning a cut reply. +#[test] +fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Of\r\n0\r\n\r\nfice"])])]); + let cut = reply.windows(7).position(|w| w == b"\r\n0\r\n\r\n").unwrap() + 7; + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let (first, second) = reply.split_at(cut); + // Part one ends exactly on the pattern, inside the first chunk's payload. + let mut a = b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + a.extend_from_slice(format!("{:x}\r\n", first.len()).as_bytes()); + a.extend_from_slice(first); + out.write_all(&a).and_then(|()| out.flush()).unwrap(); + std::thread::sleep(Duration::from_millis(400)); + let mut b = b"\r\n".to_vec(); + b.extend_from_slice(format!("{:x}\r\n", second.len()).as_bytes()); + b.extend_from_slice(second); + b.extend_from_slice(b"\r\n0\r\n\r\n"); + out.write_all(&b).and_then(|()| out.flush()).unwrap(); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice"); + // The boundary walk itself: complete only at a zero-size chunk, never on payload bytes. + assert!(!chunked_complete(b"5\r\nab\r\n0")); + assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); + assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); + assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); +} + +/// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the +/// reply cap) is a protocol error, through the real reader, never an overflow panic. +#[test] +fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { + for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] { + assert!(chunked_complete(format!("{size_line}\r\n").as_bytes())); + assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let line = size_line.to_string(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut l, mut len) = (String::new(), 0usize); + loop { + l.clear(); + if reader.read_line(&mut l).unwrap_or(0) == 0 || l == "\r\n" { + break; + } + if let Some(v) = l.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let resp = format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n{line}\r\n"); + let _ = out.write_all(resp.as_bytes()).and_then(|()| out.flush()); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + match printers(&ep) { + Err(Error::Protocol(_)) => {} + other => panic!("{size_line}: expected a protocol error, got {other:?}"), + } + } +} diff --git a/docs/development.md b/docs/development.md index 239f9456bf..6891e88a9c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,6 +126,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_STROKE_TRACE=1` (or `=`; also `--stroke-trace `, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | | `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace | | `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace | +| `PHOTOCRAFT_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens diff --git a/docs/printing.md b/docs/printing.md new file mode 100644 index 0000000000..68015d278e --- /dev/null +++ b/docs/printing.md @@ -0,0 +1,67 @@ +# Printing + +File › Print renders the flattened document onto one PDF page (paper size and orientation, +position and scale, crop and registration marks, labels, colour handling) and hands that page +to a printer. The rendering is `print_cmds.rs` in `photocraft-engine`, shared by the dialog, the +batch commands and automation (`file.print`, `file.printOneCopy`); this page is about the hand-off. + +## Routes to the printer + +| Route | When | What the user sees | +|---|---|---| +| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` | +| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` | +| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log | +| **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true | + +A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a +missing route (no `lp`, no print server, no printer) moves on to the next one. + +The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`, +`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That +is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the +host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox +without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox. + +## Which printer + +The dialog's Printer field, or `"printer"` in the command, names a queue (case-insensitively; a +CUPS class counts, as with `lp -d`). +Left empty, the server's default printer is used; without a default, the only printer; with +several and no default, the error names them. With no printer at all, the portal takes over when +it is available, otherwise the error says to add one in the system settings or print to PDF. + +`file.printers` lists what the print server knows: `{"printServer": "host[:port]|socket"}` +is optional and defaults to the system's CUPS; the result is +`{server, default, printers: [{name, info, state, acceptingJobs, default, class}]}`, or an empty list +with a `note` when no server answers. `"printServer"` on `file.print` sends one job to a named +server; `"dryRun": true` renders the page and reports the equivalent `lp` command without +spooling. + +## Results + +`file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the +job went) and `pdf` (the page's path, only when `output` named one). + +## Environment + +| Variable | Effect | +|---|---| +| `CUPS_SERVER` | The print server, as CUPS itself reads it: `host[:port]` or a socket path | +| `PHOTOCRAFT_PRINT_PORTAL=1` | Always use the system print dialog (the portal) instead of `lp` or CUPS directly | +| `PHOTOCRAFT_PRINT_PORTAL=0` | Never use the portal; a missing print server is then an error | + +## Verification + +- `crates/ipp/src/tests.rs`: the client against a fake CUPS over TCP and a Unix socket: + printer listing with state and default, the default printer and its absence, `Print-Job` + with the document and the job id, chunked and content-length replies, HTTP and IPP error + statuses, broken replies. +- `crates/engine/src/print_cmds/tests.rs`: printer resolution (requested, default, only one, + several without a default, unknown, none); a complete `file.print` against a fake CUPS + (printer URI, copies, job name, document format, the PDF itself) and `file.printers`; + `lp` taking the job when it is on the path; the IPP route when it is not (a child process + with an empty path); the app's spooler when preferred, as the fallback without a printer, and + skipped when a server is named; print to PDF untouched. +- `apps/photocraft/src/print_portal.rs`: the request path rule and the paper/copies seeding of + the dialog (PWG names for the standard papers, millimetres for custom sizes). diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..3b36e7c611 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -243,7 +243,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T ## Reliability -Never-crash attribute on 29 of 29 crate roots. +Never-crash attribute on 30 of 30 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml index 0b771ae7c7..c7d30ac7d3 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml @@ -24,6 +24,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index ee6bbfac1b..e3ac74cffd 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -25,6 +25,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3d162dd458..f2e6f53514 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("codecs", Class::Standalone), // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. ("heif", Class::Standalone), + // IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix. + ("ipp", Class::Standalone), ("raw", Class::Standalone), ("affinity", Class::Standalone), ("adobe-assets", Class::Standalone), From 3c9c93ed8cb6221697c752679d877705f91f0059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:18:36 -0700 Subject: [PATCH 0500/1452] GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4) With the `auto` backend on Windows, eframe created its wgpu instance with Vulkan, and creating it, listing its adapters and asking each adapter about the window's surface loaded and ran the Intel Vulkan driver (igvk64.dll) before the adapter selector could pick Intel's DX12 adapter. When the driver crashed there, the first launch died, and only the next one moved to DX12 through the startup marker. `configure` now lists the DX12 adapters first, with a DX12-only instance, when the plan is `auto` on Windows without WGPU_BACKEND. If one of them is an Intel adapter, the instance is created with DX12 and GL only (GL stays the last resort), so no Vulkan driver is loaded. On a system with an Intel and a discrete GPU, the discrete GPU then runs on DX12 instead of Vulkan. WGPU_BACKEND, --safe-gpu, a chosen backend and the crash fallback are unchanged, and System Info still shows the Intel DirectX 12 note. The decision is `windows_auto_backends`, tested with vendor lists. --- apps/photocraft/src/gpu_startup.rs | 85 +++++++++++++++++++++++++++--- apps/photocraft/src/services.rs | 2 +- docs/development.md | 2 +- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index e79b4bafac..5e8abbd013 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -12,8 +12,10 @@ //! - macOS: Metal → CPU //! //! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance, -//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still -//! overrides everything, and `--safe-gpu` forces the CPU path for one launch. +//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an +//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan +//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path +//! for one launch. //! //! "CPU" composites on the CPU and draws the window with a software adapter where the platform //! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present @@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option { }) } +/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor +/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An +/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's +/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]' +/// choice. +pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec) -> Option { + let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows; + (auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL) +} + +/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver) +/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]). +fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::DX12, + flags: desc.flags, + backend_options: desc.backend_options.clone(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect() +} + /// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the /// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC /// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader @@ -265,13 +289,14 @@ pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option bool { +/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of +/// the instance). +pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool { let Some(c) = adapters.get(chosen) else { return false }; os == Os::Windows && c.vendor == INTEL && c.backend == wgpu::Backend::Dx12 - && adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan) + && (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)) } /// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler @@ -298,6 +323,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel let env = std::env::var("WGPU_DX12_COMPILER").ok(); create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent)); } + let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor)); + if let Some(b) = without_vulkan { + create.instance_descriptor.backends = b; + } + let vulkan_left_out = without_vulkan.is_some(); let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)); if select { let backend = plan.backend; @@ -319,7 +349,7 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into()); } - if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) { + if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) { *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into()); } @@ -613,13 +643,13 @@ mod tests { let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; let i = pick(&intel, Auto, Os::Windows).unwrap(); assert_eq!(intel[i].backend, B::Dx12); - assert!(intel_dx12_applied(&intel, i, Os::Windows)); + assert!(intel_dx12_applied(&intel, i, Os::Windows, false)); // Elsewhere (and for other vendors) Vulkan stays first. assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan); let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; let i = pick(&nv, Auto, Os::Windows).unwrap(); assert_eq!(nv[i].backend, B::Vulkan); - assert!(!intel_dx12_applied(&nv, i, Os::Windows)); + assert!(!intel_dx12_applied(&nv, i, Os::Windows, false)); // Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan. let hybrid = [intel[0], intel[1], nv[1]]; assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2)); @@ -630,6 +660,45 @@ mod tests { assert_eq!(pick(&[], Auto, Os::Windows), None); } + /// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before + /// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on + /// Windows creates the instance without Vulkan. + #[test] + fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() { + use wgpu::{Backend as B, DeviceType as T}; + const NVIDIA: u32 = 0x10de; + const AMD: u32 = 0x1002; + const WARP: u32 = 0x1414; + let auto = plan(Auto, None, None, false, Os::Windows); + let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl); + // Intel iGPU + discrete GPU: the discrete GPU is then used on DX12. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl); + // No Intel adapter: egui's default backends, Vulkan included. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None); + // `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms + // don't list the DX12 adapters at all. + let not_listed = || -> Vec { panic!("DX12 adapters listed") }; + for (p, os) in [ + (plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows), + (plan(Auto, None, None, true, Os::Windows), Os::Windows), + (plan(Vulkan, None, None, false, Os::Windows), Os::Windows), + (plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows), + (plan(Auto, None, None, false, Os::Other), Os::Other), + (plan(Auto, None, None, false, Os::Mac), Os::Mac), + ] { + assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}"); + } + // Without Vulkan adapters to compare with, System Info still names the Intel default. + let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; + let i = pick(&listed, Auto, Os::Windows).unwrap(); + assert_eq!(i, 0); + assert!(intel_dx12_applied(&listed, i, Os::Windows, true)); + assert!(!intel_dx12_applied(&listed, i, Os::Windows, false)); + } + #[test] fn cpu_prefers_software_adapters() { use wgpu::{Backend as B, DeviceType as T}; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..d6365020d8 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -244,7 +244,7 @@ fn path_of(file: &rfd::FileHandle) -> String { } /// Runs `future` to completion on this thread, sleeping until it is woken. -fn block_on(future: impl Future) -> T { +pub(crate) fn block_on(future: impl Future) -> T { struct Unpark(std::thread::Thread); impl std::task::Wake for Unpark { fn wake(self: Arc) { diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..a5b7980378 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12: when DXGI lists an Intel adapter, the app creates its wgpu instance with DX12 and GL only, so the first start doesn't load the Intel Vulkan driver, and a discrete GPU beside the Intel one also runs on DX12. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). From 16e5374bcf5cd77c764d25989786284af2218c28 Mon Sep 17 00:00:00 2001 From: ametadot <340873525+ametadot@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:53:58 +1100 Subject: [PATCH 0501/1452] fix(camera-raw): preserve document identity across queued completions --- crates/engine/src/jobs.rs | 8 +- crates/engine/tests/open_identity.rs | 51 ++++++ .../ui-egui/src/camera_raw_identity_tests.rs | 164 ++++++++++++++++++ crates/ui-egui/src/camera_raw_ui.rs | 38 ++-- crates/ui-egui/src/file_open/tests.rs | 9 +- crates/ui-egui/src/jobs_ui.rs | 35 ++-- crates/ui-egui/src/jobs_ui_tests.rs | 156 ++++++++++++++++- crates/ui-egui/src/open_prompt.rs | 4 +- 8 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 crates/engine/tests/open_identity.rs create mode 100644 crates/ui-egui/src/camera_raw_identity_tests.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 47045a37c2..8108647b00 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -477,7 +477,9 @@ impl Session { /// Open a file in the background: read (for a path) and decode on a worker, with progress /// (per layer for PSD/PSB) and cancellation, then add the document like /// [`Session::open_document`] (Color Settings policies apply). `name` names the document. - /// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. When + /// The job's result is `{document, docId, name, warnings, color}`; on wasm it runs inline. + /// `document` is the tab index at apply time; resolve `docId` before handling a delayed + /// completion, since earlier handlers may have moved or closed tabs. When /// Color Settings ask about the file's profile, the document is held back instead and the /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. @@ -518,10 +520,12 @@ impl Session { return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question})); } let (index, color) = s.open_document(r.document, None); + // Persisted IDs can collide: report the identity the session actually admitted. + let id = s.documents().get(index).ok_or(EngineError::NoDocument)?.doc.id; if let Some(st) = s.active_mut() { st.source_read_only = r.source_read_only; } - Ok(json!({"document": index, "name": name_a, "warnings": r.warnings, "color": color})) + Ok(json!({"document": index, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) }, ) } diff --git a/crates/engine/tests/open_identity.rs b/crates/engine/tests/open_identity.rs new file mode 100644 index 0000000000..2fa4ae17f6 --- /dev/null +++ b/crates/engine/tests/open_identity.rs @@ -0,0 +1,51 @@ +//! Background imports report the admitted document's identity, not just its temporary tab index. + +use std::sync::Arc; + +use photocraft_engine::Session; +use photocraft_engine::jobs::{OpenSource, Started}; +use serde_json::{Value, json}; + +fn source() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session +} + +fn open(session: &mut Session, name: &str, bytes: Vec) -> Value { + match session.start_open(name, OpenSource::Bytes(Arc::new(bytes))).unwrap() { + Started::Done(result) => result, + Started::Job(job) => session.wait_job(job).unwrap(), + } +} + +#[test] +fn open_identity_reports_the_live_document_after_tabs_move() { + let input = source(); + let bytes = photocraft_io::export(&input.active().unwrap().doc, "synthetic.png", &Default::default()).unwrap().bytes; + let mut session = source(); + let result = open(&mut session, "synthetic.png", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let imported = session.documents()[index].doc.id; + assert_eq!(result["docId"], imported.0, "the engine result must carry the actual admitted identity: {result}"); + session.move_document(index, 0).unwrap(); + assert_eq!(session.documents()[0].doc.id.0, result["docId"]); + assert_ne!(session.documents()[index].doc.id, imported, "the old index now identifies another document"); +} + +#[test] +fn open_identity_reports_the_remapped_id_when_a_native_document_is_opened_twice() { + let mut session = source(); + let original = session.active().unwrap().doc.clone(); + // A wholly synthetic in-memory native file retains its DocId. PNG and DNG allocate fresh + // IDs during decode, so they cannot deterministically exercise this collision. + let bytes = photocraft_format::PcraftWriter::new().save_zip(&original, &Default::default()).unwrap().0; + let result = open(&mut session, "synthetic.pcraft", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let admitted = session.documents()[index].doc.id; + assert_ne!(admitted, original.id, "the original document still owns its persisted ID"); + assert_eq!(result["docId"], admitted.0, "report the remapped identity, not the ID decoded from the file"); + assert!(Arc::ptr_eq(&session.documents()[0].doc, &original)); +} diff --git a/crates/ui-egui/src/camera_raw_identity_tests.rs b/crates/ui-egui/src/camera_raw_identity_tests.rs new file mode 100644 index 0000000000..9c67894524 --- /dev/null +++ b/crates/ui-egui/src/camera_raw_identity_tests.rs @@ -0,0 +1,164 @@ +//! Re-develop completion must follow the replacement document across intervening tab changes. + +use std::sync::Arc; + +use super::*; + +fn synthetic_raw() -> Vec { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (width, height) = (48, 32); + DngSpec::cfa(width, height, mosaic(&scene(width, height), width, [0, 1, 1, 2], 0, 30000)).build() +} + +struct Control { + doc: Arc, + path: Option, + revision: u64, + saved_revision: u64, + history: usize, +} + +impl Control { + fn assert_untouched(&self, app: &PhotocraftApp) { + let state = app.session.documents().iter().find(|state| state.doc.id == self.doc.id).expect("the accepted control remains open"); + assert!(Arc::ptr_eq(&state.doc, &self.doc), "the RAW completion must not filter another document"); + assert_eq!(state.path, self.path); + assert_eq!((state.revision, state.saved_revision, state.history.past_len()), (self.revision, self.saved_revision, self.history)); + assert!(state.is_dirty(), "the unrelated document still needs saving"); + } +} + +fn identity_app() -> (PhotocraftApp, RawOpen, Control) { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session.active_mut().unwrap().path = Some("accepted-control.pcraft".into()); + let state = session.active().unwrap(); + let control = Control { + doc: state.doc.clone(), + path: state.path.clone(), + revision: state.revision, + saved_revision: state.saved_revision, + history: state.history.past_len(), + }; + let bytes = Arc::new(synthetic_raw()); + let imported = photocraft_io::import("synthetic.dng", &bytes).unwrap(); + let (index, _) = session.open_document(imported.document, None); + let raw = RawOpen { document: session.documents()[index].doc.id, name: "synthetic.dng".into(), path: None, bytes: Some(bytes) }; + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + (app, raw, control) +} + +/// Run the real DNG worker and apply its replacement, holding the UI completion until after +/// another event changes the tab list. No sleeps, timing races or fake decoder are needed. +fn completed_redevelop(app: &mut PhotocraftApp, raw: &RawOpen) -> (CameraRaw, Value, photocraft_doc::DocId) { + app.background_jobs = true; + let params = CameraRaw { exposure: 1.0, contrast: 20.0, ..Default::default() }; + let started = commit_open_raw(app, ¶ms, raw).unwrap(); + let job = photocraft_engine::jobs::JobId(started["job"].as_u64().expect("the sensor re-develop runs as a real job")); + let mut result = app.session.wait_job(job).unwrap(); + let replacement = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + // The producer has a separate contract test. Supply the observed identity here to isolate + // the UI consumer's handling even when run against the old result schema. + result["docId"] = json!(replacement.0); + app.raw_redevelop = None; + app.background_jobs = false; + (params, result, replacement) +} + +#[test] +fn completion_identity_reports_the_live_replacement_after_id_collision() { + let (mut app, raw, control) = identity_app(); + let mut imported = photocraft_io::import("synthetic.dng", &synthetic_raw()).unwrap(); + // Exercise the same collision protection as a persisted native document: the result must + // name the admitted replacement, even when the incoming ID already belongs to a tab. + imported.document.id = control.doc.id; + let result = replace_raw_document(&mut app.session, raw.document, imported, false).unwrap(); + let replacement = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + assert_ne!(replacement, control.doc.id); + assert_eq!(result["docId"], replacement.0, "the callback needs the session's actual replacement identity"); + control.assert_untouched(&app); +} + +#[test] +fn completion_identity_applies_remaining_settings_after_tab_reordering() { + let (mut app, raw, control) = identity_app(); + let (params, result, replacement) = completed_redevelop(&mut app, &raw); + let index = result["document"].as_u64().unwrap() as usize; + app.session.move_document(index, 0).unwrap(); + assert_eq!(app.session.documents()[index].doc.id, control.doc.id, "another completion may move this tab before the RAW callback"); + finish_redevelop(&mut app, ¶ms, &raw, &result).unwrap(); + control.assert_untouched(&app); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.id, replacement); + assert!(!state.is_dirty(), "development finishes opening only the replacement"); + assert!(state.history.past_len() > 0, "remaining Camera Raw settings were applied"); + assert!(!app.view_docs.contains(&raw.document)); + assert!(app.view_docs.contains(&replacement)); +} + +#[test] +fn completion_identity_rejects_a_closed_replacement_without_filtering_the_active_control() { + let (mut app, raw, control) = identity_app(); + let (params, result, replacement) = completed_redevelop(&mut app, &raw); + let index = app.session.documents().iter().position(|state| state.doc.id == replacement).unwrap(); + app.session.close(index).unwrap(); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + assert!(finish_redevelop(&mut app, ¶ms, &raw, &result).is_err(), "a closed replacement must not fall back to the active document"); + control.assert_untouched(&app); + assert_eq!(app.session.documents().len(), 1); +} + +#[test] +fn completion_identity_rejects_missing_and_invalid_ids_before_any_side_effect() { + for identity in [None, Some(Value::Null), Some(json!("invalid")), Some(json!(-1)), Some(json!(u64::MAX)), Some(json!(1.5))] { + let (mut app, raw, control) = identity_app(); + let (params, mut result, _) = completed_redevelop(&mut app, &raw); + result["document"] = json!(0); + match identity { + None => { + result.as_object_mut().unwrap().remove("docId"); + } + Some(value) => result["docId"] = value, + } + app.session.set_active(0); + let views = app.view_docs.clone(); + let notices = app.ui.notices.len(); + assert!(finish_redevelop(&mut app, ¶ms, &raw, &result).is_err(), "invalid identity was accepted: {result}"); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + assert_eq!(app.view_docs, views, "identity validation precedes view remapping"); + assert_eq!(app.ui.notices.len(), notices, "identity validation precedes import notifications"); + } +} + +#[test] +fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() { + let (mut app, raw, control) = identity_app(); + app.session.set_active(0); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice)).unwrap(); + let queued = app.pending_raw_open.as_ref().expect("the explicitly named RAW was queued"); + assert_eq!(queued.document, raw.document); + assert_eq!((&queued.name, &queued.path, &queued.bytes), (&raw.name, &raw.path, &raw.bytes)); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id, "queueing does not activate the target"); + control.assert_untouched(&app); +} + +#[test] +fn queue_identity_rejects_a_missing_document_without_replacing_the_pending_review() { + let (mut app, raw, control) = identity_app(); + queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice)).unwrap(); + let pending = app.pending_raw_open.as_ref().unwrap().clone(); + app.session.set_active(0); + let missing = photocraft_doc::DocId::fresh(); + assert!(!app.session.documents().iter().any(|state| state.doc.id == missing)); + assert!(queue_open_dialog(&mut app, missing, "missing.dng", Some("missing.dng"), Some(&[1, 2, 3])).is_err()); + let queued = app.pending_raw_open.as_ref().expect("the existing review is preserved"); + assert_eq!(queued.document, pending.document); + assert_eq!((&queued.name, &queued.path, &queued.bytes), (&pending.name, &pending.path, &pending.bytes)); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + control.assert_untouched(&app); +} diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index d2f78a35c2..375b52adf0 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -28,6 +28,10 @@ const PROXY_SIDE: usize = 900; const PANEL_W: f32 = 330.0; const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; +#[cfg(test)] +#[path = "camera_raw_identity_tests.rs"] +mod identity_tests; + pub struct CameraRawDialog { pub layer: LayerId, layer_name: String, @@ -219,16 +223,15 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { warnings.iter().filter(|w| !w.contains(photocraft_io::raw::DEVELOPED_NOTE)).cloned().collect() } -/// Queues the open-time dialog for the active document (shown on the next frame). -pub fn queue_open_dialog(app: &mut PhotocraftApp, name: &str, path: Option<&str>, bytes: Option<&[u8]>) { - if let Some(st) = app.session.active() { - app.pending_raw_open = Some(RawOpen { - document: st.doc.id, - name: name.to_string(), - path: path.map(str::to_string), - bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), - }); +/// Queues the open-time dialog for the document whose import completed, independent of the +/// active tab. A delayed completion for a closed document must not review another photograph. +pub fn queue_open_dialog(app: &mut PhotocraftApp, document: photocraft_doc::DocId, name: &str, path: Option<&str>, bytes: Option<&[u8]>) -> Result<(), String> { + if document_index(app, document).is_none() { + return Err("the imported raw document is missing".into()); } + app.pending_raw_open = + Some(RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())) }); + Ok(()) } /// Opens the queued open-time dialog on its document's (only) layer. @@ -354,7 +357,8 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - } /// Swap the default-developed document `doc_id` for the re-developed one, at the same tab -/// position and with the same path. Returns `{document, previous, warnings, wb}`. +/// position and with the same path. Returns `{document, docId, previous, warnings, wb}`: +/// `document` is the tab index at apply time, `docId` is the replacement's stable identity. fn replace_raw_document( s: &mut photocraft_engine::Session, doc_id: photocraft_doc::DocId, @@ -366,22 +370,24 @@ fn replace_raw_document( let path = s.documents().get(index).and_then(|st| st.path.clone()); s.close(index); let (opened, _) = s.open_document(r.document, path); + let replacement = s.documents().get(opened).ok_or(photocraft_engine::EngineError::NoDocument)?.doc.id; let index = match s.execute("document.move", json!({"document": opened, "to": index})) { Ok(moved) => moved.get("document").and_then(Value::as_u64).map_or(opened, |i| i as usize), Err(_) => opened, }; s.set_active(index); - Ok(json!({"document": index, "previous": doc_id.0, "warnings": r.warnings, "wb": wb})) + Ok(json!({"document": index, "docId": replacement.0, "previous": doc_id.0, "warnings": r.warnings, "wb": wb})) } /// After the re-develop: keep the old document's view (zoom, scroll) on the new one, report /// the import's notes, then apply the remaining settings. fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, v: &Value) -> Result { - let index = v.get("document").and_then(Value::as_u64).ok_or("the re-develop returned no document")? as usize; - if let Some(new_id) = app.session.documents().get(index).map(|st| st.doc.id) { - for id in app.view_docs.iter_mut().filter(|id| **id == raw.document) { - *id = new_id; - } + // A preceding completion (for example Save and Close) may have shifted the tab indices. + // Validate identity before remapping views, filtering or marking any document unmodified. + let replacement = v.get("docId").and_then(Value::as_u64).map(photocraft_doc::DocId).ok_or("the re-develop returned no document identity")?; + let index = document_index(app, replacement).ok_or("the re-developed document was closed")?; + for id in app.view_docs.iter_mut().filter(|id| **id == raw.document) { + *id = replacement; } let warnings: Vec = v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 00db3ef96e..108c7eeb4f 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -858,7 +858,8 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { let i = app.jobs.opens.iter().position(|o| o.order.is_some_and(|x| x.seq == seq)).unwrap(); let tab = app.jobs.opens.remove(i); let index = app.session.add_document(Document::new(&tab.name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); - jobs_ui::finish_open_tab(&mut app, &tab, &json!({"document": index})).unwrap(); + let id = app.session.documents()[index].doc.id; + jobs_ui::finish_open_tab(&mut app, &tab, &json!({"document": index, "docId": id.0})).unwrap(); } assert_eq!(doc_names(&app), ["existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd"]); assert_eq!(app.session.active().map(|d| d.doc.name.as_str()), Some("f2.psd"), "the one that just opened is active"); @@ -867,10 +868,12 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { // A tab with a slot (dropped on the tabs) keeps it; one from a forgotten set goes to the end. let dropped = OpenTab { job: JobId(200), name: "dropped.psd".into(), path: None, slot: Some(0), order: None }; let index = app.session.add_document(Document::new("dropped.psd", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); - jobs_ui::finish_open_tab(&mut app, &dropped, &json!({"document": index})).unwrap(); + let id = app.session.documents()[index].doc.id; + jobs_ui::finish_open_tab(&mut app, &dropped, &json!({"document": index, "docId": id.0})).unwrap(); let late = OpenTab { job: JobId(201), name: "late.psd".into(), path: None, slot: None, order: Some(OpenOrder { batch: 999, seq: 0 }) }; let index = app.session.add_document(Document::new("late.psd", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); - jobs_ui::finish_open_tab(&mut app, &late, &json!({"document": index})).unwrap(); + let id = app.session.documents()[index].doc.id; + jobs_ui::finish_open_tab(&mut app, &late, &json!({"document": index, "docId": id.0})).unwrap(); assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 29da41d481..db7f4f1a89 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -525,7 +525,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result -/// (`{document, warnings, color}`). +/// (`{document, docId, warnings, color}` or `{held, …}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { let warnings: Vec = v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); @@ -546,8 +546,11 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu crate::open_prompt::arrive(app, held(doc, read_only, v.get("color").cloned(), false)); return Ok(()); } - let mut index = v.get("document").and_then(Value::as_u64).ok_or("the open job returned no document")? as usize; - // An earlier file is waiting on its prompt: this one opens after it, in order. + // poll_jobs applies all ready jobs before the shell handles their events. An earlier + // handler may have moved or closed tabs, so the apply-time index is no longer authority. + let document = v.get("docId").and_then(Value::as_u64).map(DocId).ok_or("the open job returned no document identity")?; + let index = app.session.documents().iter().position(|st| st.doc.id == document).ok_or("the open job's document is missing")?; + // An earlier file is waiting on its prompt: this document waits after it, in order. if crate::open_prompt::waiting(app) && let Some(st) = app.session.close(index) { @@ -556,20 +559,25 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu crate::open_prompt::arrive(app, held(doc, read_only, None, true)); return Ok(()); } - if let Some(slot) = slot - && let Ok(moved) = app.run("document.move", json!({"document": index, "to": slot})) - { - index = moved.get("document").and_then(Value::as_u64).map_or(index, |i| i as usize); + if let Some(slot) = slot { + let _ = app.run("document.move", json!({"document": index, "to": slot})); } - app.session.set_active(index); - after_open(app, name, path, &warnings, None); - Ok(()) + after_open(app, document, name, path, &warnings, None) } -/// What every open does once its document is the active one: remember where it came from, say +/// What every open does for its admitted document: remember where it came from, say /// so, show the import warnings (a raw opens in Camera Raw first, developed from `path` or /// `bytes`) and fire the "Open Document" script events. -pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, warnings: &[String], bytes: Option<&[u8]>) { +pub(crate) fn after_open( + app: &mut PhotocraftApp, + document: DocId, + name: &str, + path: Option<&str>, + warnings: &[String], + bytes: Option<&[u8]>, +) -> Result<(), String> { + let index = app.session.documents().iter().position(|st| st.doc.id == document).ok_or("the opened document is missing")?; + app.session.set_active(index); if let Some(p) = path { app.opened_from(p); } @@ -582,7 +590,7 @@ pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str> app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. if crate::camera_raw_ui::wants_open_dialog(app, warnings) { - crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes); + crate::camera_raw_ui::queue_open_dialog(app, document, name, path, bytes)?; notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings)); } else { notices::io_warnings(app, &opened, warnings); @@ -590,6 +598,7 @@ pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str> // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut app.session); app.sync_views(); + Ok(()) } // ------------------------------------------------------------------------------------- drawing diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..7fcd7674e0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -778,7 +778,161 @@ fn background_open_warns_about_missing_fonts() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); // What the open job leaves behind: the decoded document in the session, then its result. let (index, _) = app.session.open_document(doc, None); - super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "warnings": []}), None).unwrap(); + let id = app.session.documents()[index].doc.id; + super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "docId": id.0, "warnings": []}), None).unwrap(); let want = vec!["font 'NoSuchFamily4559' is not installed; text may render using a fallback face".to_string()]; assert!(app.ui.notices.last().is_some_and(|n| n.title.contains("poster.psd") && n.lines == want), "{:?}", app.ui.notices); } + +fn identity_app() -> PhotocraftApp { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session.active_mut().unwrap().path = Some("accepted-control.pcraft".into()); + assert!(session.active().unwrap().is_dirty()); + PhotocraftApp::new(session, crate::Services::default()) +} + +fn identity_png(profile: Option) -> Vec { + let app = identity_app(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + doc.icc_profile = profile.map(|profile| profile.profile().to_bytes()); + photocraft_io::export(&doc, "synthetic.png", &Default::default()).unwrap().bytes +} + +/// Complete the real decoder/apply path without delivering its result to the UI yet. This is +/// the same boundary as a poll that applies all ready jobs before handling their UI events. +fn completed_identity_open(app: &mut PhotocraftApp, name: &str, bytes: Vec) -> Value { + match app.session.start_open(name, photocraft_engine::jobs::OpenSource::Bytes(Arc::new(bytes))).unwrap() { + Started::Done(result) => result, + Started::Job(job) => app.session.wait_job(job).unwrap(), + } +} + +struct IdentityControl { + doc: Arc, + path: Option, + revision: u64, + saved_revision: u64, + history: usize, + redo: bool, +} + +impl IdentityControl { + fn capture(app: &PhotocraftApp) -> Self { + let state = app.session.active().unwrap(); + Self { + doc: state.doc.clone(), + path: state.path.clone(), + revision: state.revision, + saved_revision: state.saved_revision, + history: state.history.past_len(), + redo: state.history.can_redo(), + } + } + + fn assert_untouched(&self, app: &PhotocraftApp) { + let state = app.session.documents().iter().find(|state| state.doc.id == self.doc.id).expect("the accepted control remains open"); + assert!(Arc::ptr_eq(&state.doc, &self.doc), "the accepted control's pixels and document data must not change"); + assert_eq!(state.path, self.path, "an import must not rebind another document's save path"); + assert_eq!( + (state.revision, state.saved_revision, state.history.past_len(), state.history.can_redo()), + (self.revision, self.saved_revision, self.history, self.redo) + ); + assert!(state.is_dirty(), "the unrelated document must still need saving"); + } +} + +/// Exercise the consumer separately from the engine result-schema regression: the identity is +/// observed from the real imported document, then supplied even on the old producer. +fn identified_result(app: &PhotocraftApp, mut result: Value) -> (Value, photocraft_doc::DocId) { + let id = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + result["docId"] = json!(id.0); + (result, id) +} + +#[test] +fn open_identity_survives_tab_reordering() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + let result = completed_identity_open(&mut app, "imported.png", identity_png(None)); + let (result, imported) = identified_result(&app, result); + let old_index = result["document"].as_u64().unwrap() as usize; + app.session.move_document(old_index, 0).unwrap(); + assert_eq!(app.session.documents()[old_index].doc.id, control.doc.id); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, None).unwrap(); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, imported); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("imported.png")); +} + +#[test] +fn open_identity_survives_an_earlier_tab_closing() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + app.session.execute("file.new", json!({"width": 2, "height": 2})).unwrap(); + let result = completed_identity_open(&mut app, "imported.png", identity_png(None)); + let (result, imported) = identified_result(&app, result); + app.session.close(1).unwrap(); + app.session.set_active(0); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, Some(0)).unwrap(); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, imported); + assert_eq!(app.session.documents()[0].doc.id, imported, "the requested destination slot applies to the correct document"); +} + +#[test] +fn open_identity_rejects_closed_missing_and_invalid_identities() { + for identity in [None, Some(Value::Null), Some(json!("not-an-id")), Some(json!(-1)), Some(json!(u64::MAX)), Some(json!(1.5))] { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + let result = completed_identity_open(&mut app, "closed.png", identity_png(None)); + let (mut result, imported) = identified_result(&app, result); + app.session.close(result["document"].as_u64().unwrap() as usize).unwrap(); + // A reused/stale index must never become permission to finish another document. + result["document"] = json!(0); + match identity { + None => { + result.as_object_mut().unwrap().remove("docId"); + } + Some(value) => result["docId"] = value, + } + assert!(super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, Some(0)).is_err(), "invalid identity was accepted: {result}"); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + assert_eq!(app.session.documents().len(), 1); + result["docId"] = json!(imported.0); + assert!( + super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, None).is_err(), + "a closed identity must not fall back to the old tab index" + ); + control.assert_untouched(&app); + } +} + +#[test] +fn open_identity_keeps_same_poll_results_in_the_profile_queue() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + app.session.execute("edit.colorSettings", json!({"askOnMismatch": true, "askOnMissing": false, "policyRgb": "preserve"})).unwrap(); + let mismatched = identity_png(photocraft_cms::Builtin::from_id("adobe-rgb-compat")); + let prompt = completed_identity_open(&mut app, "profile.png", mismatched); + assert!(prompt["held"].as_u64().is_some()); + let first = completed_identity_open(&mut app, "first.png", identity_png(None)); + let (first, first_id) = identified_result(&app, first); + let second = completed_identity_open(&mut app, "second.png", identity_png(None)); + let (second, second_id) = identified_result(&app, second); + // All engine applies have finished. Handling the first ordinary result removes its tab, + // shifting the second result before its UI callback runs. + super::finish_open(&mut app, "profile.png", Some("profile.png"), &prompt, None).unwrap(); + super::finish_open(&mut app, "first.png", Some("first.png"), &first, None).unwrap(); + super::finish_open(&mut app, "second.png", Some("second.png"), &second, None).unwrap(); + assert_eq!(app.held_opens.iter().map(|held| held.name.as_str()).collect::>(), ["profile.png", "first.png", "second.png"]); + assert_eq!(app.held_opens.iter().skip(1).map(|held| held.doc.id).collect::>(), [first_id, second_id]); + assert_eq!(app.session.documents().len(), 1, "both completed imports wait behind the profile prompt"); + control.assert_untouched(&app); + assert_eq!(crate::open_prompt::answer(&mut app, None).unwrap()["cancelled"], 3); + assert!(app.held_opens.is_empty()); + control.assert_untouched(&app); +} diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs index a4d93ca057..e2b1603061 100644 --- a/crates/ui-egui/src/open_prompt.rs +++ b/crates/ui-egui/src/open_prompt.rs @@ -169,6 +169,8 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> Result index, }; app.session.set_active(index); - crate::jobs_ui::after_open(app, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice)); + crate::jobs_ui::after_open(app, document, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice))?; Ok(json!({"document": index, "action": action})) } From f8cd3e79829ee224151cb70088211d2e5f526473 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 17:08:37 -0600 Subject: [PATCH 0502/1452] chore: clear the rustc 1.96 clippy lint that fails CI Unrelated to this branch's change; main already fails clippy --all-targets -- -D warnings without it, which is what failed the Windows integration job here. One clippy::nonminimal_bool site in canvas.rs, taking clippy's own rewrite: the negation stays outside, so the is_finite guards and their NaN behaviour are unchanged. A sweep of all 30 workspace members found no other site. --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 914d395acc..71bb439afa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4354,7 +4354,7 @@ pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator< }; for (pa, pb) in lines { // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). - if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) { continue; } let (pa, pb) = if pa.distance(pb) > LONG { From ead6d2197f5d0b82d70f3fddb70e472fc27ce577 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 17:20:33 -0600 Subject: [PATCH 0503/1452] Package: report its paths with `/` on Windows too (#7184) `file.package` absolutised the caller's `dir` and then built `folder`, `document` and every `links` entry on it with `join`, which always writes `/`. On Windows that left one string spelt both ways: `C:\Users\...\pkg/Print Me/Links/art.png`. The control channel takes `/` and rejects alternate separators, so a reported path could not be handed back to it. Spell the absolutised directory with `/` as well, keeping a leading `\\` (verbatim and UNC prefixes, where Win32 does not accept `/`) intact. --- crates/engine/src/file_cmds.rs | 11 +++++++++++ crates/engine/src/print_cmds.rs | 3 +++ crates/engine/src/print_cmds/tests.rs | 9 ++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 0f4ec1591b..7a86eb0ca1 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -242,6 +242,17 @@ pub fn same_file(a: &str, b: &str) -> bool { false } +/// A path as command results report it: `/` between components, whatever the platform. +/// +/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute` +/// carries the native separator, which would leave a reported path mixed. The control channel +/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so +/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep +/// theirs: Win32 does not accept `/` after a leading `\\`. +pub(crate) fn forward_slashes(path: String) -> String { + if path.starts_with(r"\\") { path } else { path.replace('\\', "/") } +} + pub(crate) fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index ed0f2cefce..3cc99aa276 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -805,7 +805,10 @@ fn package(s: &mut Session, p: &Value) -> Result { let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?; // The copies are relinked by absolute path: a relative one would be resolved against the // package document's folder, not the working directory `dir` is relative to. + // `folder`, `document` and `links` are built on it with `join`, so it is spelt the same way: + // `/` throughout, not a native prefix with `/` after it. let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned()); + let dir = crate::file_cmds::forward_slashes(dir); let d = s.active().ok_or(EngineError::NoDocument)?; let doc = (*d.doc).clone(); let document = d.path.clone(); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 34c1cda6fa..ddd1a65a16 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -5,7 +5,9 @@ fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d.to_string_lossy().into_owned() + // Spelt the way command results spell paths, so an expectation built with `/` matches on + // Windows too. Win32 takes `/` as a separator, so the directory is still usable as one. + d.to_string_lossy().replace('\\', "/") } fn session(mode: &str, depth: u32) -> Session { @@ -254,7 +256,12 @@ fn package_copies_links_and_relinks() { let copied = format!("{dir}/pkg/Print Me/Links/art.png"); assert_eq!(r["links"], json!([copied])); assert!(std::path::Path::new(&copied).is_file()); + // Reported paths are `/`-spelt on every platform, not the native separator up to `dir` and + // `/` after it: a client hands them back to the control channel, which takes only `/`. let docp = r["document"].as_str().unwrap(); + for p in [r["folder"].as_str().unwrap(), docp, r["links"][0].as_str().unwrap()] { + assert!(!p.contains('\\'), "{p}"); + } assert!(docp.ends_with("Print Me/Print Me.pcraft")); let back = photocraft_io::import("Print Me.pcraft", &std::fs::read(docp).unwrap()).unwrap().document; let linked: Vec = back From e4bc5c25e85364f1dd4a0e2248464b01913f243b Mon Sep 17 00:00:00 2001 From: Stepan Pavlenko Date: Thu, 15 Oct 2026 22:35:50 +0300 Subject: [PATCH 0504/1452] i18n(ru): translate the dynamic labels the Russian catalog was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys) but lacked the runtime-only labels the other catalogs already carry, so they showed in English: filter and adjustment parameter names and choices, gallery names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…), History/Layers/paths/channels menu labels, metadata and File Info fields, System Info and hardware status lines, rasterize prompts, composed status templates and dialog text. All of them are translated (1,229 keys added; the catalog is now 4,227 keys against upstream's 2,998), written from the meaning of the English labels and reusing the vocabulary the catalog already uses (Highlights → Света, Midtones → Полутона, Feather → Растушёвка, Pattern → узор). Filter and gallery names are aligned with the menu labels already in the catalog so a filter never reads two ways (Texturizer → Текстуризатор, Unsharp Mask → Нерезкое маскирование, Shape Blur → Размытие по фигуре, …). Tests: `russian_covers_generated_filter_options_and_gallery_names` (filter_dialog.rs, the Italian/Korean/Spanish check) and `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs). Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13, ROADMAP, ATTRIBUTION and LICENSE-translations. Validated on a rebase onto upstream/main: `cargo test -p photocraft-ui-egui --lib` 2,998 passed / 0 failed; clippy clean (the only lint is a pre-existing `nonminimal_bool` in canvas.rs, untouched here and present on upstream main); `xtask layers` OK; `xtask wasm` OK; `xtask i18n-coverage` ru 1,863 / 1,863; offscreen `snapshot` in Russian checked. --- ATTRIBUTION.md | 1 + ROADMAP.md | 5 +- crates/ui-egui/src/filter_dialog.rs | 6 + .../ui-egui/src/i18n/LICENSE-translations.txt | 7 +- crates/ui-egui/src/i18n/mod.rs | 51 + crates/ui-egui/src/i18n/ru.tsv | 1238 +++++++++++++++++ docs/gaps.md | 11 +- docs/localization-parity.md | 9 +- docs/localization-ru.md | 75 + 9 files changed, 1391 insertions(+), 12 deletions(-) create mode 100644 docs/localization-ru.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1ae19efa66..964a071cc5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,7 @@ next to them. | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..ca3bd5cbb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). +Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). ## Upcoming @@ -136,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..87eca79b94 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1475,6 +1475,12 @@ mod tests { assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); } + #[test] + fn russian_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap()); + assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 93a609f946..0e9c354446 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, -at your option. +Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR +Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -37,3 +37,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Serbian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Russian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..3cff605b18 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1111,6 +1111,57 @@ mod tests { } } + /// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels, + /// hardware status, rasterize prompts, History/paths menus) render in Russian, not English. + #[test] + fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() { + let ru = Lang::from_code("ru").expect("ru registered"); + for kind in [ + crate::rasterize_prompt::Kind::Type, + crate::rasterize_prompt::Kind::Shape, + crate::rasterize_prompt::Kind::SmartObject, + crate::rasterize_prompt::Kind::Fill, + ] { + assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind); + } + assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0"); + assert_eq!( + fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), + "Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1" + ); + assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64"); + assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер"); + assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}"); + // Filter / adjustment parameter names. + for label in [ + "Amount", + "Radius", + "Threshold", + "Angle", + "High Pass", + "Smart Blur", + "Reduce Noise", + "Lens Correction", + "Colored Pencil", + "Angled Strokes", + "Stained Glass", + "Sumi-e", + "Film Grain", + "Unsharp Mask", + "Lens Flare", + ] { + assert!(has(ru, label), "Russian filter label missing: {label}"); + } + // Dynamic composed status templates. + assert_eq!(fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]), "Шаг 2 (file.export) не выполнен: ok"); + assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied"); + assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)"); + assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)"); + // Document and layer names stay verbatim. + let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]); + assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}"); + } + /// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus /// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as /// they are (names, units) are listed in `KEEP_AS_IS`. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..2f99e293ac 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3181,3 +3181,1241 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + + +# Dynamic UI labels (fourth pass): filter and adjustment parameters, Camera Raw panels, +# lighting, History/Layers/paths/channels menus, metadata, System Info, status templates and +# dialog text that other catalogs already carry. Written from the meaning of the English labels +# in the ordinary Russian graphics-editor vocabulary; no proprietary resource consulted. + (coming soon) (скоро) + (none) (нет) + 32-bit Preview Options Параметры предпросмотра 32 бит + 3D… 3D… + A: Ш: + AI Integrations Интеграции ИИ + About {app} О программе {app} + Accented Edges Акцентированные края + Action playback failed: {error} Не удалось воспроизвести операцию: {error} + Actual Pixels Реальный размер + Add Mask Добавить маску + Add Noise Добавить шум + Add a mask (from the selection; ⌥ inverts) Добавить маску (из выделенной области; ⌥ инвертирует) + Add space after paragraph Добавить интервал после абзаца + Add space before paragraph Добавить интервал перед абзацем + Add to selection (⇧) Добавить к выделенной области (⇧) + Added swatch {name} Добавлен образец {name} + Adds grain to the soft edges of the brush tip. Добавляет зерно к мягким краям кисти. + Adjust Interval For Loops Настроить интервал циклов + Adjustment Layers Корректирующие слои + Adjustment Properties Свойства коррекции + Adjustment layers have nothing to transform В корректирующих слоях нечего трансформировать + Adobe PDF Presets… Наборы Adobe PDF… + Adobe-rgb-compat Adobe RGB (совместимо) + Adwaita (Dark) Adwaita (тёмная) + Adwaita (Light) Adwaita (светлая) + After После + Airbrush: keeps painting while the pointer rests. Аэрограф: продолжает рисовать, пока указатель неподвижен. + All shortcuts reset to Photoshop defaults. Все сочетания клавиш сброшены к значениям Photoshop по умолчанию. + Altitude Высота + Ambience Атмосфера + Ambient Окружающий свет + Amplitude Max Макс. амплитуда + Amplitude Min Мин. амплитуда + Android 1080p Android 1080p + Angled Strokes Наклонные штрихи + Anisotropic Анизотропный + Any Любой + Apple Watch 45mm Apple Watch 45 мм + Apply Data Set Применить набор данных + Apply Image Внешний канал + Apply Mask Применить маску + Apply to All Применить ко всем + Apply {option}: {state} Применить {option}: {state} + Arbitrary Произвольный + Arc Lower Нижняя дуга + Arc Upper Верхняя дуга + Are you sure you want to quit? Вы действительно хотите выйти? + Are you sure? Вы уверены? + Around Center Вокруг центра + Arrangement Расположение + Arrows Стрелки + Artboard from Group Монтажная область из группы + Artboard from Layers Монтажная область из слоёв + Artboards Монтажные области + Artboards to Files Монтажные области в файлы + Artboards to PDF Монтажные области в PDF + Artistic Художественные + Ask On Mismatch Спрашивать при несовпадении + Ask On Missing Спрашивать при отсутствии + Ask On Paste Спрашивать при вставке + Assign Profile Назначить профиль + At Позиция + Author Title Имя автора + Auto Scale Автомасштаб + Auto-Align Layers Автовыравнивание слоёв + Auto-Blend Layers Автосмешивание слоёв + Autosave failed: {error} Не удалось выполнить автосохранение: {error} + Back Назад + Backend: {backend} ({type}) Сервер: {backend} ({type}) + Background Level Уровень фона + Bamboo Бамбук + Bas Relief Барельеф + Base Tree Type Базовый тип дерева + Based On На основе + Baseline shift Смещение базовой линии + Basic Latin Основная латиница + Batch Пакетная обработка + Before До + Bend Изгиб + Berries Ягоды + Best Наилучшее + Black Clip Отсечение чёрного + Black Intensity Интенсивность чёрного + Blade Curvature Кривизна лепестков диафрагмы + Blast Порыв + Bleach Bypass Пропуск отбеливания + Blend Clipped Layers as Group Смешивать обтравочные слои как группу + Blend Interior Effects as Group Смешивать внутренние эффекты как группу + Blend Text Gamma Гамма смешивания текста + Blending Mode Режим наложения + Bloat Вздутие + Blocks Блоки + Blue Yellow Синий–жёлтый + Blues Синие тона + Border Граница + Border Thickness Толщина границы + Box Blur Размытие по рамке + Bpc Компенсация чёрной точки + Branches Color Цвет ветвей + Branches Height Высота ветвей + Branches Thickness Толщина ветвей + Breeze Dark Breeze (тёмная) + Breeze Light Breeze (светлая) + Brick Кирпич + Bristle Detail Детализация щетины + Browse Обзор + Brush Detail Детализация кисти + Brush Settings… (F5): every option of this brush Параметры кисти… (F5): все параметры этой кисти + Brush Strokes Мазки кисти + Brush Type Тип кисти + Burlap Мешковина + Calculations Вычисления + Camera Raw Camera Raw + Camera Raw Filter Фильтр Camera Raw + Cancel transform Отменить трансформирование + Candle Light Свет свечи + Canvas Size Размер холста + Case Sensitive Учитывать регистр + Ccw Против часовой стрелки + Cell Size Размер ячейки + Center X Центр X + Center Y Центр Y + Chalk Area Область мела + Change Find Найти и заменить + Changes Saved Изменения сохранены + Changes have not been saved. Изменения не сохранены. + Change… Изменить… + Channel1 Канал 1 + Channel2 Канал 2 + Channel3 Канал 3 + Channel4 Канал 4 + Charcoal Area Область угля + Charcoal Thickness Толщина угля + Check for Updates Проверить обновления + Checkerboard Шахматная доска + Choke Поджатие + Choose Folder Выбрать папку + Circle Круг + Cleanliness Чистота + Clear Amount Величина очистки + Clear Selection Отменить выделение + Click sets foreground · right-click sets background Щелчок задаёт цвет переднего плана · щелчок правой кнопкой задаёт цвет фона + Click the first point or press ↵ to close · Esc cancels Щёлкните первую точку или нажмите ↵, чтобы замкнуть · Esc отменяет + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Щёлкните, чтобы добавить точку · перетащите за пределы, нажмите ⌘/Ctrl и щёлкните или нажмите Delete, чтобы удалить + Click to apply, ⌥-click to clear overrides, double-click for Style Options Щёлкните, чтобы применить, ⌥+щелчок, чтобы очистить переопределения, двойной щелчок — параметры стиля + Click to zoom in · ⌥-click to zoom out Щёлкните, чтобы увеличить · ⌥+щелчок, чтобы уменьшить + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Щелчок: угол · Перетаскивание: сглаживание · Щелчок по первой точке: замкнуть · ↩ завершить · Esc отменить + Clipping Mask Обтравочная маска + Clipping Path… Контур отсечения… + Clone Клонировать + Clone source set at {x}, {y} Источник клонирования задан в {x}, {y} + Close Window Закрыть окно + Clumped Сгруппированные + Cmyk CMYK + Coarse Dots Крупные точки + Coated-cmyk Мелованная CMYK + Collapse group (⌥-click: all groups) Свернуть группу (⌥+щелчок: все группы) + Color Decontaminate Удаление цветового ореола + Color Halftone Цветные полутона + Color Indicates Masked Areas Цветом обозначены маскированные области + Color Indicates Selected Areas Цветом обозначены выделенные области + Color Lookup Tables Таблицы перекодировки цвета + Color Picker Палитра цветов + Color Picker (Background Color) Палитра цветов (цвет фона) + Color Picker (Custom Canvas Color) Палитра цветов (свой цвет холста) + Color Picker (Foreground Color) Палитра цветов (цвет переднего плана) + Color Sampler Tool Инструмент «Цветовой эталон» + Color Settings Настройки цвета + Color Swatches Образцы цветов + Color Table Цветовая таблица + Color management: {error} Управление цветом: {error} + Colored Pencil Цветной карандаш + Comic Book Комикс + Command Команда + Commit Perspective Warp (↩) Применить перспективную деформацию (↩) + Commit Puppet Warp (↩) Применить марионеточную деформацию (↩) + Commit any current edits (⌘↩) Применить текущие изменения (⌘↩) + Commit current crop operation (↵) Применить текущую операцию кадрирования (↵) + Commit transform Применить трансформирование + Commit transform (↩) Применить трансформирование (↩) + Commit warp (↩) Применить деформацию (↩) + Compare Сравнить + Compression Сжатие + Conditional Mode Change Условная смена режима + Confirm Подтвердить + Conform Подогнать + Constraints Ограничения + Content-Aware Fill needs a selection on a pixel layer Заливка с учётом содержимого требует выделенной области на пиксельном слое + Continue Продолжить + Contract Сжать + Contrasty Контрастный + Convert Преобразовать + Convert to Alpha Channel Преобразовать в альфа-канал + Convert to Linked Преобразовать в связанный + Convert to Profile Преобразовать в профиль + Cool Холодный + Cooling L B B Охлаждающий (LBB) + Cooling80 Охлаждающий (80) + Cooling82 Охлаждающий (82) + Copy Complete Stroke Копировать полный контур + Copy Fill Копировать заливку + Copyright Url URL авторских прав + Correct C A Коррекция хроматической аберрации + Correct Distortion Коррекция дисторсии + Correct Vignette Коррекция виньетирования + Could not rename the action: {error} Не удалось переименовать операцию: {error} + Could not transform: the layer is empty Не удалось трансформировать: слой пуст + Could not use Perspective Warp: the layer is empty Не удалось применить перспективную деформацию: слой пуст + Could not use Puppet Warp: no opaque pixels Не удалось применить марионеточную деформацию: нет непрозрачных пикселей + Could not use Puppet Warp: the layer is empty Не удалось применить марионеточную деформацию: слой пуст + Couldn't open {name}: {error} Не удалось открыть {name}: {error} + Couldn't save preferences: {error}. Retrying… Не удалось сохранить настройки: {error}. Повтор… + Crack Brightness Яркость трещин + Crack Depth Глубина трещин + Crack Spacing Интервал трещин + Craquelure Кракелюр + Create By Создать с помощью + Create Vector Mask Создать векторную маску + Create Warped Text Создать деформированный текст + Create a new layer (⇧⌘N) Создать новый слой (⇧⌘N) + Create new brush from the current settings Создать новую кисть из текущих параметров + Create new brush preset from the current settings Создать новый набор кистей из текущих параметров + Create… Создать… + Cross Крест + Cross Process Кросс-процесс + Crosshatch Перекрёстная штриховка + Crosswise Перекрёстно + Crystallize Кристаллизация + Currency Валюта + Curve Darks Кривая: тёмные тона + Curve Highlights Кривая: светлые тона + Curve Lights Кривая: светлые тона + Curve Shadows Кривая: тени + Curve Splits Кривая: разрывы + Custom Branch Color Свой цвет ветвей + Custom Pixel Aspect Ratio Своё пиксельное соотношение + Custom pattern Свой узор + Cutout Аппликация + Cw По часовой стрелке + Cyans Голубые тона + Cyrillic Кириллица + Daisies Маргаритки + Dark Gray Тёмно-серый + Dark Intensity Интенсивность тёмного + Dark Rough Тёмный шероховатый + Dark Strokes Тёмные штрихи + Darken Only Только затемнение + Darkness Затемнение + Data Данные + Data Set: {name} ({index}/{total}) Набор данных: {name} ({index}/{total}) + Data Sets as Files Наборы данных в файлы + Date Дата + Day For Night День как ночь + De-Interlace Устранение чересстрочности + Decrease Brush Hardness Уменьшить жёсткость кисти + Decrease Brush Size Уменьшить размер кисти + Decrease Type Size Уменьшить размер текста + Decrease Type Size by 10 Уменьшить размер текста на 10 + Deep Глубокий + Deep Blue Глубокий синий + Deep Emerald Глубокий изумрудный + Deep Red Глубокий красный + Deep Yellow Глубокий жёлтый + Default Foreground and Background Colors Цвета переднего плана и фона по умолчанию + Default Leaves Листья по умолчанию + Default Photoshop Size Размер Photoshop по умолчанию + Defaults По умолчанию + Define Brush Preset Определить кисть + Define Custom Shape Определить свою фигуру + Define Pattern Определить паттерн + Definition Чёткость + Defringe Убрать кайму + Degrees Градусы + Delete Channel Удалить канал + Delete Mask Удалить маску + Delete Workspace Удалить рабочую среду + Delete brush Удалить кисть + Delete path Удалить контур + Delete vector mask Удалить векторную маску + Delete… Удалить… + Depth Map Карта глубины + Depth Mode Режим глубины + Diameter Диаметр + Diamond Ромб + Diffuse Диффузия + Diffuse Glow Рассеянное свечение + Direction Направление + Direction Balance Баланс направлений + Disable Выключить + Disable Mask Отключить маску + Disable Symmetry Path Отключить контур симметрии + Disabled Отключено + Discard Changes Отменить изменения + Displace Смещение + Display Дисплей + Display profile not used Профиль дисплея не используется + Display profiles: {reason} Профили дисплеев: {reason} + Display-p3 Display P3 + Distance Расстояние + Distortion Дисторсия + Distribution Распределение + Do you want to save you made to “{name}” before quitting? Сохранить изменения в «{name}» перед выходом? + Document Sizes Размеры документа + Documentation Документация + Documents Документы + Dont Enlarge Не увеличивать + Dot Точка + Double Line Двойная линия + Down Вниз + Draft Черновик + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Перетащите рамку кадрирования · перетаскивание внутри перемещает · края изменяют размер (соотношение {ratio}, центр {centre}) · пробел перемещает при рисовании · {commit} применяет · Esc отменяет + Drag: draw freehand · Release on the start point: close Перетаскивание: рисование от руки · Отпустите на начальной точке: замкнуть + Driver: {driver} Драйвер: {driver} + Dry Brush Сухая кисть + Duplicate Channel Дублировать канал + Duplicate Layer Дублировать слой + Duplicate Layers Дублировать слои + Duplication Дублирование + Dust & Scratches Пыль и царапины + Edge Край + Edge Brightness Яркость краёв + Edge Extension Расширение краёв + Edge Fidelity Точность краёв + Edge Intensity Интенсивность краёв + Edge Only Только края + Edge Simplicity Простота краёв + Edge Thickness Толщина краёв + Edge Width Ширина краёв + Edit Toolbar Настроить панель инструментов + Edit… Изменить… + Elevation Возвышение + Eliminate Устранить + Emboss Тиснение + Enable Включить + Enable Mask Включить маску + End X Конец X + End Y Конец Y + Enlarged Увеличенный + Erase with Brush or Pencil (hold) Стирание кистью или карандашом (удерживайте) + Error Ошибка + Even Fields Чётные поля + Exit Quick Mask Выйти из быстрой маски + Expand group (⌥-click: all groups) Развернуть группу (⌥+щелчок: все группы) + Export As Экспортировать как + Export As: {name} Экспортировать как: {name} + Export Contents Экспортировать содержимое + Export Image Экспортировать изображение + Export Options Параметры экспорта + Export Preferences Экспорт настроек + Export Settings Экспортировать настройки + Export to Экспортировать в + Export/Import Presets Экспорт/импорт наборов + Exported contents to {path} Содержимое экспортировано в {path} + Exported presets to {path} Наборы экспортированы в {path} + Exported {path} Экспортировано: {path} + Exported {path} ({size}) Экспортировано: {path} ({size}) + Exporting… Экспорт… + ExtraBold Сверхжирный + ExtraLight Сверхтонкий + Extrude Экструзия + Fade Ослабить + Faded Film Выцветшая плёнка + Failed Не удалось + Failed to open file Не удалось открыть файл + Failed to save file Не удалось сохранить файл + Fallback: {reason} Резерв: {reason} + Feather Radius Радиус растушёвки + Fiber Length Длина волокон + Fibers Волокна + Field Blur Размытие по полю + File Format Формат файла + File Info Сведения о файле + File Name Имя файла + File Size Размер файла + File not found Файл не найден + Filename Имя файла + Fill Color Цвет заливки + Fill Opacity Непрозрачность заливки + Fill Path… Залить контур… + Fill path with foreground color Залить контур цветом переднего плана + Fill with Background Color Залить цветом фона + Fill with Background Color, Preserve Transparency Залить цветом фона, сохранить прозрачность + Fill with Foreground Color Залить цветом переднего плана + Fill with Foreground Color, Preserve Transparency Залить цветом переднего плана, сохранить прозрачность + Film Grain Зернистость плёнки + Filter Gallery Галерея фильтров + Find Найти + Find and Replace Text Найти и заменить текст + Fine Тонкий + Fine Dots Мелкие точки + Finish Готово + Fit Image Подогнать изображение + Fit to Window По размеру окна + Flame Пламя + Flame Bottom Alignment Выравнивание низа пламени + Flame Lines Линии пламени + Flame Shape Форма пламени + Flame Style Стиль пламени + Flame Type Тип пламени + Flat Плоский + Flat Shading Плоское затенение + Flower Color Цвет цветков + Focal Distance Дистанция фокуса + Focus Фокус + Focus Area Область фокусировки + Font size Размер шрифта + Foreground Level Уровень переднего плана + Forward Вперёд + Forward Warp Прямая деформация + Frame Кадр + Frame {current} / {total} Кадр {current} / {total} + Free Transform Path Свободное трансформирование контура + Freeze Lasso Заморозить лассо + Freeze Mask Заморозить маску + Fresco Фреска + From От + From Left Слева + From Right Справа + Frosted Матовое + Full Screen Во весь экран + Fullscreen Полноэкранный + Fuzziness Разброс + GPU backend preference: {backend} Предпочитаемый сервер GPU: {backend} + GPU device was lost; using the CPU renderer. Устройство GPU потеряно; используется отрисовка на CPU. + GPU error; using the CPU renderer. Ошибка GPU; используется отрисовка на CPU. + GPU retry scheduled Запланирована повторная попытка GPU + Gamma Гамма + Gaussian Blur Размытие по Гауссу + General Blending Общее наложение + General Brushes Общие кисти + Generators Генераторы + Generic Общий + Glass Стекло + Global Light Глобальный свет + Gloss Глянец + Glow Amount Величина свечения + Glow Brightness Яркость свечения + Glow Size Размер свечения + Glowing Edges Светящиеся контуры + Good Хорошее + Grade Balance Цветокоррекция: баланс + Grade Blending Цветокоррекция: смешивание + Grade Global Цветокоррекция: общая + Grade Highlights Цветокоррекция: светлые тона + Grade Midtones Цветокоррекция: средние тона + Grade Shadows Цветокоррекция: тени + Gradient Options Параметры градиента + Grain Amount Величина зерна + Grain Roughness Шероховатость зерна + Grain Size Размер зерна + Grain Type Тип зерна + Graininess Зернистость + Grainy Dots Зернистые точки + Graphics adapter Графический адаптер + Graphics adapter: {adapter} Графический адаптер: {adapter} + Gray-gamma-2.2 Серый (гамма 2,2) + Greek Греческий + Greens Зелёные тона + Grid (⌘') Сетка (⌘') + Grid view Вид сеткой + Group from Layers Группа из слоёв + Groups Группы + Grout Width Ширина швов + Guide Layout… Схема направляющих… + HDR Toning HDR-тонирование + Hand Tool (hold) Инструмент «Рука» (удерживайте) + Hearts Сердца + Heptagon Семиугольник + Hexagon Шестиугольник + Hidden Скрыто + Hide Grid Скрыть сетку + Hide Others Скрыть остальные + Hide the layer's effects (⌥-click: all layers) Скрыть эффекты слоя (⌥+щелчок: все слои) + Hide {app} Скрыть {app} + High Pass Цветовой контраст + Highlight Света + Highlight Amount Величина светов + Highlight Area Область светов + Highlight Radius Радиус светов + Highlight Strength Сила светов + Highlight Tone Тон светов + Highlights from: Света от: + Horizontal Distortion Горизонтальная дисторсия + Horizontal Only Только по горизонтали + Horizontal scale Масштаб по горизонтали + Horizontal type Горизонтальный текст + Hotspot Горячая точка + Hsb HSB + Hsl HSL + Hsl Hue Оттенок HSL + Hsl Lum Яркость HSL + Hsl Sat Насыщенность HSL + Image Balance Баланс изображения + Image Processor Обработчик изображений + Image compositor: {processor} Компоновщик изображения: {processor} + Import Brushes Импортировать кисти + Import Brushes… (.abr) Импортировать кисти… (.abr) + Import Image Импортировать изображение + Import Options Параметры импорта + Import Photoshop brushes (.abr) Импортировать кисти Photoshop (.abr) + Import Settings Импортировать настройки + Importing {what} from {file}… Импорт {what} из {file}… + Importing… Импорт… + In Black Входной чёрный + In White Входной белый + Increase Brush Hardness Увеличить жёсткость кисти + Increase Brush Size Увеличить размер кисти + Increase Type Size Увеличить размер текста + Increase Type Size by 10 Увеличить размер текста на 10 + Indent first line Отступ первой строки + Indent left margin Отступ слева + Indent right margin Отступ справа + Indexed Индексированный + Infinite Бесконечный + Information Информация + Initial Direction Начальное направление + Ink Outlines Обводка контура + Inner Bevel Внутренняя фаска + Input Вход + Input Levels Входные уровни + Input Mode Режим ввода + Install Plug-in Установить плагин + Intensity Интенсивность + Intent Метод преобразования + Interpret Footage Интерпретировать материал + Intersect with selection (⇧⌥) Пересечение с выделенной областью (⇧⌥) + Interval Интервал + Invalid panel layout; restored the default dock Недопустимый макет панелей; восстановлена пристыковка по умолчанию + Invert Mask Инвертировать маску + Invert Selection Инвертировать выделенную область + Iris Blur Диафрагменное размытие + Ivy Плющ + Jag Зубчатость + Join the ArtCraft Discord Discord сообщества ArtCraft + Join the ArtCraft Discord ({url}) Присоединиться к Discord ArtCraft ({url}) + Keeps the current texture when switching presets. Сохраняет текущую текстуру при смене набора. + Kernel Ядро + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Кернинг: метрический, оптический или значение в 1/1000 em (Alt+←/→ у курсора) + Keyboard Клавиатура + Lab Lab + Landscape, 10 x 8 Альбомная, 10 x 8 + Landscape, 6 x 4 Альбомная, 6 x 4 + Landscape, 7 x 5 Альбомная, 7 x 5 + Latin 1 Латиница 1 + Latin Extended-A Латиница расширенная A + Latin Extended-B Латиница расширенная B + Layer Comps to Files Варианты слоя в файлы + Layer Content Options Параметры содержимого слоя + Layer Count Число слоёв + Layer From Background Слой из фона + Layer Knocks Out Drop Shadow Слой выбивает тень + Layer Mask Hides Effects Маска слоя скрывает эффекты + Layer Properties Свойства слоя + Layer Styles Стили слоя + Layer from Background Слой из фона + Layers to Files Слои в файлы + Layout Макет + Leading (set to the font size × auto-leading when Auto) Интерлиньяж (при значении «Авто» равен размеру шрифта × автоматический интерлиньяж) + Leaf Color Цвет листьев + Learn More Подробнее + Leaves Amount Количество листьев + Leaves Color Цвет листьев + Leaves Size Размер листьев + Left Diagonal Левая диагональ + Legacy Устаревший + Legal Legal + Lens Blur Размытие при малой глубине + Lens Correction Коррекция дисторсии + Lens Flare Блик от объектива + Less Меньше + Letter Letter + Level Based На основе уровней + Library Библиотека + Light Dark Balance Баланс светлого и тёмного + Light Direction Направление света + Light Intensity Интенсивность света + Light Rough Светлый шероховатый + Light Type Тип света + Light X Свет X + Light Y Свет Y + Light Z Свет Z + Lighten Grout Осветлить швы + Lighten Only Только осветление + Lighting Освещение + Lighting Effects Эффекты освещения + Linear Dodge Линейный осветлитель + Linear Height Линейная высота + Linear-srgb Линейный sRGB + Link Связать + Liquify Пластичность + List Список + List view Вид списком + Live Preview Живой просмотр + Load 3D LUT Загрузить 3D LUT + Load Files into Stack Загрузить файлы в стопку + Load path as a selection Загрузить контур как выделенную область + Loading… Загрузка… + Lock All Заблокировать всё + Lock Image Pixels Заблокировать пиксели изображения + Lock Layers Заблокировать слои + Lock Position Закрепить положение + Lock Transparent Pixels Заблокировать прозрачные пиксели + Locked Заблокировано + Long Lines Длинные линии + Long Strokes Длинные штрихи + Lorem Ipsum Lorem Ipsum + Lower Нижний + Lum Ярк. + Lut LUT + Magentas Пурпурные тона + Make Selection from Path Создать выделенную область из контура + Make Selection… Создать выделенную область… + Make Symmetry Path Создать контур симметрии + Make Work Path… Создать рабочий контур… + Make work path from selection Создать рабочий контур из выделенной области + Manual Вручную + Map Document Документ карты + Map Layer Слой карты + Map Path Путь к карте + Mask Channel Канал-маска + Mask Incomplete Blocks Маскировать неполные блоки + Mask None Без маски + Masks Маски + Match Color Подбор цвета + Math Symbols Математические символы + Max Offset Макс. смещение + Max Radius Макс. радиус + Measurement Scale Масштаб измерений + Measurement Scale Marker Маркер масштаба измерений + Medium Dots Средние точки + Medium Gray Средне-серый + Medium Lines Средние линии + Medium Strokes Средние штрихи + Merge Channels… Объединить каналы… + Merge Spot Channel Объединить цветовой канал + Merge to HDR Pro Объединить в HDR Pro + Metallic Металлический + Metrics Метрики + Mezzotint Меццотинто + Middle Середина + Midtone Полутон + Monitor Profile Профиль монитора + Monitor profile in use on {display}: {profile} Используемый профиль монитора на {display}: {profile} + Monitor profile in use: {profile} Используемый профиль монитора: {profile} + Monitor profile on {display} (main window): {profile} Профиль монитора для {display} (главное окно): {profile} + Monitor profile on {display}: {profile} Профиль монитора для {display}: {profile} + Monitor profile: {profile} Профиль монитора: {profile} + Mono Contrast Монохромный контраст + Monochromatic Монохромный + More Больше + Mosaic Мозаика + Mosaic Tiles Мозаичные плитки + Motion Blur Размытие в движении + Mouse Мышь + Move Layer Переместить слой + Movie Prime Киносъёмочный фикс + Multiple Flames Along Path Несколько языков пламени вдоль контура + Multiple Flames One Direction Несколько языков пламени в одном направлении + Multiple Flames Path Directions Несколько языков пламени по направлениям контура + Multiple Flames Various Length Несколько языков пламени разной длины + My Brushes Мои кисти + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Перетаскивание файлов ещё не поддерживается в Wayland. Используйте «Файл › Открыть» или запустите PhotoCraft под XWayland с `WAYLAND_DISPLAY= photocraft`. + Nearest Ближайший + Neon Glow Неоновое свечение + Neutralize Нейтрализовать + Neutrals Нейтральные тона + New Brush Preset Новый набор параметров кисти + New Channel… Новый канал… + New Guide Новая направляющая + New Guide Layout Схема направляющих + New Spot Channel… Новый цветовой канал… + New Video Layer from File Новый видеослой из файла + New Window Новое окно + New Workspace Новая рабочая среда + New document Новый документ + New document… Новый документ… + New document… ⌘N Новый документ… ⌘N + Next Далее + No Document Нет документа + No Image Нет изображения + No Layers Нет слоёв + No Results Нет результатов + No Results Found Ничего не найдено + No Selection Нет выделенной области + No custom shape "{name}": pick one in the options bar or Window › Shapes Нет произвольной фигуры «{name}»: выберите её на панели параметров или в меню «Окно › Фигуры» + No layers Нет слоёв + No recent files Нет недавних файлов + No selection Нет выделенной области + Noise Color Цветовой шум + Noise Color Detail Цветовая детализация шума + Noise Luminance Яркостный шум + Noise Luminance Detail Яркостная детализация шума + Nothing to Redo Нечего повторить + Nothing to Undo Нечего отменить + Nothing to paste: the clipboard holds no image Нечего вставлять: в буфере обмена нет изображения + Number Of Levels Число уровней + Numbers Числа + Ocean Ripple Океанские волны + Octagon Восьмиугольник + Odd Fields Нечётные поля + Oil Paint Масляная живопись + On Вкл. + One Flame Along Path Один язык пламени вдоль контура + Open As Открыть как + Open File Открыть файл + Open Folder Открыть папку + Open Image Открыть изображение + Open Recent is unavailable on the web «Открыть недавние» недоступно в веб-версии + Opened {url} Открыто: {url} + Opening Открытие + Open… ⌘O Открыть… ⌘O + Operation canceled Операция отменена + Operation cancelled Операция отменена + Operation completed Операция завершена + Operation failed Операция не выполнена + Optical Оптический + Original Ratio Исходное соотношение + Out Black Выходной чёрный + Out From Center От центра наружу + Out Of Gamut Вне охвата + Out White Выходной белый + Out of memory Недостаточно памяти + Outer Bevel Внешняя фаска + Output Levels Выходные уровни + Oval Овал + Overlay Edge Наложение края + Overlay: Rule of Thirds Наложение: правило третей + PSD PSD + Package Упаковать + Page Setup… Параметры страницы… + Paint Daubs Мазки краской + Paints from the document's opening state Рисует по состоянию документа на момент открытия + Palette Knife Шпатель + Panel Панель + Paper Бумага + Paper Brightness Яркость бумаги + Parallel Параллельно + Paste Complete Stroke Вставить полный контур + Paste Fill Вставить заливку + Patchwork Лоскутное шитьё + Path Blur Размытие по контуру + Paths to Illustrator Контуры в Illustrator + Pattern Fill… Заливка узором… + Pattern Type Тип узора + Pencil Width Ширина карандаша + Pentagon Пятиугольник + Percentage Процент + Permission denied Доступ запрещён + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft продолжает попытки; пока сохранение не удастся, изменения настроек будут потеряны при закрытии. + Photomerge Фотослияние + Pick Color From Image Выбрать цвет на изображении + Picture Frame Рамка + Pillow Emboss Подушковидное тиснение + Pinch Сжатие + Pins Булавки + Place Embedded Поместить встроенный + Place Linked Поместить связанный + Place Scale Marker Шкала масштаба + Plane Плоскость + Planes Плоскости + Plastic Wrap Пластиковая упаковка + Platform: {os} {arch} Платформа: {os} {arch} + Please wait… Подождите… + Point Точка + Point Curve Точечная кривая + Point Curve Blue Точечная кривая: синяя + Point Curve Green Точечная кривая: зелёная + Point Curve Red Точечная кривая: красная + Pointed Заострённый + Pointillize Пуантилизм + Polar Coordinates Полярные координаты + Polar To Rectangular Полярные в прямоугольные + Policy Cmyk Правила для CMYK + Policy Gray Правила для градаций серого + Policy Rgb Правила для RGB + Pond Ripples Волны на пруду + Portrait, 4 x 6 Книжная, 4 x 6 + Portrait, 5 x 7 Книжная, 5 x 7 + Postcard Открытка + Poster Плакат + Poster Edges Плакатные края + Posterization Постеризация + Preferences can't be saved Не удаётся сохранить настройки + Preferences saved Настройки сохранены + Preferences were reset: {error} Настройки сброшены: {error} + Preferences… Настройки… + Preserve Сохранить + Preset Manager Управление наборами + Press keys Нажмите клавиши + Preview Image Просмотр изображения + Previous Назад + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Напечатано в {path} + Pro (Dark) Pro (тёмная) + Pro (Medium Gray) Pro (средне-серая) + Processing… Обработка… + Profile Профиль + Proof Setup… Настройка пробного отпечатка… + Prophoto-compat ProPhoto RGB (совместимо) + Pucker Сморщивание + Push Left Сдвиг влево + Pyramids Пирамиды + Quick Actions Быстрые действия + Quit Выход + Quit PhotoCraft Выйти из PhotoCraft + Quit {app} Выйти из {app} + R G B R G B + R g b R g b + Radial Радиальный + Radial Blur Радиальное размытие + Radius X Радиус X + Radius Y Радиус Y + Ran script {name} Выполнен сценарий {name} + Randomize Length Случайная длина + Randomize Shapes Случайные фигуры + Rasterize Fill Content Растрировать содержимое заливки + Rasterize Smart Object Растрировать смарт-объект + Ratio Соотношение + Raw Defaults Значения RAW по умолчанию + Reading Чтение + Ready Готово + Rec2020 Rec. 2020 + Recent Files Недавние файлы + Reconstruct All Восстановить всё + Recovered {name} Восстановлено: {name} + Rectangular Selection Прямоугольная выделенная область + Rectangular To Polar Прямоугольные в полярные + Red Cyan Красный–голубой + Reds Красные тона + Reduce Color Noise Уменьшить цветовой шум + Reduce Noise Уменьшить шум + Reflected Отражение + Relief Рельеф + Relink to File Повторно связать с файлом + Remove All Удалить всё + Remove Jpeg Artifact Удалить артефакты JPEG + Rename Brush Переименовать кисть + Rename Channel… Переименовать канал… + Rename Group Переименовать группу + Rename Tool Preset Переименовать набор + Render Video Экспорт видео + Rendering… Отрисовка… + Reorder Layer Изменить порядок слоя + Replace Color Замена цвета + Replace Contents Заменить содержимое + Replace Footage Заменить материал + Report a Bug Сообщить об ошибке + Report an Issue Сообщить о проблеме + Reset Alignment Сбросить выравнивание + Reset All Сбросить всё + Reset Preferences Сбросить настройки + Resolve Missing Fonts Устранить отсутствующие шрифты + Restart Перезапустить + Restart Required Требуется перезапуск + Resume Продолжить + Retry Повторить + Rgb RGB + Ridges Гребни + Right Diagonal Правая диагональ + Ripple Рябь + Ripple Magnitude Величина ряби + Ripple Size Размер ряби + Rope Верёвка + Roses Розы + Rough Pastels Пастель + Row Order Порядок строк + Rulers (⌘R) Линейки (⌘R) + Sample Size Размер образца + Sampled Colors Отобранные цвета + Sandstone Песчаник + Sat Насыщ. + Save As Сохранить как + Save Changes Сохранить изменения + Save File Сохранить файл + Save Image Сохранить изображение + Save Path Сохранить контур + Save Pixel Aspect Ratio Сохранить соотношение сторон пикселя + Save a Copy Сохранить копию + Save before closing? Сохранить перед закрытием? + Save for Web (Legacy) Сохранить для Web (устаревшее) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Сохраните работу и перезапустите PhotoCraft, чтобы повторить попытку ускорения GPU. Отрисовка на CPU остаётся активной в этом сеансе. + Saved a copy as {path} Копия сохранена как {path} + Saved {path}; image assets: {n} in {dir} Сохранено: {path}; графические ресурсы: {n} в {dir} + Saving… Сохранение… + Scale Effects Масштабировать эффекты + Scaling Масштабирование + Scallops Фестоны + Script Events Manager Диспетчер событий сценариев + Scroll menu down Прокрутить меню вниз + Scroll menu up Прокрутить меню вверх + Search commands Поиск команд + Search commands (⌘K) Поиск команд (⌘K) + Search commands, tools and panels Поиск команд, инструментов и панелей + Search menus Поиск по меню + Seed Зерно + Select Custom Color… Выбрать свой цвет… + Select Data Points Выбрать точки данных + Select Layer Выбрать слой + Select and Mask Выделение и маска + Selected at launch: {backend} Выбрано при запуске: {backend} + SemiBold Полужирный + Sensitivity Чувствительность + Sent to the printer ({spooler}) Отправлено на принтер ({spooler}) + Sepia Сепия + Services Службы + Set Colors Задать цвета + Set Measurement Scale Задать масштаб измерений + Sgray sGray + Shadow Тень + Shadow Amount Величина теней + Shadow Intensity Интенсивность теней + Shadow Radius Радиус теней + Shadow Tone Тон теней + Shadows up to: Тени до: + Shadows/Highlights Тени/Света + Shallow Поверхностный + Shape Blur Размытие по фигуре + Sharpen Amount Величина резкости + Sharpen Detail Детализация резкости + Sharpen Details Резкость деталей + Sharpen Masking Маскирование резкости + Sharpen Radius Радиус резкости + Shear Искривление + Shine Блеск + Short Lines Короткие линии + Short Strokes Короткие штрихи + Shortcut Сочетание клавиш + Shortcuts Сочетания клавиш + Show All Показать все + Show Extras Options Параметры вспомогательных элементов + Show Guides Показать направляющие + Show Guides (⌘;) Показать направляющие (⌘;) + Show Rulers Показать линейки + Show the layer's effects (⌥-click: all layers) Показать эффекты слоя (⌥+щелчок: все слои) + Simple Простой + Simulate Paper Имитировать бумагу + Sine Синусоида + Sketch Эскиз + Skip Пропустить + Slice Options Параметры фрагмента + Smart Blur Умное размытие + Smart Sharpen Умная резкость + Smoothness Сглаженность + Smudge Stick Растушёвка + Snap to Grid Привязать к сетке + Snowflakes Снежинки + Soften Смягчить + Softness Мягкость + Software adapter Программный адаптер + Solarized Dark Solarized (тёмная) + Solid Color Однотонный цвет + Solid Front Faces Сплошные передние грани + Source Channel Канал-источник + Source Invert Инвертировать источник + Source Layer Исходный слой + Source1 Channel Канал источника 1 + Source1 Invert Инвертировать источник 1 + Source2 Channel Канал источника 2 + Source2 Invert Инвертировать источник 2 + Source: set Источник: задан + Space Пробел + Sparkle Искры + Spatter Разбрызгивание + Speckle Крапинки + Speed Скорость + Spherize Сферизация + Spin Вращение + Spin Blur Размытие вращением + Split Channels Разделить каналы + Spot Точечный + Spray Radius Радиус распыления + Sprayed Strokes Разбрызганные штрихи + Spread Разброс + Sprinkles Вкрапления + Square Size Размер квадрата + Square, 12 x 12 Квадратная, 12 x 12 + Square, 5 x 5 Квадратная, 5 x 5 + Squareness Квадратность + Stagger Смещение + Stained Glass Витраж + Star Звезда + Stars Звёзды + Start X Начало X + Start Y Начало Y + Statistics Статистика + Step {step} ({id}) failed: {error} Шаг {step} ({id}) не выполнен: {error} + Stippled Крапчатый + Stops Контрольные точки + Strength Сила + Stretch Растянуть + Stroke Color Цвет обводки + Stroke Detail Детализация обводки + Stroke Direction Направление обводки + Stroke Length Длина обводки + Stroke Pressure Нажим обводки + Stroke Size Размер обводки + Stroke Width Ширина обводки + Stroke path with brush Обвести контур кистью + Strokes Штрихи + Studio (Dark) Studio (тёмная) + Studio (Light) Studio (светлая) + Stylization Стилизация + Subtract Shapes at Overlap Вычесть фигуры при перекрытии + Subtract from selection (⌥) Вычесть из выделенной области (⌥) + Sumi-e Суми-э + Surface Blur Размытие по поверхности + Switch Foreground and Background Colors Поменять цвета переднего плана и фона + System Info Информация о системе + Tablet Планшет + Tabloid Tabloid + Taper Сужение + Target Цель + Target Channel 3 Целевой канал 3 + Target Channel 4 Целевой канал 4 + Target Channel 5 Целевой канал 5 + Target Channel 6 Целевой канал 6 + Target Channel 7 Целевой канал 7 + Target Channel 8 Целевой канал 8 + Target Channel 9 Целевой канал 9 + Target Composite Channel Целевой составной канал + Target X Цель X + Target Y Цель Y + Teal Orange Бирюзовый–оранжевый + Technology Previews… Пробные технологии… + Temporary Временный + Text Layer Текстовый слой + Texture Coverage Покрытие текстуры + Texturizer Текстуризатор + Thaw Mask Разморозить маску + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст на {display} отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The current gradient (pick one in Window › Gradients) Текущий градиент (выберите в меню «Окно › Градиенты») + The document does not have an embedded colour profile. Working space: {working}. Документ не содержит встроенного цветового профиля. Рабочее пространство: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Документ содержит встроенный цветовой профиль, не совпадающий с рабочим пространством.\nВстроенный: {embedded}\nРабочее пространство: {working} + The file could not be opened. Не удалось открыть файл. + The file could not be saved. Не удалось сохранить файл. + Thickness Толщина + This Layer: Этот слой: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? + This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. + This session: {reason} Этот сеанс: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Перед продолжением необходимо растрировать этот слой фигуры. Он перестанет быть редактируемым как фигура. Растрировать фигуру? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Перед продолжением необходимо растрировать этот текстовый слой. Его текст перестанет быть редактируемым. Растрировать текст? + Tile Size Размер плитки + Tiles Плитки + Tilt-Shift Наклон-сдвиг + Tint Color Цвет тонирования + Tiny Lens Маленькая линза + Title Заголовок + To До + To Center К центру + Toggle Layer Mask Overlay Переключить наложение маски слоя + Toggle the Brush Settings panel Переключить панель «Параметры кисти» + Toggle the Brush Settings panel (F5) Переключить панель «Параметры кисти» (F5) + Tool Options Параметры инструмента + Trace Contour Обводка контуров + Tracking (1/1000 em) Трекинг (1/1000 em) + Transformation Трансформирование + Transition Переход + Transparency Shapes Layer Прозрачность задаёт форму слоя + Trap Приводка + Tree Дерево + Trim Обрезать + Turbulent Турбулентность + Twirl Скручивание + Twirl Clockwise Скручивание по часовой стрелке + Twirl Counterclockwise Скручивание против часовой стрелки + Type Options Параметры текста + Unaltered Без изменений + Undefined Areas Неопределённые области + Underlying Layer: Нижележащий слой: + Underpainting Подмалёвок + Underwater Под водой + Ungroup Разгруппировать + Uniform Однородный + Unite Shapes at Overlap Объединить фигуры при перекрытии + Unlink Разъединить + Unlock Разблокировать + Unlocked Разблокировано + Unsaved Changes Несохранённые изменения + Unsharp Mask Нерезкое маскирование + Unsupported file format Неподдерживаемый формат файла + Unsupported format Неподдерживаемый формат + Up Вверх + Update Обновить + Updates Обновления + Upper Верхний + Use Custom Color Использовать свой цвет + Use File › Open to open an image. Используйте «Файл › Открыть», чтобы открыть изображение. + Use Selection In Source Использовать выделение в источнике + Use Selection In Target Использовать выделение в цели + Uses the foreground and background colors. Использует цвета переднего плана и фона. + Vanishing Point Точка схода + Variable Data Sets Наборы данных переменных + Vector Mask Hides Effects Векторная маска скрывает эффекты + Vertical Distortion Вертикальная дисторсия + Vertical Only Только по вертикали + Vertical Type Tool Инструмент «Вертикальный текст» + Vertical scale Масштаб по вертикали + Vertical type Вертикальный текст + Video Frames to Layers Видеокадры в слои + Vignette Amount Величина виньетки + Vignette Feather Растушёвка виньетки + Vignette Highlights Светлые тона виньетки + Vignette Midpoint Средняя точка виньетки + Vignette Roundness Округлость виньетки + Vignette Style Стиль виньетки + Vine Color Цвет лозы + Vine With Flowers Лоза с цветами + Vine With Leaves Лоза с листьями + Violent Сильный + W: Ш: + WIA Support Поддержка WIA + Warm Тёплый + Warming Filter (85) Согревающий фильтр (85) + Warming L B A Согревающий (LBA) + Warming81 Согревающий (81) + Warming85 Согревающий (85) + Warmth Теплота + Warning Предупреждение + Warp Text Деформация текста + Watercolor Акварель + Watercolour look: lighter interior, darker rims. Акварельный вид: светлее внутри, темнее по краям. + Wavelength Max Макс. длина волны + Wavelength Min Мин. длина волны + Waves Волны + Web Large Web (крупный) + Web Minimum Web (минимальный) + Web Most Common Web (наиболее распространённый) + WebP (lossless) WebP (без потерь) + Wetness Влажность + White Clip Отсечение белого + White Intensity Интенсивность белого + White Is High Белое — высокое + Whole Word Слово целиком + Wide Blurry Широкий размытый + Wide Sharp Широкий резкий + Wind Ветер + Window renderer: {renderer} Отрисовка окна: {renderer} + Working Рабочее + Working Cmyk Рабочий CMYK + Working Gray Рабочий: градации серого + Working Rgb Рабочий RGB + Working-cmyk Рабочий CMYK + Workspaces Рабочие среды + Wrap Оборачивание + Yellows Жёлтые тона + ZigZag Зигзаг + Zigzag Зигзаг + Zoom In (hold, drag to scrub) Увеличение (удерживайте, перетащите для прокрутки) + Zoom Out (hold) Уменьшение (удерживайте) + auto авто + blue синий + brushes кисти + fallback резерв + gradients градиенты + gray серый + green зелёный + layer mask маска слоя + manual вручную + none нет + off выключено + on включено + orange оранжевый + previous reading kept; the last re-read failed: {reason} сохранено предыдущее значение; последнее повторное чтение не удалось: {reason} + red красный + unknown неизвестно + unknown type неизвестный тип + vector mask векторная маска + violet фиолетовый + yellow жёлтый + {index} of {total} {index} из {total} + {label} is not available: {reason} {label} недоступно: {reason} + {message} (notes: {n}; {warning}) {message} (примечаний: {n}; {warning}) + {shortcut} assigned. {shortcut} назначено. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} уже используется командой {commands} и будет удалено из неё при нажатии «ОК». + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ навигация ↵ выполнить esc закрыть + ✓ Color Indicates Masked Areas ✓ Цветом обозначены маскированные области + ✓ Color Indicates Selected Areas ✓ Цветом обозначены выделенные области + + +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Очистить +@id edit.purge.undo Данные отмены +@id edit.stroke Обвести… +@id edit.transform.scale Масштабирование +@id file.close Закрыть +@id filter.sharpen.sharpen Резкость +@id layer.layerMask.apply Применить +@id select.all Выделить всё +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Импортирована {n} кисть из {file}|Импортированы {n} кисти из {file}|Импортировано {n} кистей из {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Импортирован {n} градиент из {file}|Импортированы {n} градиента из {file}|Импортировано {n} градиентов из {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Импортирован {n} узор из {file}|Импортированы {n} узора из {file}|Импортировано {n} узоров из {file} +cameraRaw Balance Баланс +cameraRaw Clarity Чёткость +cameraRaw Color Цвет +cameraRaw Color Mixer Смеситель цветов +cameraRaw Contrast Контраст +cameraRaw Curve Кривая +cameraRaw Dehaze Удаление дымки +cameraRaw Detail Детализация +cameraRaw Effects Эффекты +cameraRaw Exposure Экспозиция +cameraRaw Feather Растушёвка +cameraRaw Highlights Света +cameraRaw Luminance Яркость +cameraRaw Masking Маскирование +cameraRaw Midpoint Средняя точка +cameraRaw Midtones Средние тона +cameraRaw Radius Радиус +cameraRaw Roundness Округлость +cameraRaw Saturation Насыщенность +cameraRaw Shadows Тени +cameraRaw Size Размер +cameraRaw Temperature Температура +cameraRaw Texture Текстура +cameraRaw Tint Оттенок +cameraRaw Whites Белые точки +colorRange Invert Инвертировать +document Orientation Ориентация +layerLabel Magenta Пурпурный +layerLabel No Color Без цвета +layerLabel Red Красный +layers Fill Заливка +layers Fill: Заливка: +navigator Zoom Масштаб +shape Appearance Оформление diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..f48c5afae3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 - +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G13: Russian gained the 1,233 dynamic UI labels other catalogs already had; no estimate changed; previously minor: G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that nobody is working on, and when you close part of one, update its evidence and estimate here and @@ -254,6 +253,10 @@ what an agent can't finish alone. as tofu (#1408, #1693). - **Evidence (2026-10-11):** Czech dynamic UI wrappers, hardware labels and composed messages translate at display sites; engine errors remain English. This does not close G13. +- **Evidence (2026-10-15):** Russian gained the 1,233 dynamic labels the other catalogs already + had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, + metadata, System Info, status templates, dialogs), with a terminology note + ([localization-ru](localization-ru.md)); engine errors remain English. This does not close G13. - **Detail:** [`localization-parity.md`](localization-parity.md). - **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded). @@ -270,6 +273,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G13: Russian gained the 1,233 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | @@ -286,8 +290,7 @@ what an agent can't finish alone. | 2026-10-13 | minor | G1: Threshold splits on Photoshop's luminosity of whole samples (8- and 16-bit rules exact on four oracles); psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 | | 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 | | 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | -| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | -| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 || 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b2be85595..5d6b974c88 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Russian dynamic labels; previously minor: Spanish dynamic labels) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -46,8 +46,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde ## Other shipped languages 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional -Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the +Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the +other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every @@ -87,3 +87,4 @@ engine localization or native review is complete. | 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | +| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) | diff --git a/docs/localization-ru.md b/docs/localization-ru.md new file mode 100644 index 0000000000..ea0eff64df --- /dev/null +++ b/docs/localization-ru.md @@ -0,0 +1,75 @@ +# Russian terminology and dynamic UI labels + +This note goes with the completion of the Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`). +The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked the +labels that only exist at runtime and that other catalogs (Italian, Serbian, Czech, Hungarian) +already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects, +History / Layers / paths / channels menu labels, metadata fields, status messages, System Info +and dialog text. In Russian those showed in English. +They are now translated, reusing the vocabulary the catalog already uses elsewhere so the same +term reads the same everywhere. Translations are written from the meaning of PhotoCraft's English +labels; no proprietary translation resources, fonts, screenshots or user documents are included. + +## Register and style + +Commands are imperatives in the second person plural (*Сохранить*, *Удалить*, *Заново*); +where the catalog already uses a noun it is kept (*Уровни…*, *Кривые…*). Sentence case after the +first word, as in the rest of the catalog. Ellipses on dialog commands are preserved (`Ветер…` +→ `Ветер…`). Decimals use a comma (the file format's own labels keep their dots, e.g. +`Landscape, 10 x 8` stays `Альбомная, 10 x 8`). Latin product and technology names are not +translated (PhotoCraft, Camera Raw, RGB, CMYK, Lab, LUT, WebP); Camera Raw keeps `.xmp`; the +`HDR`, `HSL`, `HSB` and `W3C` acronyms and the `sGray`/`Pro Photo` identifiers stay Latin. + +## Terminology + +Existing choices were followed rather than re-invented, so the new rows use: + +| English source | Russian | Note | +|---|---|---| +| Highlight(s) | Света | already in the catalog (`Highlights` → `Света`) | +| Midtone(s) | Полутона | already in the catalog | +| Shadow(s) | Тени | pairs with *Света* | +| Feather | Растушёвка | never *перо* | +| Fuzziness | Разброс | Color Range's tolerance | +| Dither | Дизеринг | | +| Pattern | Узор | also in *Узорный штамп* | +| Preset | Набор | *Набор кистей*, *набор инструментов* | +| Guide | Направляющая | *Схема направляющих* | +| Artboard | Монтажная область | | +| Workspace | Рабочая среда | | +| Stroke (contour) | Обводка | painterly strokes stay *штрихи*/*мазки* | +| Stroke (paint param) | Штрихи / мазки | `Strokes` → `Штрихи` | +| Amount | Величина | `Штрихи`, never *количество*, when it is a strength | +| Fill Opacity | Непрозрачность заливки | distinct from layer *Непрозрачность* | +| Feather Radius | Радиус растушёвки | | +| Sharpen | Резкость | `Smart Sharpen` → *Умная резкость*, `Unsharp Mask` → *Контурная резкость* | +| Noise | Шум | `Reduce Noise` → *Уменьшить шум* | +| Content-Aware | С учётом содержимого | | +| Camera Raw | Camera Raw | the panel name stays Latin (as German/Spanish do; Greek translates it) | +| Place Embedded / Linked | Поместить встроенный / связанный | | +| Rasterize | Растрировать | | + +## Native review + +The review was AI-assisted and each row was checked against the meaning of PhotoCraft's own +English label. A review by a native speaker is still welcome. + +## Not covered + +Engine error and status messages, user document, layer and preset names, and automation command +IDs stay in English, as in every language ([`localization-parity.md`](localization-parity.md)). +A few dynamic labels the other catalogs carry are not used by the current code; they are +translated to stay aligned with the other catalogs. + +## Validation + +- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) + enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires + a Russian row for each, like the Korean and Italian tests. +- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`) + covers the rasterize prompts, hardware status lines, composed status templates and a sample of + filter labels. +- `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. +- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. +- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian + labels, without missing glyphs or clipped text. From 53ced08b23146f3f061c1bfcb82869f2628adae8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 16:31:23 -0700 Subject: [PATCH 0505/1452] Liquify: an OK that cannot be applied returns the error `liquify_ui::commit` discarded the results of `layer.smartFilter.setParams` and `filter.liquify`. `PhotocraftApp::run` showed a failed command's error in the status bar, but the control channel answered `filter.liquify {"ui": {"commit": true}}` with `{"committed": true}`. `commit` now returns the command's result, and the control channel returns the error, as `gallery_ui::commit` does. The dialog closes on OK whether or not the command succeeds, as the Filter Gallery's does. Two tests send OK through the control channel, one after the smart filters are cleared and one after the layer is deleted. Each checks that the dialog closed, the status bar shows the error, no history step was added, and the control channel returned the error. --- crates/ui-egui/src/distort_ui.rs | 2 +- crates/ui-egui/src/liquify_ui.rs | 62 ++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 1f12b129f1..344d3a0ebe 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -166,7 +166,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/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 8a5efb284b..ed70b9e47b 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -551,27 +551,28 @@ pub fn cancel(app: &mut PhotocraftApp) { } /// 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). -pub fn commit(app: &mut PhotocraftApp) { - let Some(mut d) = app.distort.liquify.take() else { return }; +/// 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}); - let _ = app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})); - return; + 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) { @@ -1027,7 +1028,9 @@ 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), _ => {} } @@ -1174,7 +1177,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(); @@ -1569,4 +1572,43 @@ mod tests { 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())); + } } From e1ce85a5dfc6683fa874619f775c1a36876ee78a Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 01:55:28 +0200 Subject: [PATCH 0506/1452] PSD: Hue/Saturation export writes each triplet in its own slot (#7065) The hue2 block holds a colorize triplet at byte 4 and a master triplet at byte 10. The writer copied the active triplet into both, so a Colorize hue of 200 also landed in the master slot (outside its -180..180 range) and toggling Colorize in Photoshop showed the wrong values. Write the active triplet at its own offset and Photoshop's defaults in the other (colorize 30/25/0, master 0/0/0), with the colorize hue stored signed as Photoshop does (200 -> -160). The reader maps a negative colorize hue back to the model's 0..360. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index d4bc312690..8d37c91585 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -228,8 +228,10 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { *r = HueRange { hue: h, saturation: s, lightness: l, bounds: HueRange::canonical_bounds([a, b, c, d]) }; } } + let hue = f32::from(bei16(data, base)?); Some(Adjustment::HueSaturation { - hue: f32::from(bei16(data, base)?), + // Photoshop stores the colorize hue signed (200 as -160); the model keeps 0..360. + hue: if colorize { hue.rem_euclid(360.0) } else { hue }, saturation: f32::from(bei16(data, base + 2)?), lightness: f32::from(bei16(data, base + 4)?), colorize, @@ -621,10 +623,16 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { put16(&mut v, 2); v.push(u8::from(*colorize)); v.push(0); - for _ in 0..2 { - for x in [hue, saturation, lightness] { - v.extend_from_slice(&clamp_i16(*x).to_be_bytes()); - } + // The colorize triplet, then the master triplet. The inactive one gets Photoshop's + // defaults; the colorize hue (0..360 in the model) is stored signed, -180..180. + let (colorize_hsl, master) = if *colorize { + let h = hue.rem_euclid(360.0); + ([if h > 180.0 { h - 360.0 } else { h }, *saturation, *lightness], [0.0; 3]) + } else { + ([30.0, 25.0, 0.0], [*hue, *saturation, *lightness]) + }; + for x in colorize_hsl.into_iter().chain(master) { + v.extend_from_slice(&clamp_i16(x).to_be_bytes()); } // Six hue ranges (reds, yellows, greens, cyans, blues, magentas): range values in // 0..360, then the range's hue, saturation and lightness. @@ -948,6 +956,21 @@ mod tests { assert_eq!(back, a); } + /// Photoshop keeps the colorize triplet at byte 4 and the master triplet at byte 10, and fills + /// the inactive one with its defaults: colorize (30, 25, 0), master (0, 0, 0). The colorize hue + /// is stored signed (200 -> -160). + #[test] + fn hue2_writes_each_triplet_in_its_own_slot() { + let triplets = |a: &Adjustment| -> Vec { + let blocks = write(a); + (0..6).map(|i| bei16(&blocks[0].1, 4 + 2 * i).unwrap_or(i16::MIN)).collect() + }; + let on = Adjustment::HueSaturation { hue: 200.0, saturation: 40.0, lightness: 10.0, colorize: true, ranges: HueRange::defaults() }; + assert_eq!(triplets(&on), [-160, 40, 10, 0, 0, 0]); + let off = Adjustment::HueSaturation { hue: -30.0, saturation: 20.0, lightness: 5.0, colorize: false, ranges: HueRange::defaults() }; + assert_eq!(triplets(&off), [30, 25, 0, -30, 20, 5]); + } + #[test] fn roundtrips() { rt(Adjustment::Invert); From e0d90b289652af8ff861572a87f33cfed996b195 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:39:48 +0300 Subject: [PATCH 0507/1452] fix(compose): layer effects in Dissolve mode speckle instead of painting as Normal (#6995) --- crates/compose/src/effects.rs | 51 ++++++++++++++++++++++++++++++----- crates/gpu/src/compose.wgsl | 23 +++++++++++++--- crates/gpu/src/plan.rs | 4 ++- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..a552cbeefe 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -68,13 +68,18 @@ pub const MAX_REACH: f32 = 512.0; /// shape's extent), Precise glows and chiselled bevels (exact Euclidean distances, whose f32 /// arithmetic depends on where the region starts) and noise (it speckles any coverage above /// zero, so it would pick up the blur's float noise, which depends on where the region starts). +/// Dissolve is not local for the same reason: it turns any coverage above zero into a chance of +/// a full pixel. pub(crate) fn maps_are_local(layer: &Layer) -> bool { - layer.effects.items.iter().filter(|e| e.enabled()).all(|e| match e { - Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)), - Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0, - Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0, - Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth, - _ => true, + layer.effects.items.iter().filter(|e| e.enabled()).all(|e| { + !dissolves(e) + && match e { + Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)), + Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0, + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0, + Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth, + _ => true, + } }) } @@ -1108,16 +1113,43 @@ fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] { /// Composite `color × coverage` into `dst` (both over the same rectangle). fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: BlendMode, opacity: f32) { + let (x0, y0, w) = (i64::from(dst.rect.x0), i64::from(dst.rect.y0), (dst.rect.width().max(1)) as usize); for (i, p) in dst.px.iter_mut().enumerate() { let a = m.v[i] * opacity; if a <= 0.0 { continue; } let c = color(i); + if blend == BlendMode::Dissolve { + if dissolves_in(x0 + (i % w) as i64, y0 + (i / w) as i64, c[3] * a) { + *p = psblend::composite(BlendMode::Normal, *p, [c[0], c[1], c[2], 1.0], 1.0); + } + continue; + } *p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0); } } +/// Whether `e` paints anything in Dissolve mode. +fn dissolves(e: &Effect) -> bool { + let d = |m: BlendMode| m == BlendMode::Dissolve; + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => d(s.common.blend), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => d(g.common.blend), + Effect::Stroke(s) => d(s.common.blend), + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => d(common.blend), + Effect::Satin(s) => d(s.common.blend), + Effect::BevelEmboss(b) => d(b.highlight.blend) || d(b.shadow.blend), + } +} + +/// Dissolve on an effect (#6995): a pixel takes the effect fully or not at all, with probability +/// `coverage`, picked by the same document-coordinate noise as a Dissolve layer. A soft glow or +/// shadow turns into scattered speckle instead of a smooth falloff. +fn dissolves_in(x: i64, y: i64, coverage: f32) -> bool { + hash_noise(x, y) < coverage +} + fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity: f32) { paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity); } @@ -2110,7 +2142,12 @@ pub(crate) fn composite_with_effects_prepared( } else { band }; - *sh = k * st.common.opacity * (1.0 - cover[i]); + let mut k = k * st.common.opacity; + if st.common.blend == BlendMode::Dissolve { + let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64); + k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 }; + } + *sh = k * (1.0 - cover[i]); cover[i] += *sh; } // The stroke blended over the backdrop at full coverage. diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..cf804cc44f 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1016,6 +1016,16 @@ fn fx_color(d: vec2) -> vec4 { return op.color; } +// effects::paint: blend an effect's colour `s` (alpha = colour alpha × coverage × opacity) into +// `dst` at document pixel `d`. Dissolve takes the effect fully or not at all (effects::dissolves_in). +fn fx_composite(d: vec2, dst: vec4, s: vec4) -> vec4 { + if (op.mode == M_DISSOLVE) { + if (dissolve_noise(d) < s.a) { return composite(M_NORMAL, dst, vec4(s.rgb, 1.0), 1.0); } + return dst; + } + return composite(op.mode, dst, s, 1.0); +} + // Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base // for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer // before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the @@ -1034,7 +1044,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { let a = textureLoad(tex_b, p, 0).a; let s = textureLoad(tex_c, p, 0); if (s.a <= 0.0 || a <= INSIDE_EPS) { return c; } - return composite(op.mode, c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0)), 1.0); + return fx_composite(doc_px(p), c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0))); } case 5: { let a = textureLoad(tex_b, p, 0).a; @@ -1083,12 +1093,12 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4 { let k = min(m * op.p0.x, 1.0) * op.opacity; if (k <= 0.0) { return dst; } let t = 1.0 - clamp(m, 0.0, 1.0); - return composite(op.mode, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k), 1.0); + return fx_composite(d, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k)); } let k = m * op.opacity; if (k <= 0.0) { return dst; } let c = fx_color(d); - return composite(op.mode, dst, vec4(c.rgb, c.a * k), 1.0); + return fx_composite(d, dst, vec4(c.rgb, c.a * k)); } // Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in @@ -1125,7 +1135,12 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4 { k = clamp((1.0 - clamp(a, 0.0, 1.0)) / (1.0 - lay_a), 0.0, 1.0); } } - let share = k * op.opacity * (1.0 - cover); + var cov = k * op.opacity; + if (op.mode == M_DISSOLVE) { + // effects.rs: a Dissolve stroke covers each pixel fully or not at all. + cov = select(0.0, 1.0, cov > 0.0 && dissolve_noise(d) < cov); + } + let share = cov * (1.0 - cover); if (op.kind == 1) { return vec4(cover + share, lay_a, 0.0, 0.0); } var acc = vec4(0.0); if (!first) { acc = textureLoad(layer_tex, p, 0); } diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..48cefb7022 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1278,7 +1278,9 @@ impl<'a> Planner<'a> { p.c = cover.map(|c| self.retain(c)); p.d = acc; let a = self.emit(p); - let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb); + // The stroke's mode rides along only for Dissolve, whose coverage is all or + // nothing in this pass as in the colour pass (#6995). + let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), st.common.blend, st.common.opacity, flags, clip, sb); p.adjust_kind = 1; p.map = Some(map(i, 0)); p.c = cover; From 00e4423fcca9ac0d9a117b229386d9665d307ce5 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:39:49 +0300 Subject: [PATCH 0508/1452] test(compose): Dissolve effects are speckled, tile-independent and match on the GPU (#6995) --- crates/compose/src/tests.rs | 54 +++++++++++++++++++++++++++++++++++++ crates/gpu/tests/parity.rs | 23 ++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..cdea17467d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1050,6 +1050,60 @@ fn outer_and_inner_glow_regions() { assert!(close4(px(&d, 5, 20), [1.0; 4])); } +// An effect in Dissolve mode (#6995) shows its colour fully or not at all at each pixel, so a soft +// glow becomes speckle thinning out with the glow instead of a smooth fade, and a half-opaque +// outside stroke is scattered dots. The speckle follows document coordinates: a tile renders the +// same pixels as the whole document. +#[test] +fn dissolve_effects_are_speckled_not_faded() { + let ring = |d: &Document| -> Vec<(i32, [f32; 4])> { + let mut v = Vec::new(); + for y in 2..38_i32 { + for x in 2..38_i32 { + if !(9..31).contains(&x) || !(9..31).contains(&y) { + v.push(((x - 20).abs().max((y - 20).abs()) - 10, px(d, x, y))); + } + } + } + v + }; + let soft = Glow { size: 8.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) }; + let faded = fx_doc(vec![Effect::OuterGlow(soft.clone())]); + assert!(ring(&faded).iter().any(|(_, p)| p[0] > 0.1 && p[0] < 0.9), "a Normal glow fades smoothly"); + let dissolved = Glow { common: FxCommon::new(BlendMode::Dissolve, 1.0), ..soft }; + let d = fx_doc(vec![Effect::OuterGlow(dissolved.clone())]); + let (mut near, mut far) = ((0, 0), (0, 0)); + for (dist, p) in ring(&d) { + let green = close4(p, [0.0, 1.0, 0.0, 1.0]); + assert!(green || close4(p, [1.0; 4]), "each pixel is the glow or the backdrop: {p:?} at distance {dist}"); + let n = if dist <= 3 { &mut near } else { &mut far }; + n.0 += usize::from(green); + n.1 += 1; + } + let share = |(g, n): (usize, usize)| g as f32 / n.max(1) as f32; + assert!(share(near) > 0.3 && share(far) < share(near) && far.0 > 0, "speckle thins out with the glow: {near:?} {far:?}"); + assert!(!crate::effects::maps_are_local(&d.layers[1]), "Dissolve speckles any coverage, so its maps are not tiled"); + let full = flatten(&d); + let tile = render(&d, Rect::new(4, 4, 21, 17)); + for y in 4..17 { + for x in 4..21 { + assert_eq!(full.get(x, y), tile.get(x, y), "({x},{y})"); + } + } + + let d = fx_doc(vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Dissolve, 0.5), + size: 4.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + })]); + let band: Vec<[f32; 4]> = ring(&d).into_iter().filter(|(dist, _)| (0..4).contains(dist)).map(|(_, p)| p).collect(); + let blue = band.iter().filter(|p| close4(**p, [0.0, 0.0, 1.0, 1.0])).count(); + assert!(band.iter().all(|p| close4(*p, [0.0, 0.0, 1.0, 1.0]) || close4(*p, [1.0; 4])), "no half-blue pixel"); + let share = blue as f32 / band.len() as f32; + assert!((share - 0.5).abs() < 0.1, "about half the band at 50 % opacity: {share}"); +} + // A Precise inner glow from the centre is brightest in the middle and fades towards the edge, // like the Softer one; it used to paint nothing at all (#966). #[test] diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..affdf757fa 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1411,6 +1411,29 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))], ), ("inner glow precise center", vec![Effect::InnerGlow(glow(pat_paint.clone(), GlowTechnique::Precise, 5.0, 0.0, GlowSource::Center))]), + // Dissolve effects (#6995): the same speckle as the CPU, pixel for pixel. + ( + "outer glow dissolve", + vec![Effect::OuterGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 0.8), + ..glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge) + })], + ), + ( + "inner glow dissolve gradient", + vec![Effect::InnerGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 1.0), + ..glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 7.0, 0.1, GlowSource::Edge) + })], + ), + ("drop shadow dissolve", vec![Effect::DropShadow(shadow(BlendMode::Dissolve, 0.75, 120.0, 6.0, 8.0, 0.0))]), + ( + "stroke outside dissolve", + vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Dissolve, 0.6), + ..stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))) + })], + ), ("bevel inner", vec![Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 7.0, 2.0))]), ("bevel outer", vec![Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 0.0))]), ("bevel emboss down", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, false, 8.0, 1.0))]), From 7e5a97385e7065d982efddc6e7f0e4111cb58308 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:44:23 +0300 Subject: [PATCH 0509/1452] fix(ui): right-click an Adaptive Wide Angle constraint for its orientation menu, as in Photoshop --- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/wide_angle_ui.rs | 60 ++++++++++++++++++++++------- docs/context-menu-parity.md | 2 +- 22 files changed, 68 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..67c7a517b6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1104,7 +1104,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 b1ea8977e2..ea960cddfd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1087,7 +1087,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 8229806307..93c9e9154b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2341,7 +2341,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 6f3d85002b..b477bab7dd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1094,7 +1094,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 4a819b0e1b..3a828e15cb 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 86a6b30acb..9bc5f1c499 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2069,7 +2069,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 49777913c1..a34f41d14c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1074,7 +1074,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 f1144c0fdd..173b845f37 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1089,7 +1089,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 351699ea89..ee6a8a28f5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1092,7 +1092,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 3eaf143979..a387c3fddc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1070,7 +1070,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 09dd568009..901daaf4d6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1073,7 +1073,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 c4827aa829..1abff61a19 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1092,7 +1092,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 f96542d642..fad7388ddc 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 f49200b973..56e35a8fb7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1578,7 +1578,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 c207facb72..6dbb543653 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 a8aa0c3c58..8819078715 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1052,7 +1052,7 @@ Done Bitti 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 resmin üzerine sürükleyin; Yatay/dikey için kaydırma; 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: çapayı seçin · Sürükleyin: çapayı, 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 48fb60343c..4655dde7ed 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1556,7 +1556,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 f33ef15011..4202152a11 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2250,7 +2250,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 75eacc994d..49906ad018 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1097,7 +1097,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 95a11dc593..b5029763e4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1100,7 +1100,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/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 35b996deea..0921ebe0ca 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); diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 76d6724b7e..ee0b782119 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 | No menu | 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 From 3ee662093555182044d2e1f2bfa83ddd656a83ed Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:44:28 +0300 Subject: [PATCH 0510/1452] test(ui): cover the Adaptive Wide Angle constraint menu and the orient op --- crates/ui-egui/src/wide_angle_ui.rs | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 0921ebe0ca..b577bf5861 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -492,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()); From 204c76ce36ab5e7ddf6d406074f099d3759fd8ff Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 20:03:18 -0400 Subject: [PATCH 0511/1452] fix(psd): an edited type layer keeps its Txt2 object instead of a bare stub (#6968) When a type layer's text changed, build_txt2 replaced the layer's object in Photoshop's own Txt2 block with a bare `<< /0 << /0 text /6 runs >> >>`: no paragraph runs (/5), no view (/1, which names the object's frame) and none of the style keys. Photoshop 27.10 quits while opening such a file (layer_effects.psd after type.edit), as it refused stripped objects in #2374. An edited object now starts from the file's object at its slot: the text is replaced, the paragraph runs are remade one per paragraph of the new text, and every other run array of the model (style runs, ...) is cut or extended to the new length before the auto-kern modes are applied. The view and other keys are kept; only the old text's glyph pen positions (/21) are dropped. An object whose run arrays aren't shaped as Photoshop writes them still falls back to a fresh one. Test: text edited_text_keeps_the_files_txt2_object. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/psd.rs | 87 ++++++++++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 50 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 8c502c75af..41d65ff887 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -964,8 +964,10 @@ pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { /// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text /// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with /// no text edit must not drop them. Its own style runs keep every style key too: only their -/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates -/// those extras along with the old runs, so they are dropped rather than left stale. Everything +/// auto-kern mode is set (split where a mode changes inside a run). An edited object — the +/// file's object at that slot, its text changed — keeps the object too, with its text replaced +/// and its run arrays refitted to the new text (#6968: a bare stub crashed Photoshop); only its +/// glyph pen positions, which belong to the old text, are dropped. Everything /// else the file's block held — the other keys beside the text objects, objects no layer names — /// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374: /// Photoshop refused files whose `Txt2` an unedited save had stripped). @@ -981,9 +983,15 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let modes = txt2_mode_runs(layer); let at = *index as usize; let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); - let mut object = kept.cloned().unwrap_or_else(E::dict); + // An edited object (#6968): the file's object at this slot with its text changed. It keeps + // the file's structure — paragraph runs, the view naming its frame, the rest — with every + // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in + // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. + let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); + let reuse = kept.is_some() || edited.is_some(); + let mut object = kept.cloned().or(edited).unwrap_or_else(E::dict); let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); - let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes)); + let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = file_runs.unwrap_or_else(|| { modes .iter() @@ -1028,6 +1036,77 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec } } +/// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine +/// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`) +/// one per paragraph of the new text (each keeping the style of the file's paragraph at that +/// position, else its last), and every other run array of the model (style runs `/6`, …) cut or +/// extended at its end to the new length. The view and the other keys are kept, except the glyph +/// pen positions (`/21`), which belong to the old text. `None` when the object or one of its run +/// arrays isn't shaped as Photoshop writes it: the caller writes a fresh object instead. +fn edited_object(object: &E, text: &str) -> Option { + let mut object = object.clone(); + let E::Dict(items) = &mut object else { return None }; + items.retain(|(k, _)| k != "21"); + let model = object.get_mut("0").filter(|m| matches!(m, E::Dict(_)))?; + let total = utf16_len(text) as usize; + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let E::Dict(entries) = model else { return None }; + for (key, value) in entries.iter_mut() { + if key.as_str() == "0" { + *value = E::String(text.to_string()); + continue; + } + let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else { + continue; + }; + let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs)? } else { runs_to_length(runs, total)? }; + value.set("0", E::Array(fitted)); + } + Some(object) +} + +/// `runs` (`<< … /1 length >>`) remade with the lengths `lens`: run i keeps the content of the +/// file's run i, else of its last one. `None` for an empty `runs`. +fn runs_per(runs: &[E], lens: &[usize]) -> Option> { + let last = runs.last()?; + lens.iter() + .enumerate() + .map(|(i, &len)| { + let mut r = runs.get(i).unwrap_or(last).clone(); + r.set("1", E::Int(i64::try_from(len).ok()?)); + Some(r) + }) + .collect() +} + +/// `runs` (`<< … /1 length >>`) covering `total` UTF-16 units: the leading runs as they were, cut +/// where `total` ends, the last kept run extended when `total` is longer. Empty runs are dropped. +/// `None` when `runs` is empty or a length isn't a count. +fn runs_to_length(runs: &[E], total: usize) -> Option> { + let mut out: Vec<(E, usize)> = Vec::new(); + let mut left = total; + for r in runs { + let len = r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?; + let take = len.min(left); + if take > 0 { + out.push((r.clone(), take)); + left -= take; + } + } + if left > 0 { + match out.last_mut() { + Some(last) => last.1 = last.1.checked_add(left)?, + None => out.push((runs.last()?.clone(), left)), + } + } + out.into_iter() + .map(|(mut r, len)| { + r.set("1", E::Int(i64::try_from(len).ok()?)); + Some(r) + }) + .collect() +} + /// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16 /// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A /// run a mode change falls inside is split there. `None` when the runs don't cover the text or diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 894ecf4e4f..d2aaa78a45 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1097,6 +1097,56 @@ fn edited_txt2_keeps_the_files_styles_and_objects() { assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]); } +/// #6968: editing the text of a Photoshop type layer keeps its `Txt2` object — paragraph runs, +/// the view naming its frame, the model's other run arrays and its style keys — refitted to the +/// new text. It was replaced by a bare `<< /0 << /0 text /6 runs >> >>` stub, and Photoshop quit +/// while opening the file. +#[test] +fn edited_text_keeps_the_files_txt2_object() { + use crate::engine_data::Value as E; + let run = |extra: &str, len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 {extra} >> >> >> /1 {len} >> "); + // "AB\rCD\r": two paragraphs, two style runs, a kern run, a view and pen positions. + let prev = format!( + "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}{}] >> /7 << /0 [ << /0 << >> /1 6 >> ] >> >> /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", + run("", 3), + run("", 3), + run("/11 1", 2), + run("/11 1 /53 (red)", 4) + ); + // Every character above is below U+0100: one raw byte each (the BOM `FE FF` included). + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let before_object = before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone(); + assert_eq!(before_object.path(&["0", "0"]).and_then(E::as_str), Some("AB\rCD\r")); + let lens = |o: &E, key: &str| -> Vec { + o.path(&["0", key, "0"]).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() + }; + for (text, paragraphs, styles) in + [("Bevex\nCD", vec![6, 3], vec![2, 7]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![2, 8]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![2, 4])] + { + let t = runs_of(text, &[(text.len(), CharStyle::default())]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + for key in [&["0"][..], &["98"], &["1", "0"], &["1", "2"]] { + assert_eq!(out.path(key), before.path(key), "{text:?}: /{} survives", key.join(" /")); + } + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + let engine = format!("{}\r", text.replace('\n', "\r")); + let total = engine.encode_utf16().count() as i64; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some(engine.as_str())); + assert_eq!(object.get("1"), before_object.get("1"), "{text:?}: the view (its frame) is kept"); + assert!(object.get("21").is_none(), "{text:?}: pen positions of the old text are dropped"); + assert_eq!(lens(object, "5"), paragraphs, "{text:?}: one paragraph run per paragraph"); + assert_eq!(lens(object, "6"), styles, "{text:?}: style runs cover the new text"); + assert_eq!(lens(object, "7"), vec![total], "{text:?}: other run arrays cover the new text"); + for r in object.path(&["0", "5", "0"]).and_then(E::as_array).unwrap() { + assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: paragraph style survives"); + } + for r in object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap() { + assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(12.0), "{text:?}: the font size survives"); + } + } +} + /// The file's style runs are only reused when they cover the text: runs of another length (or /// hostile lengths) fall back to generated runs, without panicking. #[test] From 66c0e4ce13b35ebf3ea9977f3c03a73595161a49 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:20:20 +0200 Subject: [PATCH 0512/1452] Content-Aware Fill workspace: review follow-ups (#2376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing on a 6000 × 4000 document found the proxy's sampling state computed per pixel against every stroke segment: each stroke got slower, and an undo tested 1.7 M pixels × 3000 segments on the UI thread, blocking it for over a minute. The proxy is now painted as the engine paints the full-resolution mask: the option's area and the fill area are sampled once at the proxy's pixel centres (set_base), and each stroke is painted over them in order, visiting only its own pixels (paint, restate_all), so a dab costs its segment and a rebuild costs the strokes' areas. Also from the same review: - `edit.contentAwareFill {"ui": …}` with the workspace closed is an error instead of a fill with the defaults (an agent polling the state after an Esc added a fill per poll). - The view sends one Down per press: the press is the stroke's first point and the frames before egui's drag threshold are its next ones, instead of drag_started() beginning the stroke over (the lasso lost its first points the same way; the Zoom tool zoomed on every frame the button was held). - The preview texture is box-averaged to the device's max_texture_side when the plan's rectangle exceeds it (a 17000 px wide plan gave a validation error every frame). - A preview that fails counts as shown for that state (Preview::finish), so it is reported once instead of restarting on every repaint. - The view shows the document's visible layers composited (an Apply to a new layer shows); the engine's limits on strokes, lasso edits and points hold at the brush with a message instead of failing Apply; the footer is Photoshop's reset, OK, Apply, Cancel on every platform; a leftover `let _ = i;` is gone. Tests: strokes painted once and equal to the engine's sampling area; `ui` with the workspace closed; the first points of a stroke and a lasso through egui_kittest; the preview texture at a 2048 limit; a failing preview reported once; the view after Apply to a new layer; the limits at the brush. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/caf_ui.rs | 576 ++++++++++++++++++------ crates/ui-egui/src/distort_ui.rs | 2 + crates/ui-egui/src/filter_preview_ui.rs | 2 +- 3 files changed, 438 insertions(+), 142 deletions(-) diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs index de2b62e40a..747e9d4bcf 100644 --- a/crates/ui-egui/src/caf_ui.rs +++ b/crates/ui-egui/src/caf_ui.rs @@ -24,8 +24,9 @@ //! what gets written. The settings are remembered for the next time (`prefs.dialogs`). use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; -use photocraft_algo::content_aware::SamplingStroke; +use photocraft_algo::content_aware::{SamplingStroke, paint_strokes}; use photocraft_doc::LayerId; +use photocraft_engine::edit_menu_cmds::{MAX_CAF_EDITS, MAX_CAF_POINTS}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -240,6 +241,25 @@ struct Preview { error: Option, } +impl Preview { + /// Takes the result of the preview of state `generation`: an image to upload, or the error, + /// which stands for that state until it changes (the same preview is not started again). + fn finish(&mut self, generation: u64, r: Result<(Rect, egui::ColorImage), String>) { + self.shown = generation; + self.tex = None; + match r { + Ok(img) => { + self.pending_image = Some(img); + self.error = None; + } + Err(e) => { + self.pending_image = None; + self.error = Some(e); + } + } + } +} + pub struct CafWorkspace { pub layer: LayerId, layer_name: String, @@ -266,8 +286,17 @@ pub struct CafWorkspace { k: usize, pw: usize, ph: usize, - /// Per proxy pixel: 0 not sampled, 1 sampled, 2 fill area. - state: Vec, + /// Per proxy pixel: the sampling option's area (before the strokes), the fill area, and the + /// sampling area after the strokes so far (the first painted with them in order, as the + /// engine's `paint_strokes` paints the full-resolution one). + base_px: Vec, + hole_px: Vec, + mask: Vec, + /// The pointer is down on the view with a tool: one press is one `Down`, egui's drag (which + /// starts a few pixels later) continues it. + pressed: bool, + /// The device's texture side limit, as the context reports it each frame. + texture_limit: usize, image_tex: Option, overlay_tex: Option, overlay_dirty: bool, @@ -300,7 +329,7 @@ fn plan_params(w: &CafWorkspace, strokes: bool) -> Value { impl CafWorkspace { pub fn describe(&self) -> Value { - let sampled = self.state.iter().filter(|s| **s == 1).count(); + let sampled = self.mask.iter().zip(&self.hole_px).filter(|(m, h)| **m && !**h).count(); json!({ "layer": self.layer.0, "tool": self.tool.name(), @@ -323,50 +352,60 @@ impl CafWorkspace { }) } - /// The sampling state of document pixel `(x, y)` from the base and the strokes so far. - fn sampled_at(&self, x: f64, y: f64) -> bool { - let b = &self.base; - let i = b.index(x, y); - if i.is_some_and(|i| b.hole.get(i).copied().unwrap_or(false)) { - return false; - } - let mut on = i.is_some_and(|i| b.base.get(i).copied().unwrap_or(false)); - for s in self.strokes.iter().chain(self.cur.iter()) { - if covers(s, x, y) { - on = s.add; - } - } - on + /// The proxy, in proxy px. + fn proxy_area(&self) -> Rect { + Rect::new(0, 0, self.pw as i32, self.ph as i32) } - fn in_hole(&self, x: f64, y: f64) -> bool { - let b = &self.base; - b.index(x, y).is_some_and(|i| b.hole.get(i).copied().unwrap_or(false)) - } - - /// Recompute the proxy's per-pixel state inside proxy rect `r` (x0, y0, x1, y1). - fn restate(&mut self, r: [usize; 4]) { + /// `s` in proxy coordinates: proxy pixel `(px, py)` is centred on document point + /// `canvas.x0 + (px + 0.5) k`, so the stroke's points and size scale by `1 / k`. + fn proxy_stroke(&self, s: &SamplingStroke) -> SamplingStroke { let k = self.k as f64; - for py in r[1]..r[3].min(self.ph) { - for px in r[0]..r[2].min(self.pw) { - let (x, y) = (f64::from(self.canvas.x0) + (px as f64 + 0.5) * k, f64::from(self.canvas.y0) + (py as f64 + 0.5) * k); - let v = if self.in_hole(x, y) { 2 } else { u8::from(self.sampled_at(x, y)) }; - self.state[py * self.pw + px] = v; - } - } + let (ox, oy) = (f64::from(self.canvas.x0), f64::from(self.canvas.y0)); + SamplingStroke { add: s.add, size: s.size / k, points: s.points.iter().map(|p| [(p[0] - ox) / k, (p[1] - oy) / k]).collect() } + } + + /// Paints `s` (a stroke, or one segment of the one being drawn) into the proxy's sampling + /// area: only the pixels within the brush of its path are visited, whatever came before. + fn paint(&mut self, s: &SamplingStroke) { + let (s, area) = (self.proxy_stroke(s), self.proxy_area()); + paint_strokes(&mut self.mask, area, &[s]); self.overlay_dirty = true; } - fn restate_all(&mut self) { - self.restate([0, 0, self.pw, self.ph]); + /// Takes a new base: its option's area and fill area at the proxy's pixel centres, then the + /// strokes over it. + fn set_base(&mut self, b: Base) { + self.outline = outline(&b); + let k = self.k as f64; + for py in 0..self.ph { + for px in 0..self.pw { + let (x, y) = (f64::from(self.canvas.x0) + (px as f64 + 0.5) * k, f64::from(self.canvas.y0) + (py as f64 + 0.5) * k); + let i = b.index(x, y); + let at = |v: &[bool]| i.is_some_and(|i| v.get(i).copied().unwrap_or(false)); + self.base_px[py * self.pw + px] = at(&b.base); + self.hole_px[py * self.pw + px] = at(&b.hole); + } + } + self.base = b; + self.restate_all(); } - /// The proxy pixels a stroke segment touches. - fn proxy_rect(&self, r: Rect) -> [usize; 4] { - let k = self.k as i32; - let c = self.canvas; - let f = |v: i32, o: i32| ((v - o).max(0) / k) as usize; - [f(r.x0, c.x0), f(r.y0, c.y0), f(r.x1, c.x0) + 2, f(r.y1, c.y0) + 2] + /// Rebuilds the proxy's sampling area: the base painted with every stroke in order. Each + /// stroke costs its own pixels, so this is linear in the strokes, not strokes × pixels. + fn restate_all(&mut self) { + self.mask.clone_from(&self.base_px); + let strokes: Vec = self.strokes.iter().chain(self.cur.iter()).map(|s| self.proxy_stroke(s)).collect(); + let area = self.proxy_area(); + paint_strokes(&mut self.mask, area, &strokes); + self.overlay_dirty = true; + } + + /// Points in the strokes and lasso edits so far, against the engine's budget. + fn points_used(&self) -> usize { + let strokes: usize = self.strokes.iter().chain(self.cur.iter()).map(|s| s.points.len()).sum(); + let lassos: usize = self.fill_edits.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum(); + strokes + lassos } /// The preview is out of date. @@ -375,14 +414,31 @@ impl CafWorkspace { } fn begin_stroke(&mut self, p: [f64; 2], alt: bool) { + // The engine takes at most MAX_CAF_EDITS strokes and MAX_CAF_POINTS points in all: the + // brush stops there, so Apply and the preview never fail on what was painted. + if self.strokes.len() >= MAX_CAF_EDITS { + self.error = Some(format!("at most {MAX_CAF_EDITS} sampling strokes: undo some, or reset the sampling area")); + return; + } + if self.points_used() >= MAX_CAF_POINTS { + self.error = Some(points_limit()); + return; + } + self.error = None; let add = self.brush_add != alt; let s = SamplingStroke { add, size: self.brush_size, points: vec![p] }; - let r = self.proxy_rect(s.bounds()); + self.paint(&s); self.cur = Some(s); - self.restate(r); } fn extend_stroke(&mut self, p: [f64; 2]) { + if self.cur.is_none() { + return; + } + if self.points_used() >= MAX_CAF_POINTS { + self.error = Some(points_limit()); + return; + } let Some(s) = self.cur.as_mut() else { return }; if s.points.last().is_some_and(|l| l[0] == p[0] && l[1] == p[1]) { return; @@ -390,8 +446,7 @@ impl CafWorkspace { let last = *s.points.last().unwrap_or(&p); s.points.push(p); let seg = SamplingStroke { add: s.add, size: s.size, points: vec![last, p] }; - let r = self.proxy_rect(seg.bounds()); - self.restate(r); + self.paint(&seg); } fn end_stroke(&mut self) { @@ -411,21 +466,8 @@ impl CafWorkspace { } } -/// Does stroke `s` cover document point `(x, y)` (a pixel centre)? -fn covers(s: &SamplingStroke, x: f64, y: f64) -> bool { - let r2 = (s.size / 2.0).max(0.5).powi(2); - let near = |a: [f64; 2], b: [f64; 2]| { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let l2 = dx * dx + dy * dy; - let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let (qx, qy) = (a[0] + t * dx - x, a[1] + t * dy - y); - qx * qx + qy * qy <= r2 - }; - match s.points.as_slice() { - [] => false, - [p] => near(*p, *p), - pts => pts.windows(2).any(|w| near(w[0], w[1])), - } +fn points_limit() -> String { + format!("at most {MAX_CAF_POINTS} points in all sampling strokes and lasso edits: undo some, or reset") } /// The plan without strokes: the window, fill area and the option's area. Custom starts empty @@ -481,13 +523,14 @@ fn rebase(app: &mut PhotocraftApp) { let Some(w) = app.distort.caf.as_mut() else { return }; match r { Ok(b) => { - w.outline = outline(&b); - w.base = b; + w.set_base(b); w.error = None; } - Err(e) => w.error = Some(e), + Err(e) => { + w.error = Some(e); + w.restate_all(); + } } - w.restate_all(); w.stale(); } @@ -529,7 +572,11 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { k, pw, ph, - state: vec![0; pw * ph], + base_px: vec![false; pw * ph], + hole_px: vec![false; pw * ph], + mask: vec![false; pw * ph], + pressed: false, + texture_limit: 2048, image_tex: None, overlay_tex: None, overlay_dirty: true, @@ -542,9 +589,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { w.brush_add = w.opts.sampling == "custom"; let base = compute_base(app, &w)?; w.brush_size = photocraft_algo::content_aware::default_brush_size(bounds(&base)); - w.outline = outline(&base); - w.base = base; - w.restate_all(); + w.set_base(base); w.stale(); app.distort.caf = Some(w); Ok(()) @@ -691,6 +736,15 @@ fn redo(app: &mut PhotocraftApp) { fn fill_edit(app: &mut PhotocraftApp, edit: Value) { let Some(w) = app.distort.caf.as_mut() else { return }; w.end_stroke(); + // The engine's limits, kept here (see `begin_stroke`). + if w.fill_edits.len() >= MAX_CAF_EDITS { + w.error = Some(format!("at most {MAX_CAF_EDITS} lasso edits: undo some, or reset the fill area")); + return; + } + if w.points_used() + edit.get("points").and_then(Value::as_array).map_or(0, Vec::len) > MAX_CAF_POINTS { + w.error = Some(points_limit()); + return; + } w.fill_edits.push(edit.clone()); w.push(Step::Fill(edit)); rebase(app); @@ -852,30 +906,28 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { /// Computes the preview now, on this thread (tests and the control channel). fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { - let (doc, layer, p, want) = { + let (doc, layer, p, want, limit) = { let w = app.distort.caf.as_ref().ok_or("Content-Aware Fill is not open")?; let st = app.session.active().ok_or("no document")?; - (st.doc.clone(), w.layer, plan_params(w, true), w.preview.want) + (st.doc.clone(), w.layer, plan_params(w, true), w.preview.want, w.texture_limit) }; - let r = compute_preview(&doc, layer, &p, &photocraft_raster::Interrupt::NONE); + let r = compute_preview(&doc, layer, &p, &photocraft_raster::Interrupt::NONE, limit); let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; - match r { - Ok((rect, img)) => { - w.preview.error = None; - w.preview.shown = want; - w.preview.tex = None; - w.preview.pending_image = Some((rect, img)); - Ok(()) - } - Err(e) => { - w.preview.error = Some(e.clone()); - Err(e) - } - } + let err = r.as_ref().err().cloned(); + w.preview.finish(want, r); + err.map_or(Ok(()), Err) } -/// The filled rectangle as an image (what Apply would write there). -fn compute_preview(doc: &photocraft_doc::Document, layer: LayerId, p: &Value, ctl: &photocraft_raster::Interrupt) -> Result<(Rect, egui::ColorImage), String> { +/// The filled rectangle as an image (what Apply would write there), as a texture no wider than +/// `max_side`: past the device's limit it is box-averaged to fit (the rectangle it is drawn over +/// stays the plan's). +fn compute_preview( + doc: &photocraft_doc::Document, + layer: LayerId, + p: &Value, + ctl: &photocraft_raster::Interrupt, + max_side: usize, +) -> Result<(Rect, egui::ColorImage), String> { let plan = photocraft_engine::edit_menu_cmds::content_aware_plan_with(doc, layer, p, CMD, ctl).map_err(|e| match e { photocraft_engine::EngineError::Cancelled => "cancelled".to_string(), e => e.to_string(), @@ -885,22 +937,48 @@ fn compute_preview(doc: &photocraft_doc::Document, layer: LayerId, p: &Value, ct let filled = photocraft_algo::content_aware::fill_with(w, h, n, &plan.img, &plan.hole, &plan.source, &plan.opts, ctl).map_err(|_| "cancelled".to_string())?; let own = plan.layer_pixels(); - let mut px = Vec::with_capacity(w * h); - for i in 0..w * h { + // Pixel `i`: the fill over the fill area (a partly selected pixel by its coverage), the + // layer's own pixels elsewhere. + let pixel = |i: usize| -> [f32; 4] { let src = if plan.hole[i] { &filled[i * n..(i + 1) * n] } else { &own[i * n..(i + 1) * n] }; let mut c = photocraft_raster::to_rgba(&plan.fmt, src); if plan.hole[i] { - // A partly selected pixel shows the fill by its coverage. let k = plan.cover[i]; let under = photocraft_raster::to_rgba(&plan.fmt, &own[i * n..(i + 1) * n]); for j in 0..4 { c[j] = under[j] + (c[j] - under[j]) * k; } } - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - px.push(Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))); + c + }; + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let color = |c: [f32; 4]| Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])); + let f = crate::filter_preview_ui::texture_factor(w as u32, h as u32, max_side) as usize; + if f == 1 { + let px = (0..w * h).map(|i| color(pixel(i))).collect(); + return Ok((plan.rect, egui::ColorImage::new([w, h], px))); } - Ok((plan.rect, egui::ColorImage::new([w, h], px))) + let (tw, th) = (w.div_ceil(f), h.div_ceil(f)); + let mut px = Vec::with_capacity(tw * th); + for ty in 0..th { + for tx in 0..tw { + // The block's premultiplied average. + let (mut acc, mut count) = ([0.0f32; 4], 0.0f32); + for y in ty * f..((ty + 1) * f).min(h) { + for x in tx * f..((tx + 1) * f).min(w) { + let c = pixel(y * w + x); + for j in 0..3 { + acc[j] += c[j] * c[3]; + } + acc[3] += c[3]; + count += 1.0; + } + } + let c = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / count] } else { [0.0; 4] }; + px.push(color(c)); + } + } + Ok((plan.rect, egui::ColorImage::new([tw, th], px))) } /// Starts a background preview when the shown one is out of date (and the pointer is up), and @@ -917,13 +995,8 @@ fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context, dragging: bool) { pv.running = None; if generation == pv.want { match r { - Ok(img) => { - pv.pending_image = Some(img); - pv.shown = generation; - pv.error = None; - } - Err(e) if e != "cancelled" => pv.error = Some(e), - Err(_) => {} + Err(e) if e == "cancelled" => {} + r => pv.finish(generation, r), } } } @@ -944,13 +1017,13 @@ fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context, dragging: bool) { return; } let Some(doc) = doc else { return }; - let (layer, p, generation) = (w.layer, plan_params(w, true), w.preview.want); + let (layer, p, generation, limit) = (w.layer, plan_params(w, true), w.preview.want, w.texture_limit); let (tx, rx) = std::sync::mpsc::sync_channel(1); let job = photocraft_engine::jobs::JobCtx::new(); let work = job.clone(); let repaint = ctx.clone(); let spawned = std::thread::Builder::new().name("caf-preview".into()).spawn(move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work.stage(0.0, 1.0, "", |ctl| compute_preview(&doc, layer, &p, ctl)))) + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work.stage(0.0, 1.0, "", |ctl| compute_preview(&doc, layer, &p, ctl, limit)))) .unwrap_or_else(|_| Err("the preview failed".into())); let _ = tx.send(r); repaint.request_repaint(); @@ -1089,11 +1162,7 @@ impl View { fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { let need_image = app.distort.caf.as_ref().is_some_and(|w| w.image_tex.is_none()); if need_image { - let img = app.distort.caf.as_ref().and_then(|w| { - let st = app.session.active()?; - let surf = st.doc.layer(w.layer)?.surface()?; - Some(crate::distort_ui::surface_image(surf, w.canvas, PROXY_SIDE)) - }); + let img = view_image(app); if let (Some(w), Some(img)) = (app.distort.caf.as_mut(), img) { w.image_tex = Some(ctx.load_texture("caf-image", img, egui::TextureOptions::LINEAR)); } @@ -1104,15 +1173,10 @@ fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { let [r, g, b] = w.opts.color; let a = (w.opts.opacity / 100.0 * 255.0).round() as u8; let tint = Color32::from_rgba_unmultiplied(r, g, b, a); - let px: Vec = w - .state - .iter() - .map(|s| match (*s, w.opts.show_overlay, w.opts.excluded) { - (_, false, _) | (2, _, _) => Color32::TRANSPARENT, - (1, true, false) | (0, true, true) => tint, - _ => Color32::TRANSPARENT, - }) - .collect(); + // The tint marks the sampling area (with Indicates: Excluded Area, what is left out); + // the fill area is never tinted. + let px: Vec = + w.mask.iter().zip(&w.hole_px).map(|(m, h)| if w.opts.show_overlay && !*h && *m != w.opts.excluded { tint } else { Color32::TRANSPARENT }).collect(); let img = egui::ColorImage::new([w.pw, w.ph], px); match &mut w.overlay_tex { Some(t) => t.set(img, egui::TextureOptions::NEAREST), @@ -1124,6 +1188,16 @@ fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// The document as the view shows it: the visible layers composited, as on the canvas (so an +/// Apply to a new layer shows), reduced to the proxy. +fn view_image(app: &PhotocraftApp) -> Option { + let w = app.distort.caf.as_ref()?; + let st = app.session.active()?; + let buf = photocraft_compose::render_reduced(&st.doc, w.pw as u32, w.ph as u32); + let (display, _) = crate::canvas::canvas_display(app, &st.doc, None); + Some(crate::canvas::display_image(display.as_deref(), &buf)) +} + /// Draws the workspace (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // An Apply (or anything else) changed the document: refresh. @@ -1145,6 +1219,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { egui::Area::new(egui::Id::new("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(w) = app.distort.caf.as_mut() else { return }; let before = w.opts.clone(); + w.texture_limit = ctx.input(|i| i.max_texture_side); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); let painter = ui.painter().clone(); painter.rect_filled(full, 0.0, t.chrome); @@ -1304,22 +1379,18 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); } }); - // Footer: reset all, OK, Apply, Cancel. + // Footer, in Photoshop's order on every platform: reset all at the left, then OK, Apply, + // Cancel against the right edge. let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 48.0), vec2(PANEL_W - 20.0, 32.0)); let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); - if let Some(role) = widgets::dialog_buttons( - &mut fb, - &[ - widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 70.0), - widgets::DialogButton::new(widgets::ButtonRole::Apply, tl!("Apply"), 70.0), - widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 64.0), - ], - ) { - action = Some(match role { - widgets::ButtonRole::Default => "ok", - widgets::ButtonRole::Cancel => "cancel", - _ => "apply", - }); + if widgets::secondary_button(&mut fb, tl!("Cancel"), 70.0).clicked() { + action = Some("cancel"); + } + if widgets::secondary_button(&mut fb, tl!("Apply"), 70.0).clicked() { + action = Some("apply"); + } + if widgets::primary_button(&mut fb, tl!("OK"), 64.0).clicked() { + action = Some("ok"); } if crate::icons::button(&mut fb, "undo-2", 24.0, false, tl!("Reset")).clicked() { reset_what = Some("all"); @@ -1345,8 +1416,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let img_rect = pv.rect(w.canvas); widgets::checker(&pclip, img_rect.intersect(pv_area), 8.0); if let Some(tex) = &w.image_tex { - let pr = ERect::from_min_size(img_rect.min, vec2((w.pw * w.k) as f32 * pv.zoom, (w.ph * w.k) as f32 * pv.zoom)); - pclip.image(tex.id(), pr, uv, Color32::WHITE); + pclip.image(tex.id(), img_rect, uv, Color32::WHITE); } if let Some((tex, r)) = &w.preview.tex { pclip.image(tex.id(), pv.rect(*r), uv, Color32::WHITE); @@ -1401,21 +1471,22 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let clip = painter.with_clip_rect(area); let img = v.rect(w.canvas); widgets::checker(&clip, img.intersect(area), 8.0); - let pr = ERect::from_min_size(img.min, vec2((w.pw * w.k) as f32 * v.zoom, (w.ph * w.k) as f32 * v.zoom)); if let Some(tex) = &w.image_tex { - clip.image(tex.id(), pr, uv, Color32::WHITE); + clip.image(tex.id(), img, uv, Color32::WHITE); } if let Some(tex) = &w.overlay_tex { - clip.image(tex.id(), pr, uv, Color32::WHITE); + // The proxy's blocks are `k` document px; its last row and column may reach past the + // canvas, so the overlay is clipped to it. + let pr = ERect::from_min_size(img.min, vec2((w.pw * w.k) as f32 * v.zoom, (w.ph * w.k) as f32 * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), pr, uv, Color32::WHITE); } // The fill area's outline (marching ants: black dashes on white). let phase = (ui.input(|i| i.time) * 8.0) as usize; match &w.outline { Some(edges) => { - for (i, e) in edges.iter().enumerate() { + for e in edges { let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); - let _ = i; clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); } } @@ -1445,19 +1516,25 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if pan && resp.dragged() { let dl = resp.drag_delta(); w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; - } else if let Some(pp) = resp.interact_pointer_pos() { + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + // The press is the first point; the frames before egui's drag threshold and the drag + // itself continue it (a second `Down` would start the stroke over, dropping them). let q = v.to_doc(pp); mods = ui.input(|i| i.modifiers); - let active = w.cur.is_some() || w.lasso.is_some(); - if resp.drag_started() || (resp.is_pointer_button_down_on() && !active) { - events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); - } else if resp.dragged() || resp.is_pointer_button_down_on() { + if w.pressed { events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); } dragging = true; } - if (resp.drag_stopped() || resp.clicked()) && !pan { + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; let q = w.hover.unwrap_or(w.center); events.push(ToolEvent::Up { x: q[0], y: q[1] }); dragging = false; @@ -1675,4 +1752,221 @@ mod tests { assert_eq!(r["filled"], json!(400)); assert!(app.distort.caf.is_none()); } + + /// The engine's sampling area over the plan's rectangle (what the fill samples) and its fill + /// area. + fn engine_source(app: &PhotocraftApp) -> (Rect, Vec, Vec) { + let w = ws(app); + let st = app.session.active().unwrap(); + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan(&st.doc, w.layer, &plan_params(w, true), CMD).unwrap(); + (plan.rect, plan.source, plan.hole) + } + + /// Each stroke is painted into the proxy once, over its own pixels: 400 strokes of 50 points + /// and the undo and redo that rebuild the proxy stay cheap (they used to test every proxy + /// pixel against every segment), and the overlay is exactly what the engine samples (at one + /// proxy px per document px). + #[test] + fn strokes_are_painted_once_and_the_overlay_is_the_engines_sampling_area() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + let start = std::time::Instant::now(); + for i in 0..400u32 { + // Lines across the document, most subtracting, every fifth adding (Alt). + let y = 2.0 + f64::from(i % 40) * 4.0; + let pts: Vec<[f64; 2]> = (0..50).map(|j| [f64::from(j) * 4.0 + f64::from(i % 3), y + f64::from(j % 2)]).collect(); + control(&mut app, &json!({"stroke": {"points": pts, "size": 6 + (i % 4) * 3, "alt": i % 5 == 0}})).unwrap(); + } + let painted = start.elapsed(); + let start = std::time::Instant::now(); + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"redo": true})).unwrap(); + let rebuilt = start.elapsed(); + assert!(painted.as_secs_f64() < 10.0 && rebuilt.as_secs_f64() < 10.0, "400 strokes {painted:?}, undo + redo {rebuilt:?}"); + let w = ws(&app); + assert_eq!((w.k, w.strokes.len()), (1, 400)); + let (rect, source, hole) = engine_source(&app); + assert_eq!(rect, w.canvas, "Rectangular's window is the whole 200 × 160 canvas"); + let differ = (0..source.len()).filter(|&i| (w.mask[i] && !w.hole_px[i]) != (source[i] && !hole[i])).count(); + assert_eq!(differ, 0, "proxy pixels that differ from the engine's sampling area"); + assert!(w.describe()["sampledProxyPixels"].as_u64().unwrap() > 1000); + } + + /// `edit.contentAwareFill {"ui": …}` addresses the workspace: with it closed that is an + /// error, not a fill with the default settings (an agent polling the state after an Esc + /// used to add a fill per poll). + #[test] + fn ui_params_with_the_workspace_closed_are_an_error_not_a_fill() { + let ctx = egui::Context::default(); + let mut app = app_with_selection(); + let steps = app.session.active().unwrap().history.past_len(); + for ui in [json!({}), json!({"cancel": true}), json!({"sampling": "rectangular"})] { + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": ui})); + assert!(r.as_ref().is_err_and(|e| e.contains("not open")), "{r:?}"); + } + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "nothing was filled"); + assert!(app.distort.caf.is_none()); + } + + /// The press is the first point of a stroke (and of a lasso), and the pointer's moves before + /// egui's drag threshold are its next ones: the drag, which starts a few pixels later, goes + /// on with the same stroke rather than beginning another over it. + #[test] + fn a_stroke_keeps_its_points_from_before_the_drag_threshold() { + use egui::{Modifiers, PointerButton}; + for tool in ["brush", "lasso"] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"tool": tool, "sampling": "rectangular"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // The document view sits between the tool strip and the Preview panel, under the + // title and the options bar; its centre shows the document's centre. + let view = ERect::from_min_max(pos2(LEFT_W, 30.0 + BAR_H), pos2(1600.0 - PANEL_W - PREVIEW_W, 1000.0)); + let c = view.center(); + let at = |dx: f32, dy: f32| pos2(c.x + dx, c.y + dy); + let press = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, down: bool, p: Pos2| { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + h.event(egui::Event::PointerMoved(at(0.0, 0.0))); + h.run_steps(1); + press(&mut h, true, at(0.0, 0.0)); + // Within egui's click distance (6 px): no drag yet, but these are the stroke's points. + // Then past it: the drag starts. (Not collinear: the lasso's polygon needs an area.) + for (dx, dy) in [(2.0, 1.0), (4.0, 2.0), (30.0, 20.0), (60.0, -10.0), (90.0, 40.0)] { + h.event(egui::Event::PointerMoved(at(dx, dy))); + h.run_steps(1); + } + press(&mut h, false, at(90.0, 40.0)); + h.run_steps(2); + let w = h.state().distort.caf.as_ref().unwrap(); + let points: Vec<[f64; 2]> = match tool { + "brush" => { + assert_eq!(w.strokes.len(), 1, "one stroke"); + w.strokes[0].points.clone() + } + _ => { + assert_eq!(w.fill_edits.len(), 1, "one lasso edit"); + serde_json::from_value(w.fill_edits[0]["points"].clone()).unwrap() + } + }; + let centre = [f64::from(w.canvas.x0 + w.canvas.x1) / 2.0, f64::from(w.canvas.y0 + w.canvas.y1) / 2.0]; + assert_eq!(points.len(), 6, "{tool}: the press and every move are points: {points:?}"); + let d = ((points[0][0] - centre[0]).powi(2) + (points[0][1] - centre[1]).powi(2)).sqrt(); + assert!(d < 1.0, "{tool}: the first point is the press ({:?} vs {centre:?})", points[0]); + assert!(w.cur.is_none() && w.lasso.is_none() && !w.pressed, "{tool}: the release ended it"); + } + } + + /// The preview texture fits the device: a plan rectangle wider than the texture limit is + /// box-averaged to fit, and is still drawn over the plan's rectangle. + #[test] + fn the_preview_texture_respects_the_device_limit() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 2200, "height": 40, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + let s = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + s.fill_rect(Rect::new(0, 0, 2200, 40), &[0.2, 0.6, 0.4, 1.0]); + s.fill_rect(Rect::new(1090, 10, 1110, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 1090, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + open(&mut app).unwrap(); + // Custom sampling with a stroke across the whole width: the plan's rectangle is 2200 px wide. + control(&mut app, &json!({"sampling": "custom", "stroke": {"points": [[0.0, 5.0], [2200.0, 5.0]], "size": 8}})).unwrap(); + for (limit, want) in [(2048usize, 1100usize), (4096, 2200)] { + let w = app.distort.caf.as_mut().unwrap(); + w.texture_limit = limit; + w.stale(); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + let (rect, img) = ws(&app).preview.pending_image.clone().unwrap(); + assert_eq!((rect.width(), img.size[0]), (2200, want), "limit {limit}"); + assert!(img.size.iter().all(|s| *s <= limit), "limit {limit}: {:?}", img.size); + let (sum, n) = img.pixels.iter().fold((0u32, 0u32), |(s, n), p| (s + u32::from(p.g()), n + 1)); + assert!(sum / n > 100, "limit {limit}: the reduced preview keeps the image ({})", sum / n); + } + } + + /// A preview that fails stands for that state: nothing is shown, the error is reported, and + /// the same preview is not started again on every frame. + #[test] + fn a_failing_preview_is_reported_once_not_restarted() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + assert!(ws(&app).preview.pending_image.is_some()); + // The selection goes away under the workspace: there is nothing to fill. + app.session.execute("select.deselect", json!({})).unwrap(); + app.distort.caf.as_mut().unwrap().stale(); + assert!(control(&mut app, &json!({"waitPreview": true})).is_err()); + let pv = &ws(&app).preview; + assert_eq!(pv.shown, pv.want, "the failed state counts as shown: no retry"); + assert!(pv.pending_image.is_none() && pv.tex.is_none()); + let d = ws(&app).describe(); + assert_eq!(d["previewReady"], json!(false)); + assert!(d["previewError"].as_str().is_some_and(|e| e.contains("selection")), "{d}"); + } + + /// The view shows the document, not the layer alone: after Apply to a new layer the fill + /// (on that layer) covers the blemish. + #[test] + fn the_view_shows_the_document_after_an_apply_to_a_new_layer() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let red = |img: &egui::ColorImage| { + let p = img.pixels[80 * img.size[0] + 100]; + p.r() > 200 && p.g() < 60 + }; + let before = view_image(&app).unwrap(); + assert_eq!(before.size, [200, 160]); + assert!(red(&before), "the blemish shows before the fill"); + control(&mut app, &json!({"output": "new", "colorAdaptation": "none", "apply": true})).unwrap(); + assert!(!red(&view_image(&app).unwrap()), "the fill on the new layer shows"); + } + + /// The engine's limits (MAX_CAF_EDITS strokes or lasso edits, MAX_CAF_POINTS points in all) + /// hold at the brush: a stroke past them is refused with a message, so Apply and the + /// preview never fail on what was painted. + #[test] + fn strokes_past_the_engines_limits_are_refused_at_the_brush() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + for i in 0..MAX_CAF_EDITS { + let x = (i % 190) as f64; + control(&mut app, &json!({"stroke": {"points": [[x, 5.0], [x + 3.0, 5.0]], "size": 2}})).unwrap(); + } + let d = control(&mut app, &json!({"stroke": {"points": [[10.0, 50.0], [20.0, 50.0]], "size": 2}})).unwrap(); + assert_eq!(d["strokes"], json!(MAX_CAF_EDITS)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + // The points budget: a long stroke stops at it. + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let pts: Vec<[f64; 2]> = (0..MAX_CAF_POINTS + 100).map(|i| [10.0 + i as f64 * 0.0005, 5.0]).collect(); + let d = control(&mut app, &json!({"stroke": {"points": pts, "size": 2}})).unwrap(); + assert_eq!(ws(&app).points_used(), MAX_CAF_POINTS); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + // A lasso edit past the points budget is refused too. + let d = control(&mut app, &json!({"lasso": {"op": "add", "points": [[130.0, 70.0], [150.0, 70.0], [150.0, 90.0]]}})).unwrap(); + assert_eq!(d["fillEdits"], json!(0)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + } } diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index da1f24b55d..4987d8cc4d 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -115,6 +115,8 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "filter.liquify" if empty => Some(crate::liquify_ui::open(app, ctx).map(|_| json!({"liquify": app.distort.describe()["liquify"]}))), "filter.liquify" if ui.is_some() && app.distort.liquify.is_some() => Some(crate::liquify_ui::control(app, ui.unwrap_or(&Value::Null))), "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), + // `ui` addresses the workspace: closed, that is an error, not a fill with the defaults. + "edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..463155981c 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -237,7 +237,7 @@ fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) } /// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits). -fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { +pub(crate) fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1); w.max(h).div_ceil(max_side).max(1) } From 0397867a6591321312084cc2c7cd99e94a907478 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 18 Oct 2026 11:46:44 +1100 Subject: [PATCH 0513/1452] Add catalog entries for Move Up and Move Down (#6498). every_tl_literal_is_translated requires every complete-menu language to include the new Layer Comps context actions. --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ 20 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..66fa42688a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3816,3 +3816,5 @@ navigator Zoom Zvětšení This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + Move Up Přesunout nahoru + Move Down Přesunout dolů diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..e1272cc57e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3210,3 +3210,5 @@ curves Smooth Glätten This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + Move Up Nach oben + Move Down Nach unten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..46c5c1ae87 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3908,3 +3908,5 @@ curves Smooth Εξομάλυνση This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + Move Up Μετακίνηση επάνω + Move Down Μετακίνηση κάτω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..49328ba991 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4170,3 +4170,5 @@ navigator Zoom Zoom This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + Move Up Mover arriba + Move Down Mover abajo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..8a5de5a6b0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3213,3 +3213,5 @@ curves Smooth Lisser This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + Move Up Déplacer vers le haut + Move Down Déplacer vers le bas diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..9bf47c2ab9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4127,3 +4127,5 @@ cameraRaw Yellow Sárga This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + Move Up Mozgatás felfelé + Move Down Mozgatás lefelé diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..162f056cd3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3200,3 +3200,5 @@ curves Smooth Haluskan This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + Move Up Pindah ke atas + Move Down Pindah ke bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..917bf52d62 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4329,3 +4329,5 @@ shape Appearance Aspetto This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + Move Up Sposta su + Move Down Sposta giù diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..1187e39b56 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3884,3 +3884,5 @@ curves Smooth スムーズ This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + Move Up 上へ移動 + Move Down 下へ移動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..339a494162 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3386,3 +3386,5 @@ curves Smooth 매끄럽게 This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + Move Up 위로 이동 + Move Down 아래로 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..10f1fcdc61 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3968,3 +3968,5 @@ curves Smooth Vloeiend maken This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + Move Up Omhoog + Move Down Omlaag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..432c58fd93 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3806,3 +3806,5 @@ curves Smooth Wygładź This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + Move Up Przesuń w górę + Move Down Przesuń w dół diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..e7607eaf9b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3998,3 +3998,5 @@ layerLabel No Color Sem cor This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + Move Up Mover para cima + Move Down Mover para baixo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..c05ffc011e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3178,3 +3178,5 @@ curves Smooth Сгладить This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + Move Up Переместить вверх + Move Down Переместить вниз diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..bd1462a499 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4281,3 +4281,5 @@ shape Appearance Izgled This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + Move Up Pomeri nagore + Move Down Pomeri nadole diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..71902574c4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3938,3 +3938,5 @@ cameraData Lens Lens This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + Move Up Yukarı taşı + Move Down Aşağı taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..b9c3f95162 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3907,3 +3907,5 @@ curves Smooth Згладити This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + Move Up Перемістити вгору + Move Down Перемістити вниз diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..0c1b0bb990 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3694,3 +3694,5 @@ curves Smooth Làm mượt This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + Move Up Di chuyển lên + Move Down Di chuyển xuống diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..b8957352ef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2824,3 +2824,5 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + Move Up 上移 + Move Down 下移 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..77fa132600 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2809,3 +2809,5 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + Move Up 上移 + Move Down 下移 From 61db2b2bba6719b4857665801e42e09f778095e9 Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sat, 17 Oct 2026 18:00:15 -0700 Subject: [PATCH 0514/1452] Group all selected layers from the Layers panel footer --- crates/ui-egui/src/new_group_button_tests.rs | 43 ++++++++++++++++++-- crates/ui-egui/src/panels.rs | 5 +-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/new_group_button_tests.rs b/crates/ui-egui/src/new_group_button_tests.rs index e236ad74ec..529972a418 100644 --- a/crates/ui-egui/src/new_group_button_tests.rs +++ b/crates/ui-egui/src/new_group_button_tests.rs @@ -1,5 +1,5 @@ -//! #2561: the Layers panel New Group button. Like Photoshop a plain click adds an empty group -//! and Shift-click groups the selected layers. +//! The Layers panel New Group button groups multiple selected layers on a plain click. +//! Shift-click also groups a single selected layer; otherwise it creates an empty group. use egui::Modifiers; use egui_kittest::kittest::Queryable; @@ -42,8 +42,42 @@ fn shift_click_groups_the_selected_layers_in_order() { } #[test] -fn plain_click_still_adds_an_empty_group() { +fn plain_click_groups_all_selected_layers_in_order_and_undoes_in_one_step() { + for active in ["A", "B"] { + let (mut h, a, b) = two_selected(); + let id = if active == "A" { a } else { b }; + h.state_mut().session.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + h.run_steps(4); + h.get_by_label("Create a new group").click(); + h.run_steps(4); + let groups = groups(&h); + assert_eq!(groups.len(), 1); + let group = &groups[0]; + let children: Vec = group.children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![a, b]); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "only the background and group remain at the top level"); + assert_eq!(st.selected_layers(), vec![group.id]); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 3); + assert_eq!(st.doc.layers[1].id, a); + assert_eq!(st.doc.layers[2].id, b); + assert_eq!(st.active_layer, Some(id)); + + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + let st = h.state().session.active().unwrap(); + let children: Vec = st.doc.layer(group.id).unwrap().children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![a, b]); + } +} + +#[test] +fn plain_click_with_one_selected_layer_still_adds_an_empty_group() { let (mut h, a, b) = two_selected(); + h.state_mut().session.execute("layer.select", json!({"layer": a.0})).unwrap(); + h.run_steps(4); h.get_by_label("Create a new group").click(); h.run_steps(4); let groups = groups(&h); @@ -51,5 +85,6 @@ fn plain_click_still_adds_an_empty_group() { assert!(groups[0].children().unwrap().is_empty()); let doc = &h.state().session.active().unwrap().doc; let top: Vec = doc.layers.iter().map(|l| l.id).collect(); - assert_eq!(top[1..3], [a, b], "the selected layers stay where they were"); + assert_eq!(top[1], a, "the selected layer stays where it was"); + assert_eq!(top[3], b, "the unselected layer stays above the new group"); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fc8e2d8f4..7dbe3ab843 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2521,10 +2521,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); if group.clicked() { - // Like Photoshop a plain click adds an empty group; Shift-click groups the selected - // layers, the same as Layer › Group Layers (#2561). + // Multiple selected layers share one group. Shift also groups a single selection. let shift = ui.input(|i| i.modifiers.shift); - let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; + let cmd = if (shift || selection.len() > 1) && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; actions.push((cmd.into(), json!({}))); } actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); From b450baa6eb9f8b9d0c4712451494706368df1721 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 03:02:31 +0200 Subject: [PATCH 0515/1452] Image: Trim and merged Duplicate report an error on documents over 2^30 px (#7223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compose::flatten` returns an empty buffer for canvases over MAX_RENDER_PIXELS. Trim (Top Left) indexed `px[0]` of it and Duplicate › Merged Layers Only fed it to `write_region`'s length assert: both panicked; Trim's other modes reported "nothing to trim to". Both now return a "too large to flatten" error first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 17 +++++++++++--- crates/engine/tests/huge_doc_flatten.rs | 30 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/huge_doc_flatten.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..6d4970ecc9 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -273,6 +273,17 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// The flattened document, or an error when the canvas is over the compositor's render cap +/// ([`photocraft_compose::MAX_RENDER_PIXELS`]; `flatten` returns an empty buffer then). +fn flatten_whole(doc: &Document, cmd: &str) -> Result { + let buf = photocraft_compose::flatten(doc); + let (w, h) = (doc.size.width, doc.size.height); + if buf.px.len() as u64 != u64::from(w) * u64::from(h) { + return Err(bad(cmd, format!("the document ({w} x {h} px) is too large to flatten (limit {} pixels)", photocraft_compose::MAX_RENDER_PIXELS))); + } + Ok(buf) +} + /// Moves every surface, channel, selection and guide by `(dx, dy)`. /// /// Fails, before moving anything, when some pixels would land outside the i32 coordinate @@ -650,11 +661,11 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar fn trim(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); - let buf = photocraft_compose::flatten(&d.doc); + let buf = flatten_whole(&d.doc, "image.trim")?; let based = p.get("basedOn").and_then(Value::as_str).unwrap_or("transparent"); let w = canvas.width() as usize; let reference = match based { - "topLeft" => Some(buf.px[0]), + "topLeft" => buf.px.first().copied(), "bottomRight" => buf.px.last().copied(), _ => None, }; @@ -729,7 +740,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.id = photocraft_doc::DocId::fresh(); doc.name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} copy", d.doc.name)); if p.get("mergedOnly").and_then(Value::as_bool).unwrap_or(false) { - let buf = photocraft_compose::flatten(&doc); + let buf = flatten_whole(&doc, "image.duplicate")?; let fmt = doc.pixel_format(); let mut surf = Surface::new(fmt); let vals: Vec = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); diff --git a/crates/engine/tests/huge_doc_flatten.rs b/crates/engine/tests/huge_doc_flatten.rs new file mode 100644 index 0000000000..fd83c1169d --- /dev/null +++ b/crates/engine/tests/huge_doc_flatten.rs @@ -0,0 +1,30 @@ +//! Trim and Duplicate › Merged Layers Only flatten the document; on a canvas over the compositor's +//! render cap the flatten comes back empty, and both must report it instead of panicking (#7223). + +use photocraft_engine::Session; +use serde_json::json; + +/// 1.2e9 px: within File › New's per-side limit, over `MAX_RENDER_PIXELS` (2^30). +fn huge() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + s +} + +#[test] +fn trim_on_a_document_too_large_to_flatten_is_an_error() { + for based in ["topLeft", "bottomRight", "transparent"] { + let mut s = huge(); + let err = s.execute("image.trim", json!({"basedOn": based})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{based}: {err}"); + assert_eq!(s.active().unwrap().doc.size.width, 40000, "{based}: the document is untouched"); + } +} + +#[test] +fn merged_duplicate_of_a_document_too_large_to_flatten_is_an_error() { + let mut s = huge(); + let err = s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{err}"); + assert_eq!(s.documents().len(), 1, "no half-built copy is added"); +} From 5ed2a1ff99c4e719b258a2956a574551cf0e50cb Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:17:32 +0300 Subject: [PATCH 0516/1452] =?UTF-8?q?fix(liquify):=20zoom=20the=20preview?= =?UTF-8?q?=20with=20=E2=8C=98+/=E2=8C=98=E2=88=92/=E2=8C=980/=E2=8C=981,?= =?UTF-8?q?=20a=20trackpad=20pinch=20and=20=C3=971.1=20wheel=20notches,=20?= =?UTF-8?q?as=20in=20Photoshop=20(#7210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/liquify_ui.rs | 45 +++++++++++++++++++++++++------- crates/ui-egui/src/shortcuts.rs | 25 +++++++++++------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..f6894b5f1d 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -639,7 +639,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; @@ -733,6 +744,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); @@ -850,19 +862,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]]; } @@ -991,6 +1013,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// 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) } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e8645a1511..7690a5f7fe 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -195,18 +195,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 From cc3ca41c6d3f552bb8ce8cb5e5d58107c3f714be Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:17:32 +0300 Subject: [PATCH 0517/1452] test(liquify): zoom keys step the preview, not the canvas; pinch and wheel zoom about the pointer (#7210) --- crates/ui-egui/src/liquify_ui.rs | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index f6894b5f1d..5e42761ff1 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -1448,6 +1448,88 @@ 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"); + } + #[test] fn menu_opens_the_dialog_and_routes_pointer_events() { let ctx = egui::Context::default(); From c9fc1f3b2e86a630b4573daf1335cfa031baa3f0 Mon Sep 17 00:00:00 2001 From: ametadot <340873525+ametadot@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:21:47 +1100 Subject: [PATCH 0518/1452] test(camera-raw): keep identity tests after production source --- crates/ui-egui/src/camera_raw_ui.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 375b52adf0..649313d727 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -28,10 +28,6 @@ const PROXY_SIDE: usize = 900; const PANEL_W: f32 = 330.0; const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; -#[cfg(test)] -#[path = "camera_raw_identity_tests.rs"] -mod identity_tests; - pub struct CameraRawDialog { pub layer: LayerId, layer_name: String, @@ -1707,3 +1703,7 @@ mod tests { assert_eq!(app.camera_raw.as_ref().unwrap().params.clarity, 25.0); } } + +#[cfg(test)] +#[path = "camera_raw_identity_tests.rs"] +mod identity_tests; From 8407d9ccb125af52944fca9d228383459f94aaef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 21:28:00 -0400 Subject: [PATCH 0519/1452] Release 0.10.0 (#7192) * Release 0.10.0 Co-Authored-By: Claude Opus 5.5 * Cargo.lock: photocraft-ipp at 0.10.0 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fefc7e5771..26920a968e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4618,7 +4618,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "async-io", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.9.0" +version = "0.10.0" dependencies = [ "crc32fast", "flate2", @@ -4673,7 +4673,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4686,7 +4686,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4708,7 +4708,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4722,7 +4722,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.9.0" +version = "0.10.0" dependencies = [ "moxcms", "rayon", @@ -4730,7 +4730,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.9.0" +version = "0.10.0" dependencies = [ "exr", "flate2", @@ -4751,7 +4751,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-cms", "serde", @@ -4759,7 +4759,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.9.0" +version = "0.10.0" dependencies = [ "criterion", "photocraft-color", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4785,7 +4785,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "blake3", "craft-print", @@ -4821,7 +4821,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.9.0" +version = "0.10.0" dependencies = [ "blake3", "crc32fast", @@ -4842,14 +4842,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.9.0" +version = "0.10.0" dependencies = [ "half", "log", @@ -4866,14 +4866,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.9.0" +version = "0.10.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.9.0" +version = "0.10.0" dependencies = [ "blake3", "craft-docpath", @@ -4903,18 +4903,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.9.0" +version = "0.10.0" [[package]] name = "photocraft-ops" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4925,7 +4925,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4940,7 +4940,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "photocraft-psd", @@ -4951,7 +4951,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "image", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.9.0" +version = "0.10.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4988,7 +4988,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.9.0" +version = "0.10.0" dependencies = [ "memory-stats", "photocraft-color", @@ -4999,7 +4999,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "parley", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5055,7 +5055,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.9.0" +version = "0.10.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5066,7 +5066,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -5088,7 +5088,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "js-sys", @@ -8675,7 +8675,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 25a7a101c3..3f8dbb0a72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From ceb85009c775c67834371c2fcccbc1861cec05c2 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 20:21:51 -0600 Subject: [PATCH 0520/1452] perf(canvas): a Properties-panel adjustment drag refreshes only the view (#6839) (#7155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `damage_since` had a case for the Image > Adjustments dialog but none for the Properties panel's `live_adjust` preview, so every parameter change returned no damage and recomposited the whole document — `gpu-full` at 24.0 MP. That is what made #5778 a latency regression at 100 % zoom: it correctly stops CPU-compositing a 1.5 MP proxy, but the full-size GPU composite replacing it is per-pixel work and 24 MP of it costs more. The Properties path now gets what the dialog had: a session-tagged key, the layer's area as damage, and of that only what the view shows, catching up as the view moves. Intel UHD P630, 24 MP document, 12 changes, medians: v0.9.0 70.3 ms 1.5 MP CPU proxy main 203.1 ms gpu-full, 24.0 MP this 47.8 ms gpu-rect, 1.10 MP Byte-identical to the full-refresh base on all 12 changes and across a pan. 25 % zoom and PHOTOCRAFT_CPU_COMPOSE are unchanged. `live_region` does not narrow by the layer's mask: a reveal-all mask keeps its coverage in the default pixel and reports empty content bounds, which emptied the damage and froze the preview. Every adjustment layer has one. --- crates/ui-egui/src/adjust_preview.rs | 105 +++++++++++++++++++++ crates/ui-egui/src/adjust_preview_tests.rs | 57 +++++++++++ crates/ui-egui/src/canvas.rs | 21 ++++- crates/ui-egui/src/lib.rs | 4 + 4 files changed, 186 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/adjust_preview.rs b/crates/ui-egui/src/adjust_preview.rs index d94d58bff6..f74afc8595 100644 --- a/crates/ui-egui/src/adjust_preview.rs +++ b/crates/ui-egui/src/adjust_preview.rs @@ -436,6 +436,9 @@ pub(crate) fn retain_documents(app: &mut PhotocraftApp) { if app.adjust_preview.as_ref().is_some_and(|p| !app.session.documents().iter().any(|st| st.doc.id == p.doc)) { app.adjust_preview = None; } + if app.live_adjust_preview.as_ref().is_some_and(|p| !app.session.documents().iter().any(|st| st.doc.id == p.doc)) { + app.live_adjust_preview = None; + } } /// GPU texture keys the preview uses besides the documents' own (keep them alive). @@ -444,6 +447,108 @@ pub fn gpu_keys(app: &PhotocraftApp) -> Option { p.proxy.as_ref().map(|g| g.base.id.0) } +// --------------------------------------------------------------------------------------------- +// The Properties panel's live adjustment-layer preview (`canvas::display_doc`'s `live_adjust`). +// +// Dragging a control there shows the document with that layer's adjustment replaced. Unlike the +// dialog above it needs no temporary layer: the layer is already in the document. What it did +// lack was a damage region, so every parameter value recomposited the whole document — on the +// GPU canvas at 100 % zoom that is the full size, measured at about 7 ms per megapixel, which +// made #5778 a latency regression (#6839). These give that path the same treatment the dialog +// gets: a session-tagged key, the layer's area as the damage, and of that only what the view +// shows, catching up as the view moves. + +/// Tag of the Properties-panel live-adjust preview keys. Distinct from [`TAG`], so a key is +/// never mistaken for the dialog's. +pub const LIVE_TAG: u64 = 1 << 58; +/// Bits of a live-adjust key that identify the session (document, revision, layer); below them +/// the settings hash. The session occupies bits 40..58, under [`LIVE_TAG`]. +const LIVE_SESSION_SHIFT: u32 = 40; +/// Mask of the session part of a live-adjust key (18 bits). +const LIVE_SESSION_MASK: u64 = (1 << 18) - 1; + +/// One Properties-panel drag: which layer of which document revision, the area its adjustment +/// can change, and what the canvas has composited of it so far. +pub struct LiveAdjust { + doc: DocId, + revision: u64, + session: u64, + region: Rect, + /// The main canvas texture shows preview `key` only within this rect; elsewhere in the + /// region it is older (see [`live_uncovered`]). + covered: Option<(u64, Rect)>, +} + +/// The area an adjustment layer can change: the whole document. +/// +/// It is deliberately not narrowed by the layer's mask. A mask's coverage can live entirely in +/// its surface's *default pixel* with no tiles written — [`LayerMask::reveal_all`], which is what +/// `layer.newAdjustmentLayer` gives every adjustment layer — and such a mask reports an empty +/// `content_bounds()`. Intersecting with that emptied the region, so the damage was empty and the +/// compositor drew nothing at all for the whole drag. A mask only bounds the effect when it hides +/// outside its content (default pixel 0); that narrowing is worth little here, since the damage is +/// clipped to the view anyway ([`live_switch_damage`]), and is not worth the risk of showing stale +/// pixels. Too large only costs pixels; too small freezes the preview. +fn live_region(doc: &Document, layer: LayerId) -> Rect { + if doc.layer(layer).is_none() { + return Rect::EMPTY; + } + doc.bounds() +} + +/// The preview key for `params` on `layer` of `doc` at `revision`, starting a session (and +/// measuring its region) when the document, revision or layer changes. +pub fn live_key(app: &mut PhotocraftApp, doc: &Document, revision: u64, layer: LayerId, params: &Value) -> u64 { + let session = hash(&[&doc.id.0.to_le_bytes(), &revision.to_le_bytes(), &layer.0.to_le_bytes()]); + if app.live_adjust_preview.as_ref().is_none_or(|p| p.session != session) { + app.live_adjust_preview = Some(LiveAdjust { doc: doc.id, revision, session, region: live_region(doc, layer), covered: None }); + } + let h = hash(&[params.to_string().as_bytes()]); + LIVE_TAG | ((session & LIVE_SESSION_MASK) << LIVE_SESSION_SHIFT) | (h & ((1 << LIVE_SESSION_SHIFT) - 1)) +} + +/// The canvas area to recomposite when document `doc` (at `revision`) switches between two of +/// this drag's previews, or between one and the committed document (0). +pub fn live_switch_region(app: &PhotocraftApp, doc: DocId, revision: u64, from: u64, to: u64) -> Option { + let p = app.live_adjust_preview.as_ref()?; + if p.doc != doc || p.revision != revision { + return None; + } + let prefix = LIVE_TAG | ((p.session & LIVE_SESSION_MASK) << LIVE_SESSION_SHIFT); + let ours = |k: u64| k == 0 || k >> LIVE_SESSION_SHIFT == prefix >> LIVE_SESSION_SHIFT; + (ours(from) && ours(to) && (from != 0 || to != 0)).then_some(p.region) +} + +/// The part of a switch's region to composite now: to a preview, only what the view shows, +/// remembered so [`live_uncovered`] catches up as the view moves; back to the committed +/// document (key 0), the whole region. +pub fn live_switch_damage(app: &mut PhotocraftApp, to: u64, region: Rect, visible: Rect) -> Rect { + let Some(p) = app.live_adjust_preview.as_mut() else { return region }; + if to == 0 { + p.covered = None; + return region; + } + let shown = region.intersect(&visible); + p.covered = Some((to, shown)); + shown +} + +/// Where the main canvas texture (showing preview `key`) is out of date within `visible`: the +/// region's visible part when the view moved past what was composited. Marks it covered. +pub fn live_uncovered(app: &mut PhotocraftApp, doc: DocId, key: u64, visible: Rect) -> Option { + let p = app.live_adjust_preview.as_mut().filter(|p| p.doc == doc)?; + let (k, cov) = p.covered?; + if k != key { + return None; + } + let want = p.region.intersect(&visible); + if want.is_empty() || cov.contains_rect(&want) { + return None; + } + p.covered = Some((k, want)); + Some(want) +} + #[cfg(test)] #[path = "adjust_preview_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/adjust_preview_tests.rs b/crates/ui-egui/src/adjust_preview_tests.rs index f3e33a723b..0b5337da7a 100644 --- a/crates/ui-egui/src/adjust_preview_tests.rs +++ b/crates/ui-egui/src/adjust_preview_tests.rs @@ -333,3 +333,60 @@ fn proxy_preview_has_its_own_ids() { } assert!(proxy_with_settings(&proxy, "levels", &json!({"gamma": "x"})).is_err()); } + +/// A Properties-panel drag damages the layer's area and only the part of it the view shows, +/// catching up as the view moves; a new document revision ends the session (#6839). +#[test] +fn a_live_adjust_drag_damages_only_the_visible_part_of_its_layer() { + let (doc, target) = document(ColorMode::Rgb, SampleType::U8, false); + let (id, bounds) = (doc.id, doc.bounds()); + let mut app = app_with(doc.clone(), target); + + let k1 = live_key(&mut app, &doc, 7, target, &json!({"gamma": 1.1})); + let k2 = live_key(&mut app, &doc, 7, target, &json!({"gamma": 1.4})); + assert_ne!(k1, k2, "a different setting is a different key"); + assert_eq!(k1 & LIVE_TAG, LIVE_TAG, "keys carry the live tag"); + assert_eq!(k1 >> LIVE_SESSION_SHIFT, k2 >> LIVE_SESSION_SHIFT, "the same drag is one session"); + assert_ne!(k1 >> LIVE_SESSION_SHIFT, TAG >> LIVE_SESSION_SHIFT, "never the dialog's tag"); + + // Between two of the drag's previews, and between one and the committed document. + let region = live_switch_region(&app, id, 7, k1, k2).expect("same session"); + assert_eq!(region, bounds, "the region is the whole document"); + assert!(live_switch_region(&app, id, 7, 0, k2).is_some(), "from the committed document"); + assert!(live_switch_region(&app, id, 7, k1, 0).is_some(), "back to the committed document"); + assert!(live_switch_region(&app, id, 7, 0, 0).is_none(), "the document to itself is no switch"); + assert!(live_switch_region(&app, id, 8, k1, k2).is_none(), "another revision is another session"); + + // Only the visible part is composited now; moving the view asks for the rest. + let mid = region.x0 + region.width() as i32 / 2; + let left = Rect::new(region.x0, region.y0, mid, region.y1); + assert_eq!(live_switch_damage(&mut app, k2, region, left), region.intersect(&left)); + assert!(live_uncovered(&mut app, id, k2, left).is_none(), "what was just composited is covered"); + let right = Rect::new(mid, region.y0, region.x1, region.y1); + assert_eq!(live_uncovered(&mut app, id, k2, right), Some(region.intersect(&right)), "the view moved"); + assert!(live_uncovered(&mut app, id, k2, right).is_none(), "and is covered once composited"); + assert!(live_uncovered(&mut app, id, k1, right).is_none(), "another preview's coverage is not this one's"); + + // Switching back to the document composites the whole region, not just the view. + assert_eq!(live_switch_damage(&mut app, 0, region, left), region); +} + +/// A reveal-all mask stores its coverage in the surface's default pixel and reports an empty +/// `content_bounds()`. Narrowing the region by it emptied the damage and the canvas drew nothing +/// for a whole drag — every adjustment layer `layer.newAdjustmentLayer` creates has one (#6839). +#[test] +fn a_reveal_all_mask_does_not_empty_the_damage_region() { + let (mut doc, target) = document(ColorMode::Rgb, SampleType::U8, false); + let bounds = doc.bounds(); + let mask = photocraft_doc::LayerMask::reveal_all(); + assert!(mask.surface.content_bounds().is_empty(), "a reveal-all mask has no written content"); + doc.layer_mut(target).expect("target").mask = Some(mask); + let id = doc.id; + let mut app = app_with(doc.clone(), target); + + let k1 = live_key(&mut app, &doc, 3, target, &json!({"gamma": 1.1})); + let k2 = live_key(&mut app, &doc, 3, target, &json!({"gamma": 1.4})); + let region = live_switch_region(&app, id, 3, k1, k2).expect("same session"); + assert_eq!(region, bounds, "the whole document, not the mask's empty content"); + assert!(!live_switch_damage(&mut app, k2, region, bounds).is_empty(), "something is composited"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 914d395acc..9ca0bac7c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1203,12 +1203,13 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar return (doc.clone(), key); } } + let revision = st.revision; let snapshot = st.doc.clone(); if let Some((layer, params)) = app.live_adjust.clone() && let Some(l) = snapshot.layer(layer) && let LayerContent::Adjustment(a) = &l.content { - let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); + let key = crate::adjust_preview::live_key(app, &snapshot, revision, layer, ¶ms); let shown = memo_display(app, &snapshot, key, || { let kind = photocraft_engine::commands::adjustment_kind(a); let preview = photocraft_engine::adjust_params::from_params(kind, ¶ms, Some(a), snapshot.mode).unwrap_or_else(|_| a.clone()); @@ -1489,6 +1490,14 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(r); } + // Between a Properties-panel adjustment-layer drag's previews (and the document): the + // layer's area. Without this every parameter value recomposited the whole document (#6839). + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::adjust_preview::live_switch_region(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(r); + } // Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick // at the press changes no pixels). if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty()))) @@ -1592,6 +1601,12 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: { damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible)); } + // The same for a Properties-panel drag's previews. + if damage.is_some() + && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::live_switch_region(app, id, revision, s.1 ^ display_key, raw_key)) + { + damage = Some(crate::adjust_preview::live_switch_damage(app, raw_key, r, visible)); + } let preview_key = raw_key ^ display_key; // The Navigator may draw this texture only while it holds the plain document (no live // preview, which can be composited only where the view is). @@ -1635,6 +1650,10 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); } + if let Some(r) = crate::adjust_preview::live_uncovered(app, id, raw_key, visible) { + let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); + app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + } return true; } // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5adfd78860..b5c978208e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -709,6 +709,9 @@ pub struct PhotocraftApp { pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. pub(crate) adjust_preview: Option, + /// The Properties panel's live adjustment-layer drag: its damage region and coverage + /// (`adjust_preview::live_key`). + pub(crate) live_adjust_preview: Option, /// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame. pub(crate) synthetic: Vec, /// True only while a frame is processing synthetic automation input. It @@ -916,6 +919,7 @@ impl PhotocraftApp { redraws: Default::default(), color_range: None, adjust_preview: None, + live_adjust_preview: None, synthetic: Vec::new(), automation_input: false, channel_thumbs: None, From a465fd2b1c9a1dd3867c8499168553c4796fae5d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 19:06:54 -0700 Subject: [PATCH 0521/1452] A second launch that sets only environment overrides keeps its own process PHOTOCRAFT_PRESENT_MODE, PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS and the two automation roots are documented as the equivalent of the per-launch flags, and a per-launch flag makes a launch keep its own process. A launch that sets only variables left launch_options empty, so it handed its files to the running instance (or merely raised its window) and exited before any override was read. Those variables now count as the flags they stand for; empty and 0 mean off, as with PHOTOCRAFT_NEW_INSTANCE. Test: each variable maps to its flag, a value that means off maps to nothing, and skip_reason names the flag. --- apps/photocraft/src/main.rs | 4 ++ apps/photocraft/src/single_instance.rs | 53 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 05355e55bf..8d51aa651f 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -369,6 +369,10 @@ fn main() -> eframe::Result { #[cfg(not(target_os = "macos"))] let single_instance = { let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); + // A variable documented as the equivalent of a per-launch flag counts as that flag: a + // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it + // keeps its own process instead of handing its files over and being ignored. + launch_options.extend_from_slice(&single_instance::env_launch_options()); let context = single_instance::LaunchContext { per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, new_instance_flag: new_instance, diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index d2cd65c678..d607e231e0 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -82,10 +82,35 @@ pub struct LaunchContext<'a> { pub control: bool, /// Paths that aren't valid Unicode: this launch reports them. pub unreadable_paths: bool, - /// Options that only apply to the process they're given to (`--safe-gpu`, …). + /// Options that only apply to the process they're given to (`--safe-gpu`, …), including the + /// ones this launch's environment sets (see [`env_launch_options`]). pub launch_options: &'a [&'static str], } +/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for +/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`). Setting one asks for the same thing as giving +/// the flag, so it belongs to this process alone. +const ENV_LAUNCH_OPTIONS: [(&str, &str); 6] = [ + ("PHOTOCRAFT_PRESENT_MODE", "--present-mode"), + ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency"), + ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace"), + ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus"), + ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root"), + ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root"), +]; + +/// The per-process options this launch's environment sets, named as the flags they stand for. A +/// launch that sets only variables would otherwise hand its files to the running instance, which +/// never sees the override, and exit. Empty and `0` mean off, as with `PHOTOCRAFT_NEW_INSTANCE`. +pub fn env_launch_options() -> Vec<&'static str> { + env_launch_options_from(|name| std::env::var_os(name)) +} + +/// [`env_launch_options`] with the reader given, so the rule is testable without the environment. +fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec<&'static str> { + ENV_LAUNCH_OPTIONS.iter().filter(|(name, _)| get(name).is_some_and(|v| !v.is_empty() && v != "0")).map(|(_, flag)| *flag).collect() +} + /// Why this launch runs as its own instance, or `None` to take part in single-instance. pub fn skip_reason(c: &LaunchContext) -> Option { if !c.per_user_settings { @@ -645,4 +670,30 @@ mod tests { let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); } + + /// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch + /// that sets only variables has per-process options, so it keeps its own process rather than + /// handing its files to the running instance, which never sees the override. + #[test] + fn an_environment_override_counts_as_a_process_option() { + let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into())); + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]); + assert_eq!( + with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]), + vec!["--stroke-trace", "--automation-write-root"] + ); + // Nothing set, or a value that means off (`0`, empty), is an ordinary launch. + assert!(with(&[]).is_empty()); + assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); + let base = LaunchContext { + per_user_settings: true, + new_instance_flag: false, + new_instance_env: None, + control: false, + unreadable_paths: false, + launch_options: &[], + }; + let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base }); + assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}"); + } } From a52f458702f94b0e681422cbf91aedbbac033627 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 19:06:54 -0700 Subject: [PATCH 0522/1452] Layer Export As: the dialog opens with the layer's size, not the document's open seeds width and height from the document, and open_layer replaced only the internal __w/__h, so the dialog showed and edited the document's size while the export used the layer's trimmed pixels: a 10x6 layer in a 40x30 document opened at 40x30, and editing one side started from the wrong value. In scale mode the fields now follow the layer's trimmed size; a remembered exact pixel size stays, because that is a size the user asked for. Test: a 10x6 layer in a 40x30 document opens at 10x6, a remembered 50 percent at 5x3, and remembered pixels are untouched. --- crates/ui-egui/src/export_dialog.rs | 44 ++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 275221e050..05ed0d38f5 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -112,10 +112,19 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?; let id = open(app)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { - d.fields.insert("__layer".into(), json!(layer.0)); - d.fields.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); - d.fields.insert("__w".into(), json!(ldoc.size.width)); - d.fields.insert("__h".into(), json!(ldoc.size.height)); + let f = &mut d.fields; + f.insert("__layer".into(), json!(layer.0)); + f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); + f.insert("__w".into(), json!(ldoc.size.width)); + f.insert("__h".into(), json!(ldoc.size.height)); + // `open` seeded width and height from the document; in scale mode they follow the layer's + // trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels + // (sizing "pixels") are a size the user asked for, so those stay. + if s(f, "sizing") != "pixels" + && let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0)) + { + write_size(f, size, "scale"); + } } Ok(id) } @@ -985,6 +994,33 @@ mod tests { assert_eq!(f["metadata"], "none"); } + /// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the + /// size fields from the document, and the layer replaces them in scale mode. A remembered + /// percent scales the layer; remembered exact pixels stay the size the user asked for. + #[test] + fn layer_export_as_opens_with_the_layer_size() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 4, "y": 2, "width": 10, "height": 6})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["__w"].as_f64(), f["__h"].as_f64()), (Some(10.0), Some(6.0))); + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(10.0), Some(6.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"scale": 50}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0))); + } + /// Only the dialog's own settings are remembered: not the destination, the document size or /// the layer the dialog was opened with. #[test] From 5c48fa987d72b2e08171099eb4ffda6c96bf5ada Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 19:06:54 -0700 Subject: [PATCH 0523/1452] Save As on Linux: the one file type offered gives the name its extension With a single type offered the portal reports no filter, and the dialog opens on the stem (services::dialog_file_name), so accepting the name as shown returned a bare path: Swatches became a file named "Swatches" instead of "Swatches.aco" for the Swatches and preset-export flows. The sole filter's extensions are now used when the answer names no type this dialog offered; several types with no pick still add nothing, and an extension the user typed stays their own (with_chosen_type keeps one it recognises). Tests: the mock portal answers the bare stem for a single filter and gets back Swatches.aco; chosen_type is also checked directly, so the rule is covered where no dbus-daemon exists to run the portal test. --- apps/photocraft/src/portal_save.rs | 20 ++++++++++++---- apps/photocraft/src/portal_save/tests.rs | 30 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs index 42588e2bc4..d16432193b 100644 --- a/apps/photocraft/src/portal_save.rs +++ b/apps/photocraft/src/portal_save.rs @@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option { Some(PathBuf::from(std::ffi::OsString::from_vec(out))) } +/// Which file type's extensions belong on the name the dialog returned: the one it reported, or, +/// when the dialog offered only one type, that one. It reports no pick then, and the name it was +/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as +/// `Swatches.aco`. `None` when the answer names no type this dialog offered. +fn chosen_type(filters: &[Filter], picked: Option) -> Option { + match picked { + Some(i) if i < filters.len() => Some(i), + None if filters.len() == 1 => Some(0), + _ => None, + } +} + /// Save As on Linux: the portal's dialog, with the name given the extension of the file type /// picked. When that changes the name to one that already exists, the dialog opens again under /// the new name, so the portal asks before replacing it (it asked only about the typed name). @@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul loop { let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label)); - // With one type offered there is nothing to pick, and a typed extension is the user's. - let exts: &[String] = match picked { - Some(i) if req.filters.len() > 1 => &req.filters[i].1, - _ => &[], + // A typed extension is the user's own: `with_chosen_type` keeps one it recognises. + let exts: &[String] = match chosen_type(&req.filters, picked) { + Some(i) => &req.filters[i].1, + None => &[], }; let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); if path == saved.path || !path.exists() { diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..76243c32be 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -230,3 +230,33 @@ fn a_single_file_type_keeps_the_typed_extension() { assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase"))); daemon.kill().ok(); } + +/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and +/// that one type gives it its extension instead of a file named `Swatches` being written. +#[test] +fn a_single_file_type_gives_the_bare_name_its_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") }; + assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco"))); + daemon.kill().ok(); +} + +/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`, +/// so the rule itself is checked here too, which needs none. +#[test] +fn the_type_that_gives_the_extension_is_the_one_picked() { + let one: Vec = vec![("Color Swatches".into(), vec!["aco".into()])]; + // One type offered: the dialog reports no pick, and it was given the stem. + assert_eq!(chosen_type(&one, None), Some(0)); + assert_eq!(chosen_type(&one, Some(0)), Some(0)); + let many: Vec = request("test").filters; + assert_eq!(chosen_type(&many, Some(1)), Some(1)); + // Several types and no pick: nothing to add, as before. + assert_eq!(chosen_type(&many, None), None); + // An answer naming a type this dialog never offered is not a pick either. + assert_eq!(chosen_type(&many, Some(99)), None); +} From 4d9f163c617447074d50f0e39d03125e915ccdf0 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:53:59 -0400 Subject: [PATCH 0524/1452] Linux: Reveal never hands xdg-open a file (#7166) (#7168) Reveal in Finder on Linux opens the linked file's parent with xdg-open, which launches whatever it is given. A link stored as `tool.jar/a.png` names a file as its parent, so Reveal launched that file. Reveal now refuses a parent that is not a folder. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/layer_menu_cmds.rs | 14 ++++++++++++++ crates/engine/src/layer_menu_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 8e033b244e..ab25912296 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -387,10 +387,24 @@ pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { if dry_run { return Ok(json!({"program": program, "args": args})); } + ensure_folder(program, &args)?; spawn(program, &args)?; Ok(json!({"path": path})) } +/// `xdg-open` opens whatever it is given in its default application, so it only gets a folder: a +/// linked path such as `tool.jar/a.png` would otherwise launch `tool.jar` instead of showing it. +fn ensure_folder(program: &str, args: &[String]) -> Result<()> { + if program != "xdg-open" { + return Ok(()); + } + match args.first() { + Some(dir) if std::path::Path::new(dir).is_dir() => Ok(()), + Some(dir) => Err(other(format!("{dir} is not a folder, so it can't be shown in the file manager"))), + None => Err(other("no folder to show in the file manager")), + } +} + /// The file-manager invocation that selects `path` on this platform. pub fn reveal_command(path: &str) -> Result<(&'static str, Vec)> { reveal_command_on(std::env::consts::OS, path) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 72ace605cb..171034876e 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -515,6 +515,26 @@ fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { } } +/// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a +/// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent. +#[test] +fn reveal_on_linux_never_hands_xdg_open_a_file() { + let dir = std::env::temp_dir().join(format!("photocraft-reveal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("tool.jar"); + std::fs::write(&file, b"not a folder").unwrap(); + let (program, args) = reveal_command_on("linux", &file.join("a.png").to_string_lossy()).unwrap(); + assert_eq!(args, vec![file.to_string_lossy().into_owned()]); + let err = ensure_folder(program, &args).unwrap_err(); + assert!(err.to_string().contains("is not a folder"), "{err}"); + let (program, args) = reveal_command_on("linux", &dir.join("a.png").to_string_lossy()).unwrap(); + ensure_folder(program, &args).unwrap(); + assert!(ensure_folder("xdg-open", &[]).is_err()); + // Finder and Explorer select the file itself, whatever it is. + ensure_folder("open", &["-R".into(), "--".into(), file.to_string_lossy().into_owned()]).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); From b5fb2fde62138023becdd6b685f389da3f6cacd3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 20:07:00 -0700 Subject: [PATCH 0525/1452] single_instance: each environment override is off the way it is read --- apps/photocraft/src/single_instance.rs | 67 +++++++++++++++++++++----- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index d607e231e0..a42c2120fd 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -88,27 +88,61 @@ pub struct LaunchContext<'a> { } /// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for -/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`). Setting one asks for the same thing as giving -/// the flag, so it belongs to this process alone. -const ENV_LAUNCH_OPTIONS: [(&str, &str); 6] = [ - ("PHOTOCRAFT_PRESENT_MODE", "--present-mode"), - ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency"), - ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace"), - ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus"), - ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root"), - ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root"), +/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for +/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this +/// process alone. +const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [ + ("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on), + ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on), + ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on), + ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on), + ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set), + ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set), ]; +/// Whether a variable's value asks this launch for its own option. Each rule matches how that +/// variable is read where it takes effect, so a value ignored there keeps no process here either. +type ValueRule = fn(&str) -> bool; + +/// A value `presentation::parse` accepts: it warns and ignores the rest, so an unrecognised +/// `PHOTOCRAFT_PRESENT_MODE` changes nothing and so needs no process of its own. +fn present_mode_is_on(value: &str) -> bool { + !value.trim().is_empty() && crate::presentation::parse(Some(value.trim()), None).0.mode.is_some() +} + +/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`. +fn frame_latency_is_on(value: &str) -> bool { + !value.trim().is_empty() && crate::presentation::parse(None, Some(value.trim())).0.latency.is_some() +} + +/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the +/// rest is a stderr summary or a CSV path. +fn stroke_trace_is_on(value: &str) -> bool { + !matches!(value.trim(), "" | "0" | "off" | "false") +} + +/// Any value except empty and `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is why +/// `off` means on there, and why this rule is not shared with the stroke trace above. +fn flag_is_on(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && value != "0" +} + +/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. +fn path_is_set(value: &str) -> bool { + !value.is_empty() +} + /// The per-process options this launch's environment sets, named as the flags they stand for. A /// launch that sets only variables would otherwise hand its files to the running instance, which -/// never sees the override, and exit. Empty and `0` mean off, as with `PHOTOCRAFT_NEW_INSTANCE`. +/// never sees the override, and exit. pub fn env_launch_options() -> Vec<&'static str> { env_launch_options_from(|name| std::env::var_os(name)) } /// [`env_launch_options`] with the reader given, so the rule is testable without the environment. fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec<&'static str> { - ENV_LAUNCH_OPTIONS.iter().filter(|(name, _)| get(name).is_some_and(|v| !v.is_empty() && v != "0")).map(|(_, flag)| *flag).collect() + ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect() } /// Why this launch runs as its own instance, or `None` to take part in single-instance. @@ -682,9 +716,18 @@ mod tests { with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]), vec!["--stroke-trace", "--automation-write-root"] ); - // Nothing set, or a value that means off (`0`, empty), is an ordinary launch. + // Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way + // it is read where it takes effect, not by one rule shared across all six. assert!(with(&[]).is_empty()); assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); + // The trace is off at `off` and `false` as well, and a value the presentation parser rejects + // is ignored there, so neither keeps a process of its own here. + assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty()); + assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty()); + // Spaces are not part of a value, except in a path, where they can be. + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]); + // `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too. + assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]); let base = LaunchContext { per_user_settings: true, new_instance_flag: false, From d3d1506e13e37fd363304a5fa4f2337dbfe7770d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 20:16:25 -0700 Subject: [PATCH 0526/1452] single_instance: the menu override is on and off exactly as main.rs reads it --- apps/photocraft/src/single_instance.rs | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index a42c2120fd..ad893bd58a 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -104,31 +104,33 @@ const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [ /// variable is read where it takes effect, so a value ignored there keeps no process here either. type ValueRule = fn(&str) -> bool; -/// A value `presentation::parse` accepts: it warns and ignores the rest, so an unrecognised -/// `PHOTOCRAFT_PRESENT_MODE` changes nothing and so needs no process of its own. +/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and +/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE` +/// changes nothing there and so needs no process of its own here. fn present_mode_is_on(value: &str) -> bool { - !value.trim().is_empty() && crate::presentation::parse(Some(value.trim()), None).0.mode.is_some() + crate::presentation::parse(Some(value), None).0.mode.is_some() } /// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`. fn frame_latency_is_on(value: &str) -> bool { - !value.trim().is_empty() && crate::presentation::parse(None, Some(value.trim())).0.latency.is_some() + crate::presentation::parse(None, Some(value)).0.latency.is_some() } /// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the -/// rest is a stderr summary or a CSV path. +/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here. fn stroke_trace_is_on(value: &str) -> bool { !matches!(value.trim(), "" | "0" | "off" | "false") } -/// Any value except empty and `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is why -/// `off` means on there, and why this rule is not shared with the stroke trace above. +/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is +/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does +/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too. fn flag_is_on(value: &str) -> bool { - let value = value.trim(); !value.is_empty() && value != "0" } -/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. +/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty +/// root is read as no root unless `--control` is given, and that flag keeps its own process already. fn path_is_set(value: &str) -> bool { !value.is_empty() } @@ -724,8 +726,15 @@ mod tests { // is ignored there, so neither keeps a process of its own here. assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty()); assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty()); - // Spaces are not part of a value, except in a path, where they can be. + // Spaces are part of the value only where the reader keeps them: the trace and the two + // presentation values are trimmed where they take effect, the menu flag and a path are not. assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]); + assert_eq!( + with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]), + vec!["--in-window-menus", "--automation-write-root"] + ); + // A whitespace-only menu value is not empty, so it enables the menus there and here too. + assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]); // `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too. assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]); let base = LaunchContext { From 9ff27ec204b60015729f934cdf8c6426c958442f Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:51:23 -0400 Subject: [PATCH 0527/1452] =?UTF-8?q?perf(stack):=20one=20loop=20per=20sta?= =?UTF-8?q?tistic,=201.3=E2=80=932.2=C3=97=20faster=20on=20one=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6462 left Mean, Variance, Standard Deviation, Skewness and Kurtosis 5–15% slower than the old serial loop on one thread. The cause: `stat` matched on the mode three times per pixel inside one shared loop. `combine` now picks the statistic once and hands `combine_with` a closure with a constant mode, so each statistic gets its own loop with the match folded away. The sample buffers are sized to the layer count and filled by index instead of `push`, which re-checked the capacity per sample. Every output is bit for bit the same (121 hashes match main). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw --- crates/algo/src/stack.rs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 512e169c54..dbb5b22e65 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -141,6 +141,27 @@ const CHUNK: usize = 4096; /// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float /// range; quantisation/clipping belongs to the destination surface, not the statistic. pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { + // Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so + // the compiler builds one loop per statistic with the `match` folded away. Matching on `s` + // three times per pixel inside one shared loop made one thread 5–15% slower than the old + // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's + // instructions by a fifth. + match s { + Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), + Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), + Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), + Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)), + Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)), + Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)), + Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)), + Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)), + Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)), + Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)), + } +} + +fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> { let len = layers.iter().map(Vec::len).max().unwrap_or(0); let mut out = vec![[0.0f32; 4]; len]; // Every output pixel depends only on the input pixels at the same index, so chunks of the @@ -150,22 +171,24 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| { // Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused // across pixels, so keep that, and give each task its own set. - let mut samples: [Vec; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len())); + // Sized to the layer count up front and filled by index (`k` samples per pixel): a + // `push` per sample re-checked the capacity and stored the length every time; indexing + // cuts the loop's instructions by another third. + let mut samples: [Vec; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]); let mut scratch = EntropyScratch::new(); let base = chunk * CHUNK; for (j, o) in outs.iter_mut().enumerate() { let i = base + j; - for v in samples.iter_mut() { - v.clear(); - } + let mut k = 0; let mut alpha = 0.0f32; for l in layers { if let Some(p) = l.get(i) && p[3] > 0.0 { for c in 0..3 { - samples[c].push(p[c]); + samples[c][k] = p[c]; } + k += 1; alpha = alpha.max(p[3]); } } @@ -173,7 +196,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { continue; } for c in 0..3 { - o[c] = stat(&mut samples[c], s, &mut scratch); + o[c] = f(&mut samples[c][..k], &mut scratch); } o[3] = alpha; } From 781a842d890ee13a8ae79ee5a7e258f69b941ee6 Mon Sep 17 00:00:00 2001 From: ahmed0magdy <110637215+ahmed0magdy@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:23:35 +0000 Subject: [PATCH 0528/1452] test(io): the SVG text-transform test shapes with the bundled Inter (#4806) transformed_text_lands_where_resvg_draws_it returned early, and passed without checking anything, when the import found no font: it relied on system fonts, and photocraft_text::CRAFT_FONTS is empty unless the build sets CRAFT_FONTS_DIR. With the system fonts hidden it passed in 0.00 s. It is now a unit test in src/svg.rs that parses through parse_with with a font database holding only the bundled Inter Regular (always compiled in), and renders its resvg reference from the same parse, so both sides use the same face. An empty import is now a failure. It fails with the #5334 fix reverted, and passes with the system fonts hidden. Raised in review of #5334 by tanish2k09. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ crates/io/tests/svg.rs | 51 ------------------------------------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 72b2318675..d5b0f62e91 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -736,6 +736,64 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text + /// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the + /// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the + /// text went off the canvas or kept its untransformed place and size. + /// + /// Shaped with the bundled Inter alone, never a system font: on a machine without fonts (a + /// bare CI image or VM) the test would otherwise have nothing to check, and the drawing and + /// its reference come from the same parse, so both use that face. + #[test] + fn transformed_text_lands_where_resvg_draws_it() { + fn inter() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(photocraft_text::fonts::INTER_REGULAR.to_vec()); + Arc::new(db) + } + assert!(inter().faces().next().is_some(), "the bundled Inter loads"); + let font = r#"font-size="24" font-family="Inter" fill="black""#; + let cases = [ + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + ]; + for c in cases { + let svg = format!(r#"{c}"#); + let parsed = parse_with(svg.as_bytes(), inter).unwrap(); + assert!(!parsed.fontless_text, "{c}"); + let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap(); + resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut()); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let [layer] = r.document.layers.as_slice() else { panic!("one text layer, got {}: {c}", r.document.layers.len()) }; + assert!(matches!(layer.content, LayerContent::Group(_)), "text stays outline shapes: {c}"); + let imported = photocraft_compose::flatten(&r.document); + let (mut both, mut either) = (0usize, 0usize); + let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); + let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; + for (i, px) in reference.pixels().iter().enumerate() { + let (x, y) = ((i % 120) as i32, (i / 120) as i32); + let (a, b) = (imported.get(x, y)[3] > 0.5, px.alpha() > 127); + if a { + grow(&mut a_box, x, y); + } + if b { + grow(&mut b_box, x, y); + } + both += usize::from(a && b); + either += usize::from(a || b); + } + assert!(either > 50, "resvg draws the text on the canvas: {c}"); + // Same place and size to the pixel; the outlines are filled by a different rasteriser, + // so the edges of thin stems may differ by half a pixel. + assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); + assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); + } + } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of /// two shapes is still rasterised, with the warning. diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index 4f21bd5dcb..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -221,57 +221,6 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() { assert!(dark > 4, "glyph strokes cross the middle row: {dark}"); } -/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text -/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the -/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the -/// text went off the canvas or kept its untransformed place and size. -#[test] -fn transformed_text_lands_where_resvg_draws_it() { - let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\""; - let cases = [ - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - ]; - for c in cases { - let svg = format!("{c}"); - let r = open(&svg); - let d = &r.document; - if d.layers.is_empty() { - // No font at all on this machine: the import says so instead. - assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); - return; - } - assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}"); - let imported = photocraft_compose::flatten(d); - let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap(); - let (mut both, mut either) = (0usize, 0usize); - let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); - let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; - for y in 0..60 { - for x in 0..120 { - let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5); - if a { - grow(&mut a_box, x, y); - } - if b { - grow(&mut b_box, x, y); - } - both += usize::from(a && b); - either += usize::from(a || b); - } - } - assert!(either > 50, "resvg draws the text on the canvas: {c}"); - // Same place and size to the pixel; the outlines are filled by a different rasteriser, so - // the edges of thin stems may differ by half a pixel. - assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); - assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); - } -} - #[test] fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() { let r = open(&format!("")); From b484a2edb936c6047236515652890fcc03a91897 Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 08:36:25 +0300 Subject: [PATCH 0529/1452] fix(atn): reject numeric layer references in .atn export (#7248) --- crates/engine/src/atn_cmds.rs | 21 +++++++++-------- crates/engine/src/atn_cmds/tests.rs | 36 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..45c61d55aa 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -207,10 +207,11 @@ fn class_ref(cls: &str) -> D { } /// The `null` reference for a command's `layer` param: by name, else the target layer. -fn layer_ref(p: &Value) -> D { +fn layer_ref(p: &Value) -> Option { match p.get("layer") { - Some(Value::String(n)) => name_ref("Lyr ", n), - _ => target_ref("Lyr "), + None | Some(Value::Null) => Some(target_ref("Lyr ")), + Some(Value::String(n)) => Some(name_ref("Lyr ", n)), + _ => None, } } @@ -1143,7 +1144,7 @@ fn make_to(id: &str, p: &Value) -> Option { if jstr(p, "document") != Some("new") { return None; } - let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)?); if let Some(n) = jstr(p, "documentName") { d = d.with("Nm ", text(n)); } @@ -1371,7 +1372,7 @@ fn set_to(id: &str, p: &Value) -> Option { if t.items.is_empty() { return None; } - desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) + desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t)) } "layer.new.layerFromBackground" => { let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); @@ -1633,7 +1634,7 @@ fn transform_to(id: &str, p: &Value) -> Option { return None; } let target = match id { - "edit.transform" => layer_ref(p), + "edit.transform" => layer_ref(p)?, "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), _ => return None, }; @@ -1814,7 +1815,7 @@ fn export_to(_: &str, p: &Value) -> Option { fn delete_to(id: &str, p: &Value) -> Option { Some(match id { "edit.clear" => desc("null"), - "layer.delete" => desc("null").with("null", layer_ref(p)), + "layer.delete" => desc("null").with("null", layer_ref(p)?), "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), _ => return None, }) @@ -1838,7 +1839,7 @@ fn visibility_from(d: &Descriptor, id: &str) -> Mapped { } fn visibility_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", D::List(vec![layer_ref(p)]))) + Some(desc("null").with("null", D::List(vec![layer_ref(p)?]))) } fn move_from(d: &Descriptor) -> Mapped { @@ -1904,7 +1905,7 @@ fn move_to(id: &str, p: &Value) -> Option { "layer.arrange.sendBackward" => ord("Prvs"), _ => return None, }; - Some(desc("null").with("null", layer_ref(p)).with("T ", t)) + Some(desc("null").with("null", layer_ref(p)?).with("T ", t)) } fn duplicate_from(d: &Descriptor) -> Mapped { @@ -1942,7 +1943,7 @@ fn duplicate_to(id: &str, p: &Value) -> Option { return None; } let mut d = match id { - "layer.duplicate" => desc("null").with("null", layer_ref(p)), + "layer.duplicate" => desc("null").with("null", layer_ref(p)?), "image.duplicate" => { desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Frst") }])) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..c21ae6590c 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -956,3 +956,39 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } + +#[test] +fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + + // Named layer reference exports successfully + s.actions.list.push(Action::new("DeleteNamed", vec![("layer.delete".into(), json!({"layer": "A"}))])); + let saved_named = s.execute("actions.save", json!({"action": "DeleteNamed"})).unwrap(); + assert_eq!(saved_named["steps"], 1); + + // Omitted layer reference (target selection) exports successfully + s.actions.list.push(Action::new("DeleteTarget", vec![("layer.delete".into(), json!({}))])); + let saved_target = s.execute("actions.save", json!({"action": "DeleteTarget"})).unwrap(); + assert_eq!(saved_target["steps"], 1); + + // Explicit numeric ID reference cannot be silently converted to current target in strict export + s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); + let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"})); + assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target"); + + // Same for layer.duplicate with numeric ID + s.actions.list.push(Action::new("DuplicateNumeric", vec![("layer.duplicate".into(), json!({"layer": 42, "name": "Copy"}))])); + let err_dup = s.execute("actions.save", json!({"action": "DuplicateNumeric"})); + assert!(err_dup.is_err(), "strict export must reject numeric layer ID for layer.duplicate rather than falling back to current target"); + + // Same for layer.translate with numeric ID + s.actions.list.push(Action::new("TranslateNumeric", vec![("layer.translate".into(), json!({"layer": 42, "dx": 5, "dy": 5}))])); + let err_trans = s.execute("actions.save", json!({"action": "TranslateNumeric"})); + assert!(err_trans.is_err(), "strict export must reject numeric layer ID for layer.translate rather than falling back to current target"); + + // With skipUnsupported: true, numeric layer references are skipped + let skipped = s.execute("actions.save", json!({"action": "DeleteNumeric", "skipUnsupported": true})).unwrap(); + assert_eq!(skipped["steps"], 0); + assert_eq!(skipped["skipped"][0]["id"], "layer.delete"); +} From 33398aeb1cade5b10c727c9193e5f6e47c37182c Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 08:44:16 +0300 Subject: [PATCH 0530/1452] fix(ui): allow collapsing brush folders while searching (storytold/photocraft#7257) Search no longer forces groups and nested folders open; collapsed state is honored during filter. Co-authored-by: Cursor --- crates/ui-egui/src/brush_panel_tests.rs | 25 +++++++++++++++++++++++-- crates/ui-egui/src/brushes_tab.rs | 7 +++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 670f442be2..9152c260cb 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -825,11 +825,32 @@ fn brushes_panel_groups_collapse_and_filter() { let n = brush_preview::render_count(&ctx); assert_eq!(shown(&mut h), all); assert_eq!(brush_preview::render_count(&ctx), n); - // A filter shows matching presets even in collapsed groups. + // Search still respects collapsed groups (#7257). let first = h.state().session.tools.presets[0].name.clone(); h.state_mut().ui.brushes_panel.filter = first.to_uppercase(); h.run_steps(2); - assert!(brush_preview::render_count(&ctx) >= n); + assert_eq!(shown(&mut h), all); + assert_eq!(brush_preview::render_count(&ctx), n); +} + +/// #7257: with a search filter active, nested folders that contain matches can still be closed. +#[test] +fn brush_search_allows_collapsing_nested_folders() { + use egui_kittest::kittest::Queryable; + let mut h = harness(1, 0); + let builtin_groups: Vec = grouped_presets(&h.state().session.tools.presets).into_iter().map(|(g, _)| g).collect(); + h.state_mut().ui.brushes_panel.collapsed = builtin_groups; + for p in [kit("Kit Top", &[]), kit("Kit Pen", &["Test Inks"]), kit("Kit Nib", &["Test Inks", "Test Fine"])] { + h.state_mut().session.tools.presets.push(p); + } + h.state_mut().ui.brushes_panel.filter = "Kit".into(); + h.run_steps(3); + assert!(h.query_by_label("Kit Nib").is_some(), "search shows nested matches"); + h.get_by_label("Test Inks").click(); + h.run_steps(3); + assert_eq!(h.state().ui.brushes_panel.collapsed.last().map(String::as_str), Some("Kit/Test Inks")); + assert!(h.query_by_label("Kit Nib").is_none() && h.query_by_label("Test Fine").is_none()); + assert!(h.query_by_label("Kit Pen").is_none()); } fn kit(name: &str, folder: &[&str]) -> paint::BrushPreset { diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 3f45526e52..b2fefa25a5 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -732,7 +732,6 @@ struct Draw<'a> { /// The selected preset's name (by identity, so look-alike duplicates stay distinct). current: Option<&'a str>, collapsed: &'a [String], - filtering: bool, grid: bool, layout: ListLayout, /// The picker cards' parts — name, stroke, tip — from the gear's boxes. @@ -824,7 +823,7 @@ fn draw_nodes(ui: &mut egui::Ui, d: &Draw, nodes: &[Node], depth: usize, acts: & Node::Preset(p) => run.push(p), Node::Folder(f) => { flush(ui, &mut run, acts); - let open = d.filtering || !d.collapsed.contains(&folder_view_key(d.label, &f.path)); + let open = !d.collapsed.contains(&folder_view_key(d.label, &f.path)); group_header(ui, d.label, d.key, &f.path, open, f.count, acts); if open && depth < MAX_VIEW_DEPTH { draw_nodes(ui, d, &f.children, depth + 1, acts); @@ -902,12 +901,12 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& continue; } let key = group_key(presets, &label); - let open = !filter.is_empty() || !collapsed.contains(&label); + let open = !collapsed.contains(&label); group_header(ui, &label, &key, &[], open, items.len(), &mut acts); if !open { continue; } - let d = Draw { label: &label, key: &key, presets, current, collapsed, filtering: !filter.is_empty(), grid, layout, show, scale: st.scale }; + let d = Draw { label: &label, key: &key, presets, current, collapsed, grid, layout, show, scale: st.scale }; draw_nodes(ui, &d, &folder_tree(&items), 0, &mut acts); ui.add_space(2.0); } From 4b88e94f9101f334a745ddb5a0d12e792162b7a5 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 09:04:38 +0300 Subject: [PATCH 0531/1452] macOS: keep bundled Dock icon after eframe loads (storytold/photocraft#7253) Skipping with_icon on .app bundles let eframe substitute its default glyph once the window opened; pass IconData::default() so the bundle .icns stays in the Dock. Co-authored-by: Cursor --- apps/photocraft/src/app_icon.rs | 24 ++++++++++++++++++++++++ apps/photocraft/src/main.rs | 4 +--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index 54eea2d6b5..142c47b9dc 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -34,6 +34,17 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle)) } +/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`] +/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon +/// on the Dock after the window opens (#7253). +pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData { + if sets_runtime_icon(exe) { + window_icon() + } else { + egui::IconData::default() + } +} + /// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. fn in_app_bundle(exe: &std::path::Path) -> bool { let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); @@ -80,6 +91,19 @@ mod tests { assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos")); } + /// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded. + #[test] + fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() { + use std::path::Path; + let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")); + if sets_runtime_icon(bundled) { + let icon = viewport_icon(bundled); + assert!(icon.width >= 256 && icon.width == icon.height); + } else { + assert_eq!(viewport_icon(bundled), egui::IconData::default()); + } + } + #[test] fn window_icon_decodes() { let icon = window_icon(); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 05355e55bf..f3bf625924 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -103,9 +103,7 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// different offset every launch (#419). A later launch restores the saved position instead /// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { - let viewport = egui::ViewportBuilder::default(); - // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849). - let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport }; + let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref())); eframe::NativeOptions { viewport: viewport .with_app_id(APP_ID) From 0f12ac151fa2e6eaedf3c322feb4eb0d49cff905 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:19:41 +0300 Subject: [PATCH 0532/1452] fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) --- crates/engine/src/edit_menu_cmds.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index da9a4d0482..8abc4bfd28 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -719,6 +719,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & let cancel = || ctx.cancelled(); let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?; let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan; + // Patches come only from the sampling area outside the fill area. With none (a + // Rectangular `margin` of 0, a Custom area inside the selection or off the canvas) + // the fill would be black (#6532), so refuse instead. The workspace's preview builds + // the same plan and may show an empty area while the Sampling Brush adds to it. + if !source.iter().any(|s| *s) { + return Err(bad(cmd, "nothing to sample from: the sampling area has no pixels outside the fill area")); + } let (w, h) = (rect.width() as usize, rect.height() as usize); let n = fmt.channels(); // The fill changes only the fill area: keep the layer's pixels and the coverage over From 94b0219f179a5ad633ed8c9f47bb94255ee50f5e Mon Sep 17 00:00:00 2001 From: langrain Date: Sun, 18 Oct 2026 06:24:11 +0000 Subject: [PATCH 0533/1452] Fix: add missing Chinese translations for Up and To This PR fixes the missing Chinese translations for "Up" (in the Direction panel) and "To" (in the Gradient Overlay panel). --- crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f975c957e..77fb9146c4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1848,9 +1848,11 @@ Tabloid Tabloid 纸 Text Replacement 文字替换 Thin 纤细 + To 到 Traditional (72.27 points/inch) 传统(72.27 点/英寸) Transfer 传递 Triangle Tool 三角形工具 + Up 向上 Unsaved changes 未保存的更改 Uses the foreground and background colours. 使用前景色和背景色。 Watercolour look: lighter interior, darker rims. 水彩效果:内部较浅,边缘较深。 From fc298ebf12c646403fad839f718476ecb755d638 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 09:21:49 +0300 Subject: [PATCH 0534/1452] fix(ui): record Shift in Keyboard Shortcuts capture (storytold/photocraft#7190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track modifier keys across capture frames and walk events so ⇧/⌃/⌥ count even when egui reports no modifier bits on them. Co-authored-by: Cursor --- crates/ui-egui/src/prefs_ui.rs | 75 ++++++++++++++++++-- crates/ui-egui/src/shortcut_capture_tests.rs | 15 ++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6c910865bd..b86c459f75 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -859,6 +859,67 @@ pub fn is_modifier_key(key: egui::Key) -> bool { matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight) } +/// Modifier bits a physical modifier-key press contributes when egui leaves them out of +/// [`Modifiers`] on the key event (#7190). +fn modifier_key_modifiers(key: egui::Key) -> egui::Modifiers { + use egui::Key::*; + match key { + ShiftLeft | ShiftRight => egui::Modifiers::SHIFT, + ControlLeft | ControlRight => egui::Modifiers::CTRL, + AltLeft | AltRight => egui::Modifiers::ALT, + SuperLeft | SuperRight => egui::Modifiers::COMMAND, + _ => egui::Modifiers::NONE, + } +} + +fn capture_mods_field(v: Option<&Value>) -> egui::Modifiers { + let o = v.and_then(Value::as_object); + egui::Modifiers { + shift: o.and_then(|o| o.get("shift")).and_then(Value::as_bool).unwrap_or(false), + ctrl: o.and_then(|o| o.get("ctrl")).and_then(Value::as_bool).unwrap_or(false), + alt: o.and_then(|o| o.get("alt")).and_then(Value::as_bool).unwrap_or(false), + command: o.and_then(|o| o.get("command")).and_then(Value::as_bool).unwrap_or(false), + mac_cmd: o.and_then(|o| o.get("mac_cmd")).and_then(Value::as_bool).unwrap_or(false), + } +} + +fn capture_mods_json(m: egui::Modifiers) -> Value { + json!({ + "shift": m.shift, + "ctrl": m.ctrl, + "alt": m.alt, + "command": m.command, + "mac_cmd": m.mac_cmd, + }) +} + +/// While capture is active: refresh the held modifiers (including across frames) and, if a +/// non-modifier key was pressed this frame, return it with the modifiers that held with it. +/// Modifier-key presses count even when egui reports no modifier bits on them (#7190). +fn capture_key_press(capture_mods: egui::Modifiers, i: &egui::InputState) -> (egui::Modifiers, Option<(egui::Key, egui::Modifiers)>) { + let mut mods = capture_mods | i.modifiers; + let mut pressed = None; + for e in &i.events { + if let egui::Event::ModifiersChanged(m) = e { + mods = *m; + } + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + if is_modifier_key(*key) { + mods = *modifiers | modifier_key_modifiers(*key); + continue; + } + pressed = Some((*key, mods | *modifiers)); + break; + } + if let egui::Event::Key { key, pressed: false, modifiers, .. } = e { + if is_modifier_key(*key) { + mods = *modifiers; + } + } + } + (mods, pressed) +} + // ------------------------------------------------------------------ menu routing /// Shell front ends for Edit-menu commands invoked without parameters (dialogs, pickers). @@ -1767,14 +1828,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Some((*key, *modifiers)), - _ => None, - }) - }); + let (next_mods, pressed) = ui.input(|i| capture_key_press(capture_mods, i)); + capture_mods = next_mods; if let Some((key, m)) = pressed { ui.input_mut(|i| { i.consume_key(m, key); @@ -2054,6 +2112,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, button: &str, modifier: Key, held h.run_steps(2); } +#[test] +fn shift_is_recorded_when_modifier_key_events_carry_no_bits() { + // #7190: some platforms report ShiftLeft/ShiftRight with Modifiers::NONE; the real key can + // too, so capture must walk the frame's events instead of trusting one Key event. + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + h.get_by_label(&crate::shortcuts::pretty("Cmd+F")).click(); + h.run_steps(2); + key(&mut h, Key::ShiftLeft, true, Modifiers::NONE); + h.run_steps(1); + key(&mut h, Key::F6, true, Modifiers::NONE); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Shift+F6")); + assert_eq!(field(&h, id, "capture"), json!(false)); +} + #[test] fn ctrl_k_is_recorded_as_ctrl_k_not_the_control_key() { for ppp in [1.0, 2.0] { From 36d6b669d40d1f86118c6e92831f4cd7dc73f5f8 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:19:41 +0300 Subject: [PATCH 0535/1452] test(engine): Content-Aware Fill with an empty sampling area is refused and changes nothing (#6532) --- crates/engine/src/edit_menu_cmds/tests.rs | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index bc274795a8..dc32b7c8f5 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -105,10 +105,15 @@ fn blob_session(depth: u32) -> Session { fn content_aware_fill_huge_area_neither_panics_nor_wraps() { let mut s = blob_session(8); // `[1e30, 0, 1e30, 10]` overflowed the i32 additions in the area parser (a debug-build - // panic, a garbage sampling window in release); the additions saturate now, so this is - // simply a whole-canvas window. - let r = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap(); - assert!(r["filled"].as_u64().unwrap() > 100); + // panic, a garbage sampling window in release); the additions saturate now, so the area lies + // wholly right of the canvas: nothing to sample, which is an error, not a black fill (#6532). + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap_err(); + assert!(err.to_string().contains("nothing to sample from"), "{err}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "the document changed"); + assert_eq!(st.history.past_len(), past, "a history step was recorded"); // A malformed area names the problem instead of silently falling back. for area in [json!([0.0, 0.0, null, 10.0]), json!([0.0, 0.0, 10.0]), json!("nope")] { let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": area})).unwrap_err(); @@ -312,6 +317,23 @@ fn content_aware_fill_takes_the_workspace_edits() { assert!(r["filled"].as_u64().unwrap() > 2800); } +/// #6532: a sampling area with no pixels outside the fill area is refused instead of filling it +/// black, and leaves the document untouched; any margin past the selection fills. +#[test] +fn content_aware_fill_with_nothing_to_sample_is_refused() { + let mut s = blob_session(8); + let before = px(&s, 30, 22); + let revision = s.active().unwrap().revision; + for p in [json!({"sampling": "rectangular", "margin": 0}), json!({"sampling": "custom", "area": [28, 18, 4, 4]})] { + let e = s.execute("edit.contentAwareFill", p.clone()).unwrap_err().to_string(); + assert!(e.contains("nothing to sample from"), "{p}: {e}"); + assert_eq!(px(&s, 30, 22), before, "{p}: nothing was filled"); + assert_eq!(s.active().unwrap().revision, revision, "{p}: no history step"); + } + s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "margin": 1})).unwrap(); + assert_ne!(px(&s, 30, 22), before, "the red blob is filled from around the selection"); +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); From ab668a1dffede33b692474dc71a14761cfecf404 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 10:05:32 +0300 Subject: [PATCH 0536/1452] Freeform Pen: constrain freehand drags with Shift (#7171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Shift while drawing a freeform path segment keeps points on a straight 0°/45°/90° line, reusing the painting-tool stroke constraint. Co-authored-by: Cursor --- crates/ui-egui/src/lasso_ui.rs | 19 ++++++++++++++++++- crates/ui-egui/src/lasso_ui/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/stroke_constraint.rs | 5 +++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 1f3f7adba2..2774368bd4 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -197,12 +197,29 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Move { .. } | ToolEvent::Up { .. } => { let up = matches!(ev, ToolEvent::Up { .. }); + let zoom = app.point_zoom(); let Some(d) = app.drag.as_mut().filter(|d| d.tool == tool) else { return true }; + d.track(mods); let Some(lasso) = &d.lasso else { return true }; let (down, polygonal, previous) = (lasso.down, lasso.polygonal, lasso.cursor); + let p = if down && tool == Tool::FreeformPen { + let last = d.points.last().map_or(d.start, |pt| [pt[0], pt[1]]); + let [x, y] = crate::stroke_constraint::constrain( + d.tool, + &mut d.constrain, + d.start, + last, + p, + mods.shift, + zoom, + ); + [x, y] + } else { + p + }; if d.reposition { // Move relative to the rubber-band endpoint, not the last fixed vertex. - let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let last = d.points.last().map_or(d.start, |pt| [pt[0], pt[1]]); d.shift_to([last[0] + p[0] - previous[0], last[1] + p[1] - previous[1]]); } else if down { // While the button is down the outline follows the pointer (freehand), Alt or diff --git a/crates/ui-egui/src/lasso_ui/tests.rs b/crates/ui-egui/src/lasso_ui/tests.rs index 6484ed03c7..71a5b68777 100644 --- a/crates/ui-egui/src/lasso_ui/tests.rs +++ b/crates/ui-egui/src/lasso_ui/tests.rs @@ -306,6 +306,21 @@ fn clicking_the_last_point_again_closes_the_outline() { assert!(app.session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(150, 100, 0) > 0.9); } +#[test] +fn shift_drag_constrains_freeform_pen_to_a_straight_line() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let shift = Modifiers::SHIFT; + event(&mut app, "down", 10.0, 60.0, shift); + for (x, y) in [(30.0, 63.0), (50.0, 55.0), (80.0, 68.0), (100.0, 52.0)] { + event(&mut app, "move", x, y, shift); + } + let pts = &app.drag.as_ref().unwrap().points; + assert!(pts.len() >= 2, "{pts:?}"); + let y0 = pts[0][1]; + assert!(pts.iter().all(|p| (p[1] - y0).abs() < 1e-6), "⇧ keeps the freehand segment horizontal: {pts:?}"); +} + /// ⌘ (Ctrl) with the Lasso is the Move tool for the drag: through the real canvas, a ⌘-drag outside /// the selection (here: without one) moves the layer and draws no outline (`canvas::command_moves_layer`). #[test] diff --git a/crates/ui-egui/src/stroke_constraint.rs b/crates/ui-egui/src/stroke_constraint.rs index a7cd51cb93..51760cc6cb 100644 --- a/crates/ui-egui/src/stroke_constraint.rs +++ b/crates/ui-egui/src/stroke_constraint.rs @@ -23,9 +23,10 @@ pub struct Axis { pub dir: Option<[f64; 2]>, } -/// Tools whose drag ⇧ constrains: every painting tool and the Gradient tool. +/// Tools whose drag ⇧ constrains: every painting tool, the Gradient tool, and the Freeform Pen +/// (Photoshop's freehand path segment with ⇧, #7171). pub fn applies(tool: Tool) -> bool { - tool.is_brushlike() || tool == Tool::Gradient + tool.is_brushlike() || tool == Tool::Gradient || tool == Tool::FreeformPen } /// Tools that ⇧-click connects to the previous stroke with a straight line. From 363d38511430963c13bba388bf106993dd8427ac Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:40 +0200 Subject: [PATCH 0537/1452] Trap: refuse locked layers (#5623) (#5624) Image > Trap wrote the active layer's pixels without checking locks, so a layer under Lock All or the pixel lock (its own or its group's) was trapped anyway. Check check_pixels_unlocked before writing, like the fill commands. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/trap_cmds.rs | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/trap_cmds.rs b/crates/engine/src/trap_cmds.rs index 03d899472c..6bd99200ac 100644 --- a/crates/engine/src/trap_cmds.rs +++ b/crates/engine/src/trap_cmds.rs @@ -24,6 +24,7 @@ fn trap(s: &mut Session, p: &Value) -> Result { } s.edit("Trap", |doc, active| { let id = active.ok_or(EngineError::NoDocument)?; + crate::commands::check_pixels_unlocked(doc, id)?; let bounds = doc.bounds(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Raster(surf) = &mut l.content else { @@ -63,11 +64,8 @@ mod tests { use super::*; use photocraft_geom::Rect; - #[test] - fn traps_a_cmyk_document() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); - // Paint cyan | magenta halves on the Background. + /// Paints cyan | magenta halves on the active layer. + fn paint_halves(s: &mut Session) { s.edit("paint", |doc, active| { let id = active.unwrap(); let fmt = doc.pixel_format(); @@ -93,6 +91,13 @@ mod tests { Ok(()) }) .unwrap(); + } + + #[test] + fn traps_a_cmyk_document() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + paint_halves(&mut s); let r = s.execute("image.trap", json!({"width": 1})).unwrap(); assert_eq!(r["trapped"], true); @@ -109,6 +114,35 @@ mod tests { assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Trap")); } + #[test] + fn refuses_locked_layers() { + // Lock All or the pixel lock, on the layer itself or on its group: Trap refuses, leaving the + // pixels and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_halves(&mut s); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the layer ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().surface().cloned(); + let r = s.execute("image.trap", json!({"width": 1})); + assert!(r.is_err(), "trapped a layer locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(layer).unwrap().surface().cloned(), before, "{lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{lock} group: {on_group}: no history step"); + } + } + } + #[test] fn rejects_non_cmyk() { let mut s = Session::new(); From 7d166edb2a6a34398fab7793b6e910c6377a5ec3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:45 +0200 Subject: [PATCH 0538/1452] Lens Flare: don't darken 32-bit pixels above 1.0 (#5785) (#5786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screen as 1 - (1 - c)(1 - a) pulls a 32-bit value above white down towards 1.0 (2.0 with a = 0.5 became 1.5, the core became 1.0). Add a·max(1 - c, 0) instead: the same result for c <= 1, unchanged above. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/algo/src/render.rs | 5 +++-- crates/algo/src/tests_ext.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index baba88f93e..d40ff9cda8 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen let mut c = rgba(ctx, px); for k in 0..3 { let a = (f[k] * bright).max(0.0); - // Screen keeps highlights below white for display-referred values. - c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0)); + // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit + // value already above white is left as is rather than pulled down towards it. + c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0); } set_rgba(ctx, px, c); } diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index fd35f2742a..be97a76cbd 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() { assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens"); } +#[test] +fn lens_flare_never_darkens_32_bit_values_above_one() { + // #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p); + for px in out.read_region(R).as_chunks::<4>().0 { + assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}"); + } +} + +#[test] +fn lens_flare_8_bit_result_is_the_screen_blend() { + // The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R); + let src = pattern(SampleType::U8, R); + let base = src.read_region(R); + let expected: Vec = base + .as_chunks::<4>() + .0 + .iter() + .zip(amount.as_chunks::<4>().0) + .flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]])) + .collect(); + let mut want = Surface::new(fmt(SampleType::U8)); + want.write_region(R, &expected); + assert_eq!(run(&src, &p).read_region(R), want.read_region(R)); +} + #[test] fn lighting_point_light_falls_off() { let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]); From f3a9b81b1f24408c65be75ffb7d1b6e64936cecf Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:49 +0200 Subject: [PATCH 0539/1452] Duotone: view and flat export clamp ink density like the Multichannel conversion (#6854) (#6855) Duotone::render used the raw curve output, so ink curves above 100 % pushed the simulated print out of gamut (red + blue at full density showed green instead of black). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/mode.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/mode.rs b/crates/doc/src/mode.rs index 96e129cbee..9722ce5b15 100644 --- a/crates/doc/src/mode.rs +++ b/crates/doc/src/mode.rs @@ -74,7 +74,7 @@ impl Duotone { let density = 1.0 - gray.clamp(0.0, 1.0); let mut out = [1.0f32; 3]; for ink in &self.inks { - let d = curve_eval(&ink.curve, density); + let d = ink.density(density); for (o, c) in out.iter_mut().zip(ink.color) { // Each ink filters light: full density transmits the ink colour. *o *= 1.0 - d * (1.0 - c); @@ -209,6 +209,22 @@ mod tests { assert!(c[0] > c[2], "{c:?}"); } + #[test] + fn duotone_render_clamps_ink_density_like_multichannel() { + // Curves above 100 % (accepted by `image.mode.duotone`) must not push the print out of + // gamut: red + blue at full density is black, not green. + let over = |name: &str, color| { + let mut ink = DuotoneInk::new(name, color); + ink.curve = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 2.0 }]; + ink + }; + let d = Duotone { inks: vec![over("Red", [1.0, 0.0, 0.0]), over("Blue", [0.0, 0.0, 1.0])], psd_raw: None }; + assert_eq!(d.render(0.0), [0.0, 0.0, 0.0]); + for g in [0.0, 0.25, 0.5, 1.0] { + assert!(d.render(g).iter().all(|c| (0.0..=1.0).contains(c)), "{g}: {:?}", d.render(g)); + } + } + #[test] fn stack_mode_ids_roundtrip() { for m in StackMode::ALL { From 13bbf0eefe4f51dca8b504c831fc86a0f86083e9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:54 +0200 Subject: [PATCH 0540/1452] Custom filter: in CMYK a positive offset brightens (#7076) (#7077) Co-authored-by: Claude Opus 5.5 --- crates/algo/src/other2.rs | 10 +++++++--- crates/algo/src/tests_ext.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index b5a16f06ea..61ccfc0038 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -8,7 +8,8 @@ use crate::image::Image; use crate::{Ctx, HsbModel}; /// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha -/// kept). Kernels shorter than 25 entries are zero-padded. +/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are +/// ink, so there it runs on `1 - ink` and a positive offset brightens. pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec { let n = src.ch; let cc = ncol(ctx, n); @@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect(); let s = if scale == 0.0 { 1.0 } else { scale }; let off = offset / 255.0; + let sub = crate::fxutil::subtractive(ctx); let mut res = src.crop(out); if k.is_empty() { return res; @@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f for (i, px) in res.chunks_exact_mut(n).enumerate() { let (x, y) = xy(out, i); for (c, v) in px.iter_mut().enumerate().take(cc) { - let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum(); - *v = sum / s + off; + // CMYK: convolve brightness (1 - ink), then back to ink. + let lum = |v: f32| if sub { 1.0 - v } else { v }; + let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum(); + *v = lum(sum / s + off); } } res diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index be97a76cbd..7c28485f1f 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -637,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() { assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5); } +/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop +/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched. +/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1). +#[test] +fn custom_offset_brightens_cmyk() { + let mut id = vec![0.0; 25]; + id[12] = 1.0; + let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 }; + for depth in [SampleType::U8, SampleType::U16] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]); + let w = run(&s, &p).pixel(5, 5); + assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]); + let c = run(&s, &p).pixel(5, 5); + let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0]; + assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}"); + } +} + #[test] fn hsb_filter_encodes_and_decodes() { let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]); From 8ef5337c29b0bb41d0995fec1f1ff026f5f8faa3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:59 +0200 Subject: [PATCH 0541/1452] Measurement Scale: large pixel lengths print their real value (#7016) (#7017) Co-authored-by: Claude Opus 5.5 --- crates/doc/src/analysis.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/analysis.rs b/crates/doc/src/analysis.rs index 840bf54990..9b1d8efdb0 100644 --- a/crates/doc/src/analysis.rs +++ b/crates/doc/src/analysis.rs @@ -30,14 +30,12 @@ impl MeasurementScale { } /// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`. pub fn describe(&self) -> String { - format!("{} pixels = {:.4} {}", trim(self.pixel_length), self.logical_length, self.units) + // f64 Display already drops a whole number's `.0` (`5`, `0.5`) and, unlike an i64 cast, + // prints lengths past i64::MAX in full. + format!("{} pixels = {:.4} {}", self.pixel_length, self.logical_length, self.units) } } -fn trim(v: f64) -> String { - if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") } -} - /// One Count tool group: its numbered markers (document pixel coordinates, in click order). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -133,6 +131,17 @@ mod tests { assert_eq!(MeasurementScale { pixel_length: 0.0, ..Default::default() }.factor(), 1.0); } + #[test] + fn scale_text_prints_large_and_fractional_pixel_lengths() { + let big = MeasurementScale { pixel_length: 1e20, logical_length: 1.0, units: "mm".into() }; + assert_eq!(big.describe(), "100000000000000000000 pixels = 1.0000 mm"); + assert_eq!(MeasurementScale::default().describe(), "1 pixels = 1.0000 pixels"); + let s = |p: f64| MeasurementScale { pixel_length: p, ..Default::default() }.describe(); + assert_eq!(s(5.0), "5 pixels = 1.0000 pixels"); + assert_eq!(s(0.5), "0.5 pixels = 1.0000 pixels"); + assert_eq!(s(12.25), "12.25 pixels = 1.0000 pixels"); + } + #[test] fn serde_defaults_fill_missing_fields() { let m: Measurement = serde_json::from_str("{}").unwrap(); From d722dd8b5425d08cc5ff7876554c226817e08324 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:07:03 +0200 Subject: [PATCH 0542/1452] Clamp filter.cameraRaw exposure to its documented -5..5 range (#6876) Found while testing crates/algo/src/camera_raw.rs:592 (value taken unchecked in crates/engine/src/lens_cmds.rs:137 raw_params) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/lens_cmds.rs | 3 ++ .../engine/tests/camera_raw_exposure_clamp.rs | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 crates/engine/tests/camera_raw_exposure_clamp.rs diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..9a566fb925 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -147,6 +147,9 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result { cr.noise_luminance_detail = cr.noise_luminance_detail.clamp(0.0, 100.0); cr.noise_color = cr.noise_color.clamp(0.0, 100.0); cr.noise_color_detail = cr.noise_color_detail.clamp(0.0, 100.0); + // `exposure` becomes `2^exposure`, which overflows f32 to infinity from 128 up (and then + // writes inf/NaN into 32-bit layers). Clamp to the documented -5..5. + cr.exposure = cr.exposure.clamp(-5.0, 5.0); Ok(cr) } diff --git a/crates/engine/tests/camera_raw_exposure_clamp.rs b/crates/engine/tests/camera_raw_exposure_clamp.rs new file mode 100644 index 0000000000..ee624f7e28 --- /dev/null +++ b/crates/engine/tests/camera_raw_exposure_clamp.rs @@ -0,0 +1,40 @@ +//! `filter.cameraRaw` must clamp `exposure` to its documented -5..5 range. Before the fix, +//! `raw_params` passed it through, so an exposure of 128 or more overflowed `2^exposure` to +//! infinity and a 32-bit layer was left with non-finite samples. + +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn camera_raw_clamps_exposure() { + let hi = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 255.0})).expect("params parse"); + assert_eq!(hi.exposure, 5.0, "exposure 255 must clamp to 5"); + let lo = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": -255.0})).expect("params parse"); + assert_eq!(lo.exposure, -5.0, "exposure -255 must clamp to -5"); + let mid = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 1.5})).expect("params parse"); + assert_eq!(mid.exposure, 1.5, "in-range exposure stays untouched"); +} + +#[test] +fn camera_raw_out_of_range_exposure_keeps_32_bit_layer_finite() { + for exposure in [255.0, -255.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("filter.cameraRaw", json!({"exposure": exposure})).unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); + let surf = layer.surface().unwrap(); + let mut bad = Vec::new(); + for y in 0..8 { + for x in 0..8 { + let px = surf.rgba(x, y); + if px.iter().any(|v| !v.is_finite()) { + bad.push(px); + } + } + } + assert!(bad.is_empty(), "exposure {exposure}: {} non-finite pixels, e.g. {:?}", bad.len(), bad.first()); + } +} From 73cd277a034623e7ea46b08631374986346539a7 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:07:08 +0400 Subject: [PATCH 0543/1452] fix(engine): check the define-brush tip size multiply before reserving (wasm32) (#6902) --- crates/engine/src/brush_cmds.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index f2c9e28b2c..5e1ba2f82e 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -864,8 +864,11 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { // Paint amount = darkness × alpha × selection (black = full paint, like Photoshop). let (w, h) = (area.width() as usize, area.height() as usize); // The sampled tip is one f32 buffer over the whole selection; a large selection must fail - // gracefully instead of aborting the process on allocation, so reserve it fallibly. - let mut v = crate::allocation::filled(w * h, 0.0, "defining a brush from the selection")?; + // gracefully instead of aborting the process on allocation, so reserve it fallibly. The + // multiply is checked too: on a 32-bit target (wasm32) it can overflow where the rest of + // this command is already fallible. + let len = w.checked_mul(h).ok_or_else(|| crate::allocation::error("defining a brush from the selection", "requested size overflowed"))?; + let mut v = crate::allocation::filled(len, 0.0, "defining a brush from the selection")?; let sc = sel.channels(); // The selection mask is a second buffer of the same size; it must fail gracefully too // (review of #5469), so read it through the fallible allocator. From e66941bde17c166a9ebb8d0d44925023a0f224c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:07:13 +0200 Subject: [PATCH 0544/1452] Ignore a leading UTF-8 BOM in variable data set CSV import (#6485) Found while testing crates/engine/src/variables_cmds.rs:285-292 (import_data_sets_text; the file is read in import_data_sets at line 279) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/variables_cmds.rs | 2 ++ crates/engine/src/variables_cmds/tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index 6308269703..0a49d780d5 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -286,6 +286,8 @@ fn import_data_sets(s: &mut Session, p: &Value) -> Result { /// file beneath its read root and hands the text over (#3216). pub fn import_data_sets_text(s: &mut Session, text: &str, p: &Value) -> Result { let delim = p.get("delimiter").and_then(Value::as_str).and_then(|d| d.bytes().next()).unwrap_or(b','); + // Excel's "CSV UTF-8" starts with a byte order mark that is not part of the first header cell. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); let mut records = csv_records(text).filter(|r| !r.trim().is_empty()); let header: Vec = records.next().map(|h| split_csv(h, delim)).unwrap_or_default(); diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index c2a30cab68..8a0c591e71 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -136,6 +136,38 @@ fn csv_import_and_apply() { assert_eq!(text_of(&s, title), "On Sale"); } +#[test] +fn csv_import_ignores_utf8_bom() { + let dir = tmp("csv-bom"); + let csv = format!("{dir}/sets.csv"); + + // BOM before the `DataSet` name column: sets keep their names. + let (mut s, _p, badge, title) = session(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); + std::fs::write(&csv, "\u{feff}DataSet,showBadge,headline\nrow-on,true,On Sale\n").unwrap(); + let r = s.execute("file.import.variableDataSets", json!({"path": csv})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"name": "row-on"})).unwrap(); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "On Sale"); + + // BOM before a variable column (no name column): the variable is still imported. + let (mut s, _p, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [{"name": "headline", "layer": title.0, "type": "textReplacement"}]})).unwrap(); + let _ = badge; + let r = import_data_sets_text(&mut s, "\u{feff}headline\nHello\n", &json!({})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"index": 0})).unwrap(); + assert_eq!(text_of(&s, title), "Hello"); +} + #[test] fn csv_fields_preserve_unicode_and_quoting() { for (line, delimiter, expected) in [ From f04fc80267b31eeb1faf81c1769e849a1a788408 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:07:18 +0300 Subject: [PATCH 0545/1452] fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928) (#6967) * fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928) * test(engine): cover flips and turns about the selection (#6928) --- crates/engine/src/extra_cmds.rs | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index c291862167..e75b2347a3 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -335,15 +335,18 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { const TRANSFORM_TARGET: &str = r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#; +/// Flips and turns about the centre of the box Free Transform would open: with a selection +/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All +/// mirrors across the canvas (#6928); otherwise the content's bounds. fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; // A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835). let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? { - Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})), + Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})), None => { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - (crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0})) + (crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0})) } }; if b.is_empty() { @@ -1469,6 +1472,49 @@ mod tests { assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]); } + /// #6928: with a selection, the presets flip and turn about the selection's own bounds, as + /// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre. + /// Without a selection they keep turning about the content (above). + #[test] + fn transform_presets_turn_about_the_selection() { + let mut s = session(8); + paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]); + paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]); + let green = vec![0.0, 1.0, 0.0, 1.0]; + // Select All on the 40 × 30 canvas: across the canvas's centre lines. + s.execute("select.all", json!({})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14)); + assert_eq!(pixel(&s, 29, 10), green); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step"); + s.execute("edit.transform.flipVertical", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20)); + assert_eq!(pixel(&s, 29, 19), green); + s.execute("edit.transform.rotate180", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + assert_eq!(pixel(&s, 10, 10), green); + // A marquee (0, 0)–(24, 30) holding the whole square: across x = 12. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14)); + assert_eq!(pixel(&s, 13, 10), green); + // Undo puts it back where it was. + s.undo(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + // A targeted alpha channel turns about the selection's bounds too. + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20)); + } + #[test] fn reselect_restores_the_last_selection() { let mut s = session(8); From 84f326fa259d13482f382b1b96943b3b41a5436d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:07:23 +0300 Subject: [PATCH 0546/1452] Actions: a Paint Bucket step keeps its explicit colour through .atn (#7199) (#7208) * fix(atn): a Paint Bucket step keeps its explicit colour through .atn export and import (#7199) * test(atn): an explicit Paint Bucket colour survives an .atn round trip (#7199) --- crates/engine/src/atn_cmds.rs | 38 +++++++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..fa86e292f3 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1986,6 +1986,10 @@ fn fill_from(d: &Descriptor) -> Mapped { match get_enum(d, "Usng").as_deref() { Some("FrgC") | None => {} Some("Pttn") => p["contents"] = json!("pattern"), + Some("Clr") => match get_obj(d, "Clr ").and_then(color_hex) { + Some(c) => p["color"] = json!(c), + None => return Mapped::Unsupported { harmless: false }, + }, Some(_) => return Mapped::Unsupported { harmless: false }, } if let Some(t) = get_num(d, "Tlrn") { @@ -2032,7 +2036,16 @@ fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); - d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + // `source` is the command's older name for `contents`. + d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") { + d.with("Usng", enm("FlCn", "Pttn")) + } else { + // An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `. + match bucket_color(p.get("color"))? { + Some(c) => d.with("Usng", enm("FlCn", "Clr ")).with("Clr ", D::Descriptor(c)), + None => d.with("Usng", enm("FlCn", "FrgC")), + } + }; if let Some(o) = jnum(p, "opacity") { d = d.with("Opct", pct(o)); } @@ -2054,6 +2067,29 @@ fn fill_to(id: &str, p: &Value) -> Option { Some(d) } +/// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or +/// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the +/// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1). +fn bucket_color(v: Option<&Value>) -> Option> { + let rgba = match v { + Some(Value::Array(a)) if a.len() >= 3 => { + let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); + [*c.first()?, *c.get(1)?, *c.get(2)?, c.get(3).copied().unwrap_or(1.0)] + } + Some(Value::String(h)) => { + let h = h.trim_start_matches('#'); + let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f64::from(v) / 255.0); + let (Some(r), Some(g), Some(b)) = (c(0), c(2), c(4)) else { return Some(None) }; + [r, g, b, c(6).unwrap_or(1.0)] + } + _ => return Some(None), + }; + if rgba[3] != 1.0 || !rgba.iter().all(|v| (0.0..=1.0).contains(v)) { + return None; + } + Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0)))) +} + const STROKE: &[F] = &[ F("Wdth", "width", Kind::Long), F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..980d5eb9e2 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -432,6 +432,55 @@ fn every_mapped_command_exports_and_imports_back() { } } +/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export. +fn bucket_round_trip(params: Value) -> Option { + let item = export_item("paint.bucket", ¶ms, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(id, p)| { + assert_eq!(id, "paint.bucket"); + p.clone() + }), + _ => None, + } +} + +/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh +/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the +/// step not export rather than fill with the foreground. +#[test] +fn bucket_keeps_an_explicit_color_through_atn() { + let fill = |params: &Value| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap(); + s.execute("paint.bucket", params.clone()).unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2) + }; + let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"}); + let back = bucket_round_trip(red.clone()).unwrap(); + assert_eq!(back["color"], "#ff0000", "{back}"); + assert_eq!(fill(&back), fill(&red)); + assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]); + // The colour as an array of channels. + assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff"); + // No colour, or one the command can't read: the foreground, as before. + for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] { + let back = bucket_round_trip(p.clone()).unwrap(); + assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}"); + } + // A pattern fill ignores the colour, also when named with the older `source`. + for key in ["contents", "source"] { + let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap(); + assert_eq!(back["contents"], "pattern", "{key}: {back}"); + assert!(back.get("color").is_none(), "{key}: {back}"); + } + // Translucent or out-of-range colours have no .atn form. + for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] { + assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}"); + } +} + #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a From 20c3e95a6bd1f101ca6d554be29c14518ef876ec Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:07:29 +0200 Subject: [PATCH 0547/1452] EXR: the ACEScg preference applies to every way of opening a file (#6782) (#6783) The "Open color-less EXR as ACEScg" option was only applied by file.openAs; background opens (File > Open, drag-drop, Open Recent via Session::start_open) and file.openExrParts kept io's linear-sRGB default. Both now retag through tag_exr_colorspace, start_open before the Ask When Opening prompt so it sees the ACEScg profile. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/exr_cmds.rs | 22 ++++++++++++++++++++- crates/engine/src/file_cmds.rs | 36 +++++++++++++++++++++++++++++++++- crates/engine/src/jobs.rs | 4 +++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index 908942d4fd..5fbc355314 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -52,7 +52,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result { precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false), }; let bytes = read_file(&path)?; - let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document); let (i, color) = s.open_document(r.document, Some(path.clone())); Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } @@ -173,6 +174,25 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// File Handling › "Open color-less EXR as ACEScg" applies to Open EXR Parts too. + #[test] + fn open_exr_parts_honours_the_aces_cg_preference() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-aces-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = session(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.icc_profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() { let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id())); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7a86eb0ca1..f9230d1293 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -497,7 +497,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { /// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1, /// linear) — the working space ACES render pipelines write. Only the io default is /// replaced; an embedded or EXIF-declared profile is kept untouched. -fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { +pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg { return; } @@ -1806,4 +1806,38 @@ mod aces_exr_tests { let d = &s.active().unwrap().doc; assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice())); } + + /// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent): + /// the ACEScg preference applies there too, not only to `file.openAs`. + #[test] + fn exr_aces_cg_preference_applies_to_background_opens() { + use crate::jobs::{OpenSource, Started}; + let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap(); + let bytes = + std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap()); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = Session::new(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() { + Started::Job(id) => s.wait_job(id).unwrap(), + Started::Done(v) => v, + }; + // ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which + // names the ACEScg profile; linear sRGB (io's HDR default) opens without asking. + let profile = match v.get("held").and_then(Value::as_u64) { + Some(held) => { + assert!(on, "{v}"); + assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}"); + s.take_held_open(held).unwrap().0.icc_profile + } + None => { + assert!(v.get("document").is_some(), "{v}"); + s.active().unwrap().doc.icc_profile.clone() + } + }; + assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + } } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 47045a37c2..b30e56da3a 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -509,7 +509,9 @@ impl Session { warm_layer_bounds(&imported.document, || ctx.check())?; Ok(imported) }, - move |s, r: photocraft_io::ImportResult| { + move |s, mut r: photocraft_io::ImportResult| { + // File Handling's ACEScg option, before the colour prompt looks at the profile. + crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); // Ask When Opening: held back until the prompt is answered (`take_held_open`). A // developed raw has no embedded profile to ask about. let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); From 5a9fb94b9b8867d791583b0601184539c232d100 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:07:34 +0200 Subject: [PATCH 0548/1452] Type: Paste Lorem Ipsum inserts into the type layer named by "layer" (#5659) (#5660) paste_lorem decided whether to insert into existing text from the active layer, while with_text edits the layer the "layer" param names. With a pixel layer active, {"layer": T} made a new paragraph layer and left T unchanged. An explicit "layer" now always goes through with_text, so a type layer gets the text and any other layer is an error; without "layer" the behaviour is unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/type_extra_cmds.rs | 7 +++++-- crates/engine/src/type_extra_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index efa2d791cc..91d75f033e 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -13,7 +13,7 @@ use photocraft_geom::Point; use photocraft_text::render::PathEl; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, layer_param}; +use crate::commands::{CommandSpec, explicit_layer, layer_param}; use crate::type_cmds::{ check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, }; @@ -477,7 +477,10 @@ fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result Result { - if has_text(s).is_ok() && p.get("new").and_then(Value::as_bool) != Some(true) { + // Into the layer `with_text` edits: an explicit "layer" (an error unless it is a type layer), + // else the active layer when it is a type layer. + let new = p.get("new").and_then(Value::as_bool) == Some(true); + if !new && (explicit_layer(s, p)?.is_some() || has_text(s).is_ok()) { let caret = with_text(s, p, "Paste Lorem Ipsum", |t, _| { let at = p.get("at").and_then(Value::as_u64).map_or(t.text.len(), |c| byte_index(&t.text, c as usize)); replace_text(t, at, at, LOREM_IPSUM); diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 7386927bb7..d87d4370a6 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -329,6 +329,27 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { } } +#[test] +fn paste_lorem_ipsum_inserts_into_the_layer_param_not_the_active_layer() { + // #5659: with a pixel layer active, an explicit type "layer" got a new text box instead. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.new.layer", json!({})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + assert_ne!(pixel, id); + let count = s.active().unwrap().doc.walk().len(); + let r = s.execute("type.pasteLoremIpsum", json!({"layer": id.0, "at": 0})).unwrap(); + assert!(text(&s, id).text.starts_with(LOREM_IPSUM), "{}", text(&s, id).text); + assert!(text(&s, id).text.ends_with("Hi")); + assert_eq!(r["caret"], LOREM_IPSUM.chars().count()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + // An explicit layer that is not a type layer is an error, not a new layer. + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("type.pasteLoremIpsum", json!({"layer": pixel.0})).is_err()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + /// A type layer stored with a family label that is not installed, as a PSD keeps the family it /// guessed from a PostScript name whose face was absent when the file was opened. fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) { From e8ab6846c0b69bbf3805eef91c7cf484165ac9f4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:07:40 +0200 Subject: [PATCH 0549/1452] Place Scale Marker: lay out the label in points, not pixels (#5754) (#5755) fontSize is in points and type.create renders it at resolution/72 px per point, but the bar height, label height and baseline treated it as pixels. At 300 ppi a 12 pt label (50 px) overlapped the bar. Lay out in font_size * dpi / 72 px; type.create still gets points. 72 ppi is unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/analysis_cmds.rs | 10 +++++++--- crates/engine/src/analysis_cmds/tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..ae16b7e4e4 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -1121,8 +1121,12 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { }; // Bottom-left with a margin; the text sits under (or over) the bar. let margin = (w.min(h) * 0.03).round().max(2.0); - let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0); - let text_h = if show_text { font_size * 1.25 } else { 0.0 }; + // `fontSize` is in points; `type.create` renders it at resolution/72 px per point, so lay out + // the bar and label in those pixels (at 300 ppi, 12 pt is 50 px) (#5754). + let dpi = f64::from(d.resolution_dpi); + let font_px = font_size * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let bar_h = (font_px / 4.0).round().clamp(2.0, h / 4.0); + let text_h = if show_text { font_px * 1.25 } else { 0.0 }; // Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932). let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0); // Likewise pull a bar nearly as wide as the image in from the right edge (#932). @@ -1143,7 +1147,7 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { })?; let mut text_layer = Value::Null; if show_text { - let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size }; + let baseline = if text_top { f64::from(bar.y0) - font_px * 0.3 } else { f64::from(bar.y1) + font_px }; let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label}); if let Some(f) = &font { tp["font"] = json!(f); diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..388d5dac97 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_doc::ColorMode; +use photocraft_doc::{ColorMode, LayerContent, LayerId}; fn session(w: u32, h: u32, depth: u32) -> Session { let mut s = Session::new(); @@ -355,6 +355,27 @@ fn place_scale_marker_builds_one_group_in_one_step() { assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker")); } +#[test] +fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() { + // `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the + // layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar. + for res in [72.0, 300.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap(); + let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap(); + let bar_bottom = r["barRect"][3].as_f64().unwrap(); + let doc = d(&s); + let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") }; + assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points"); + let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi); + let b = layout.bounds().expect("the label has a line"); + let ty = t.transform.m[5]; + let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty); + assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}"); + assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}"); + } +} + #[test] fn place_scale_marker_on_a_short_document_errors_without_panicking() { // A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run` From fb5896f43faac2c67c85a8d37389e8f5dd4a73ef Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:07:45 +0200 Subject: [PATCH 0550/1452] Fix generator spaced size keeping size text in asset file name (#6628) Found while testing crates/engine/src/web_cmds.rs:1171 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/web_cmds.rs | 8 ++++++-- crates/engine/src/web_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index e5006daa8f..9f0e25574d 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1189,8 +1189,12 @@ fn parse_size(item: &str, dpi: f64) -> (Option, Option, Option, & && toks[1].eq_ignore_ascii_case("x") && let (Some(w), Some(h)) = (unit_px(toks[0], dpi), unit_px(toks[2], dpi)) { - let pos = item.find(toks[3]).unwrap_or(item.len()); - return (None, w, h, &item[pos..]); + // Skip exactly the three size tokens; searching for the name's first word could match inside the size. + let mut rest = item; + for _ in 0..3 { + rest = rest.trim_start().trim_start_matches(|c: char| !c.is_whitespace()); + } + return (None, w, h, rest.trim_start()); } (None, None, None, item) } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 7bb6fe5646..594fe8d496 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -533,6 +533,20 @@ fn webp_export_preferences_select_lossless_or_lossy_output() { } } +#[test] +fn generator_spaced_size_keeps_only_the_file_name() { + for (name, w, h, file) in [ + ("2 x 2 2 icon.png", 2.0, 2.0, "2 icon.png"), + ("1in x 1in in.png", 72.0, 72.0, "in.png"), + ("2px x 1px x.png", 2.0, 1.0, "x.png"), + ("2in x 1in label name.png", 144.0, 72.0, "label name.png"), + ] { + let a = parse_asset_name(name, 72.0); + assert_eq!(a.len(), 1, "{name}"); + assert_eq!((a[0].width, a[0].height, a[0].file.as_str()), (Some(w), Some(h), file), "{name}"); + } +} + #[test] fn generator_naming_grammar() { let a = parse_asset_name("200% foo@2x.png, 48x48 icons/bar.png8 + photo.jpg8", 72.0); From 2e1cc763a700410d00257d784eab2a100c9e41f4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:07:50 +0200 Subject: [PATCH 0551/1452] Stamp Visible fills an empty active pixel layer instead of adding another (#6785) (#6828) --- crates/engine/src/stamp_cmds.rs | 43 +++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/stamp_cmds.rs b/crates/engine/src/stamp_cmds.rs index a525d138b5..85ac4078c7 100644 --- a/crates/engine/src/stamp_cmds.rs +++ b/crates/engine/src/stamp_cmds.rs @@ -1,7 +1,8 @@ //! Stamping (#217): merge layers into a layer while keeping the originals. //! //! - **Stamp Visible** (⌘⌥⇧E, or ⌥ + Layer › Merge Visible): every visible layer composited into a -//! new layer above the active one. +//! new layer above the active one, or into the active layer when it is an empty pixel layer (as +//! in Photoshop, so a recorded New Layer + Stamp Visible leaves one layer, #6785). //! - **Stamp Down** (⌘⌥E, or ⌥ + Layer › Merge Down): a copy of the active layer merged into the //! pixel layer below it; with several layers selected, a copy of them merged into a new layer //! above the top one. @@ -65,6 +66,13 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { if !doc.walk().iter().any(|(path, _, l)| !l.is_group() && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))) { return Err(EngineError::Other("there are no visible layers to stamp".into())); } + // Like Photoshop, an empty pixel layer that is active (Layer > New > Layer, then Stamp + // Visible, as recorded actions do) takes the stamp itself instead of getting a second layer. + if let Some(empty) = (*active).filter(|id| is_empty_pixel_layer(doc, *id)) { + let surf = photocraft_compose::flatten_to_surface(doc, with_alpha(doc), None); + *crate::pixels_mut(doc.layer_mut(empty).ok_or(EngineError::NoLayer(empty))?)? = surf; + return Ok(empty); + } let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?; let id = doc.insert_above(*active, layer); *active = Some(id); @@ -74,6 +82,18 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { Ok(json!({ "layer": id.0 })) } +/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into. +fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool { + let Some(l) = doc.layer(id) else { return false }; + let locks = doc.effective_locks(id); + matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()) + && l.visible + && l.mask.is_none() + && l.vector_mask.is_none() + && !l.clipped + && !(locks.all || locks.pixels || locks.transparency) +} + fn stamp_down(s: &mut Session, p: &Value) -> Result { object(p, "layer.stampDown")?; let sel = crate::layer_multi_cmds::selected(s); @@ -148,7 +168,7 @@ pub fn specs() -> Vec { label: "Stamp Visible", menu: &[], shortcut: Some("Cmd+Alt+Shift+E"), - params: r##"{} → {layer} (every visible layer merged into a new layer above the active one; the originals stay)"##, + params: r##"{} → {layer} (visible layers merged into a new layer above the active one, or into the active one if it is an empty pixel layer)"##, enabled: has_doc, journal: true, run: stamp_visible, @@ -235,6 +255,25 @@ mod tests { } } + /// Recorded actions do New Layer, then Stamp Visible: the stamp lands in the new empty layer. + #[test] + fn stamp_visible_fills_an_empty_active_layer_instead_of_adding_one() { + let (mut s, bg, hidden, blue) = session(8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + let r = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s), [bg, hidden, blue, empty], "no extra layer"); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers.clone()), (Some(empty), vec![empty])); + assert!(close(rgba(&s, empty, 30, 30), [0.0, 0.0, 1.0, 1.0])); + assert!(close(rgba(&s, empty, 2, 2), [1.0, 0.0, 0.0, 1.0])); + // A second stamp: the layer now has pixels, so this one adds a layer. + let r2 = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_ne!(r2["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s).len(), 5); + } + #[test] fn stamp_visible_respects_opacity_blend_and_groups() { let (mut s, bg, _, blue) = session(8); From 9ae0f624fc4ef6872dd22744b03c194546677f00 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Sun, 18 Oct 2026 08:07:55 +0100 Subject: [PATCH 0552/1452] test(type): PostScript names guessed apart from their installed family resolve exactly (#5471) Covers #5044's two names: BMDoHyeon (family "BM DoHyeon", guessed "BM Do Hyeon") and Daum_SemiBold (guessed "Daum_Semi Bold"). Both resolve to their installed faces and aren't reported missing on main, through the exact PostScript lookup (#1844) that missing-font checks follow since #608. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/type_extra_cmds/tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index d87d4370a6..687a56471a 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -420,3 +420,21 @@ fn exact_postscript_face_outside_the_guessed_family_is_not_missing() { assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family)); assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true)); } + +/// #5044: PostScript names whose family has its space elsewhere (`BMDoHyeon`, family +/// "BM DoHyeon") or an underscore style suffix (`Daum_SemiBold`). Guessing splits them as +/// "BM Do Hyeon" and "Daum_Semi Bold", neither installed; the exact installed face resolves them. +#[test] +fn camel_case_and_underscore_postscript_names_resolve_to_their_installed_faces() { + let mut fonts = photocraft_text::FontDb::new(); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "BM DoHyeon", "BMDoHyeon")); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Daum", "Daum_SemiBold")); + for (ps, family) in [("BMDoHyeon", "BM DoHyeon"), ("Daum_SemiBold", "Daum")] { + let guess = photocraft_text::fonts::guess_from_postscript(ps).family; + assert!(!fonts.has_family(&guess), "{ps}: the guess {guess:?} is not an installed family"); + let r = fonts.resolve_postscript(ps); + assert_eq!((r.family.as_str(), r.exact), (family, true), "{ps}"); + let style = photocraft_doc::text::CharStyle { font_family: guess.clone(), postscript_name: Some(ps.into()), ..Default::default() }; + assert!(!style_font_missing(&mut fonts, &style), "{ps} stored as {guess:?} is not missing"); + } +} From 459a25bd9558e5d0bf31fee898f90bc7ca18a677 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Sun, 18 Oct 2026 09:17:34 +0200 Subject: [PATCH 0553/1452] Revert "perf(gpu): batch resident overlap copies" This reverts commit f57bb1f3b1a47f8af04f451896f043f3ff797b86. --- crates/gpu/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 845e29b7bb..8f8839b9bd 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1540,7 +1540,7 @@ impl Compositor { }; // Only the region moved (painting past a layer's tiles grows it): keep the texels the // old texture already holds instead of converting and uploading every tile again. - let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(encoder, &old, &mut r)); + let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(device, queue, &old, &mut r)); // A new layer whose tiles another resident already holds (a duplicate shares its // source's tiles) starts as a GPU copy of that texture. Duplicating a 20 MP photo // converted and uploaded every tile again. @@ -2109,14 +2109,16 @@ fn put_tiles(device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::Com /// format and default pixel; only the region moved) and carry over the tiles they show, so only /// tiles outside the overlap or changed since need uploading. Returns the kept (tile-aligned) /// rect; empty when nothing can be kept. -fn keep_overlap(encoder: &mut wgpu::CommandEncoder, old: &Resident, new: &mut Resident) -> Rect { +fn keep_overlap(device: &wgpu::Device, queue: &wgpu::Queue, old: &Resident, new: &mut Resident) -> Rect { let same = old.kind == new.kind && old.format == new.format && old.cmyk == new.cmyk && old.default_bits == new.default_bits; let k = old.region.intersect(&new.region); if !same || k.is_empty() { return Rect::EMPTY; } - // Record the copy before this frame's tile uploads/copies. Keeping it in the active encoder - // preserves that order without forcing a separate queue submission for every region shift. + // Its own submission: tile writes go through the queue and land at the start of the next + // submit, before anything in the frame's encoder, so a copy recorded there would overwrite + // the tiles written after it with the old texels. + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose_keep") }); encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &old.texture, @@ -2132,9 +2134,7 @@ fn keep_overlap(encoder: &mut wgpu::CommandEncoder, old: &Resident, new: &mut Re }, wgpu::Extent3d { width: k.width(), height: k.height(), depth_or_array_layers: 1 }, ); - // A direct `queue.write_texture` would execute before this encoder's copy and then be - // overwritten. Route changed tiles through encoder-ordered scratch copies for this resident. - new.ordered = true; + queue.submit([encoder.finish()]); new.tiles = old.tiles.iter().filter(|(c, _)| k.contains_rect(&c.rect())).map(|(c, t)| (*c, t.clone())).collect(); k } From 175bdce6590c6350b30facbdc762b8728ace10af Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:32:53 +0200 Subject: [PATCH 0554/1452] GPU: a group's effect cache follows its vector mask and children's Blend If (#5706) (#5707) * GPU: a group's effect cache follows its vector mask and children's Blend If (#5706) group_key, which decides when a styled group's effect shape and distance fields are rebuilt on the GPU, hashed the group's children and layer mask but not the group's own vector mask, nor each child's Blend If and Advanced Blending. A group's Stroke or Drop Shadow kept the old outline after those changed. Hash them, as the CPU's layer_identity does. Co-Authored-By: Claude Opus 5.5 (1M context) * GPU: pass the pin callback in the group key test after main's signature change Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/gpu/src/fx.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..53802c6f2b 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -682,6 +682,8 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut( surface_fp(&m.surface, h, pin); } format!("{:?}{:?}", l.vector_mask, l.effects).hash(h); + l.blend_if.hash(h); + l.advanced.hash(h); h.write_u8(0xfe); } let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -695,6 +697,7 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut( (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); surface_fp(&m.surface, &mut h, pin); } + format!("{:?}", layer.vector_mask).hash(&mut h); } _ => walk(layer, &mut h, pin), } @@ -721,6 +724,36 @@ mod tests { assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key); } + /// A styled group's effect shape follows its own vector mask and its children's Blend If and + /// Advanced Blending, so the cached shape must be rebuilt when any of them changes. + #[test] + fn group_key_follows_vector_mask_blend_if_and_advanced() { + use photocraft_doc::{BlendRange, Knockout, Path, PixelFormat, Subpath, VectorMask}; + let light = GlobalLight::default(); + let group = Layer::group("g", vec![Layer::raster("child", PixelFormat::RGBA8)]); + let base = group_key(&group, &light, &mut |_| {}); + + let mask = |x: f64| VectorMask::new(Path::new(vec![Subpath::polygon(&[(x, 0.0), (x + 10.0, 0.0), (x + 10.0, 10.0)])])); + let mut masked = group.clone(); + masked.vector_mask = Some(mask(0.0)); + let with_mask = group_key(&masked, &light, &mut |_| {}); + assert_ne!(base, with_mask, "adding the group's vector mask"); + masked.vector_mask = Some(mask(5.0)); + assert_ne!(with_mask, group_key(&masked, &light, &mut |_| {}), "moving the group's vector mask"); + + fn child_mut(g: &mut Layer) -> &mut Layer { + let LayerContent::Group(gr) = &mut g.content else { unreachable!() }; + &mut gr.children[0] + } + let mut blend_if = group.clone(); + child_mut(&mut blend_if).blend_if.ranges = vec![[BlendRange { black: [128, 128], white: [255, 255] }, BlendRange::FULL]]; + assert_ne!(base, group_key(&blend_if, &light, &mut |_| {}), "a child's Blend If"); + + let mut advanced = group.clone(); + child_mut(&mut advanced).advanced.knockout = Knockout::Deep; + assert_ne!(base, group_key(&advanced, &light, &mut |_| {}), "a child's Advanced Blending"); + } + #[test] fn windows_grow_by_reach_and_temps_are_reused() { let light = GlobalLight::default(); From c408c83a9f52e020aab0a7e47f942c2f2466eae5 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:32:58 -0400 Subject: [PATCH 0555/1452] Pencil: a 1 px line paints the same pixels whichever end it starts from, at any Smoothing (#6336) (#6341) The pixel-grid walker stepped between the pixels holding each segment's ends. Smoothing splits a Shift-click line into pieces along it (so does the pointer on a Shift-drag), and snapping every piece to its own pixels bent the line into a jagged staircase; rounding the half-pixel ties away from zero also made the line depend on which end it started from. The walker now samples the segment's own line at each column (or row) centre and resolves ties on the pixel grid, so the pieces of a straight line paint exactly the pixels of the whole line, both ways. Co-authored-by: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 47 +++++++++++++++++++++++++++----- crates/paint/src/dynamics.rs | 48 ++++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..9ba17fcf1f 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -189,28 +189,61 @@ fn pencil_dabs_sit_on_the_pixel_grid() { #[test] fn pencil_1px_line_is_a_clean_8_connected_staircase() { - // #2139: a 1 px Pencil line is one pixel per column (or row, when steep) on the ideal line - // between the end pixels: no doubled pixels at the steps. + // #2139: a 1 px Pencil line is one pixel per column (or row, when steep) on the line: no + // doubled pixels at the steps. let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() }; let cases = [((2.5, 2.5), (22.5, 9.5)), ((3.5, 30.5), (10.5, 5.5)), ((1.2, 1.7), (25.8, 4.1)), ((5.5, 5.5), (17.5, 17.5)), ((20.3, 3.6), (4.9, 12.2))]; for ((ax, ay), (bx, by)) in cases { let s = paint(&b, &[StrokePoint::new(ax, ay, 1.0), StrokePoint::new(bx, by, 1.0)], 40, 40); let painted: Vec<(i32, i32)> = (0..40).flat_map(|y| (0..40).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect(); let (a, e) = ((ax.floor(), ay.floor()), (bx.floor(), by.floor())); - let steep = (e.1 - a.1).abs() > (e.0 - a.0).abs(); + let steep = (by - ay).abs() > (bx - ax).abs(); let major = |p: (f64, f64)| if steep { (p.1, p.0) } else { (p.0, p.1) }; - let ((m0, n0), (m1, n1)) = (major(a), major(e)); + let ((m0, _), (m1, _)) = (major(a), major(e)); + let ((am, an), (bm, bn)) = (major((ax, ay)), major((bx, by))); assert_eq!(painted.len() as f64, (m1 - m0).abs() + 1.0, "{a:?}->{e:?}: one pixel per step, got {painted:?}"); for &(x, y) in &painted { - let (m, n) = major((f64::from(x), f64::from(y))); - let ideal = n0 + (n1 - n0) * (m - m0) / (m1 - m0); - assert!((n - ideal).abs() <= 0.5 + 1e-9, "{a:?}->{e:?}: ({x},{y}) is off the line"); + if (f64::from(x), f64::from(y)) == a { + continue; // The pixel the stroke starts in. + } + // The pixel's centre is within half a pixel of the line where it crosses its column. + let (m, n) = major((f64::from(x) + 0.5, f64::from(y) + 0.5)); + let on_line = an + (bn - an) * (m - am) / (bm - am); + assert!((n - on_line).abs() <= 0.5 + 1e-9, "{a:?}->{e:?}: ({x},{y}) is off the line"); } let columns: std::collections::BTreeSet = painted.iter().map(|&(x, y)| major((f64::from(x), f64::from(y))).0 as i64).collect(); assert_eq!(columns.len(), painted.len(), "{a:?}->{e:?}: a column holds two pixels"); } } +#[test] +fn pencil_1px_line_is_the_same_whichever_way_and_however_smoothed() { + // #6336: a ⇧-click line between pixel centres painted different pixels depending on which end + // it started from, and Smoothing (which splits it into pieces along the line) bent it. + let pixels = |a: (f64, f64), e: (f64, f64), amount: f32| { + let mut b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() }; + b.smoothing.amount = amount; + let s = paint(&b, &[StrokePoint::new(a.0, a.1, 1.0), StrokePoint::new(e.0, e.1, 1.0)], 24, 24); + (0..24).flat_map(|y| (0..24).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect::>() + }; + for (a, e) in [((3.5, 3.5), (4.5, 5.5)), ((3.5, 3.5), (8.5, 5.5)), ((2.5, 20.5), (9.5, 17.5)), ((1.5, 1.5), (20.5, 9.5))] { + let line = pixels(a, e, 0.0); + assert_eq!(line, pixels(e, a, 0.0), "{a:?}->{e:?} differs from {e:?}->{a:?}"); + for amount in [0.1, 0.5, 0.9] { + assert_eq!(pixels(a, e, amount), line, "{a:?}->{e:?} at Smoothing {amount}"); + assert_eq!(pixels(e, a, amount), line, "{e:?}->{a:?} at Smoothing {amount}"); + } + } + // One right and two down: three pixels, one per row. + assert_eq!(pixels((3.5, 3.5), (4.5, 5.5), 0.1), vec![(3, 3), (4, 4), (4, 5)]); + // A straight line fed as many pointer samples along it paints the line's pixels. + let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() }; + let samples: Vec = (0..=37).map(|i| StrokePoint::new(1.5 + 19.0 * f64::from(i) / 37.0, 1.5 + 8.0 * f64::from(i) / 37.0, 1.0)).collect(); + let s = paint(&b, &samples, 24, 24); + let sampled: Vec<(i32, i32)> = (0..24).flat_map(|y| (0..24).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect(); + assert_eq!(sampled, pixels((1.5, 1.5), (20.5, 9.5), 0.0)); +} + // ---------- spacing ---------- #[test] diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index ab846f1959..05a638a532 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -27,6 +27,15 @@ const MIN_SPEED_STEP: f64 = 0.5; /// Maximum airbrush catch-up dabs for one input segment. const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512; +/// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the +/// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding +/// noise of a cell boundary counts as on it, so a line lands on the same pixels both ways. +fn grid_cell(v: f64, offset: f64) -> f64 { + let w = v - offset + 0.5; + let w = if (w - w.round()).abs() < 1e-7 { w.round() } else { w }; + w.floor() + offset +} + #[inline] fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint { let ff = f as f32; @@ -216,10 +225,14 @@ impl PathWalker { } /// Step pixel by pixel instead of by distance: one step per pixel along the segment's major - /// axis, on the line between the pixels holding its ends, each at the pixel's dab centre - /// (`offset` 0.5 for odd tip diameters, 0 for even ones, as `render::grid_center` snaps). A - /// small aliased tip (the Pencil) then draws a clean 8-connected staircase: stepping by - /// distance put two dabs in a row wherever the line crossed a pixel corner (#2139). + /// axis, each at the dab centre (`offset` 0.5 for odd tip diameters, 0 for even ones, as + /// `render::grid_center` snaps) of the pixel the segment's line crosses at that column (or + /// row, when steep). A small aliased tip (the Pencil) then draws a clean 8-connected + /// staircase: stepping by distance put two dabs in a row wherever the line crossed a pixel + /// corner (#2139). The line is sampled where it really runs, not between the pixels holding + /// the segment's ends, so a straight line split into pieces (by Smoothing, or the pointer + /// samples of a ⇧-drag) paints the pixels of the whole line, whichever end it starts from + /// (#6336). pub fn pixel_grid(mut self, offset: f64) -> Self { self.grid = Some(offset); self @@ -228,19 +241,24 @@ impl PathWalker { /// Emits the pixel steps from `a` to `p` (see [`PathWalker::pixel_grid`]). False, emitting /// nothing, when the span is not finite or implausibly long: distance spacing takes over. fn grid_steps(&mut self, a: &StrokePoint, p: &StrokePoint, offset: f64, out: &mut impl FnMut(StepInput)) -> bool { - let cell = |v: f64| if offset > 0.0 { v.floor() } else { v.round() }; - let (x0, y0) = (cell(a.x), cell(a.y)); - let (dx, dy) = (cell(p.x) - x0, cell(p.y) - y0); - let n = dx.abs().max(dy.abs()); - if !n.is_finite() || n > MAX_GRID_STEPS { + // The pieces of a 45° line differ in rounding noise: keep them all on one major axis. + let steep = (p.y - a.y).abs() > (p.x - a.x).abs() * (1.0 + 1e-9); + let (am, an, pm, pn) = if steep { (a.y, a.x, p.y, p.x) } else { (a.x, a.y, p.x, p.y) }; + let (m0, m1) = (grid_cell(am, offset), grid_cell(pm, offset)); + let (dm, dn) = (pm - am, pn - an); + let n = (m1 - m0).abs(); + if !(n.is_finite() && dn.is_finite()) || n > MAX_GRID_STEPS || dm == 0.0 { return false; } - let steps = n as u64; - for i in 1..=steps { - let i = i as f64; - let mut q = lerp_pt(a, p, i / n); - q.x = x0 + (dx * i / n).round() + offset; - q.y = y0 + (dy * i / n).round() + offset; + let dir = (m1 - m0).signum(); + for i in 1..=n as u64 { + let c = m0 + dir * i as f64; + // The last column's centre can sit up to half a pixel past `p`: the line goes on to + // it, so the next piece of a straight line picks up where this one left off. + let t = (c - am) / dm; + let mut q = lerp_pt(a, p, t.clamp(0.0, 1.0)); + let cn = grid_cell(an + dn * t, offset); + (q.x, q.y) = if steep { (cn, c) } else { (c, cn) }; self.emit(q, out); } true From 16774eea6fb33a560665b2c6192564ca41868cbe Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:33:04 -0400 Subject: [PATCH 0556/1452] Twirl, Pinch and Spherize fill the inscribed ellipse (#5643) (#5871) They acted inside a circle fitted to the bounds' shorter side, so on a wide or tall image only a central disc changed. Like Photoshop's, they now act inside the ellipse inscribed in the bounds (selection or layer), reaching all four edges; square bounds are unchanged. Co-authored-by: Claude Opus 5.5 --- crates/algo/src/distort.rs | 55 +++++++++++++++++++++++++------------- crates/algo/src/tests.rs | 18 +++++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/crates/algo/src/distort.rs b/crates/algo/src/distort.rs index 664169afa4..fb6e3e69c3 100644 --- a/crates/algo/src/distort.rs +++ b/crates/algo/src/distort.rs @@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) { (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0) } +/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse +/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all +/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to +/// unit-circle coordinates `(u, v)` and back. +fn ellipse(b: Rect) -> (f32, f32, f32, f32) { + let (w, h) = (b.width() as f32, b.height() as f32); + (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0) +} + pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = angle.to_radians(); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || rr <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let t = a * (1.0 - r / rr); + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + return (x, y); + } + let t = a * (1.0 - r); let (s, c) = t.sin_cos(); - (cx + dx * c - dy * s, cy + dx * s + dy * c) + (cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy) }) } pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * (r / rr).powf(1.0 - a * 0.5); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = r.powf(1.0 - a * 0.5) / r; + (cx + u * k * hx, cy + v * k * hy) }) } @@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 { } pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec { - let b = ctx.bounds; - let (cx, cy, rr) = centre(b); - let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { let (dx, dy) = (x - cx, y - cy); match mode { SpherizeMode::Normal => { - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * sphere_1d(r / rr, a); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = (dx / hx, dy / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = sphere_1d(r, a) / r; + (cx + u * k * hx, cy + v * k * hy) } SpherizeMode::HorizontalOnly => { if dx.abs() >= hx { diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 31def5f99d..148a0723ec 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -414,6 +414,24 @@ fn twirl_keeps_centre_and_outside() { assert_ne!(out.pixel(20, 12), s.pixel(20, 12)); } +/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as +/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges +/// on the middle row changes, the corners don't, and a square still uses its inscribed circle. +#[test] +fn round_distortions_fill_the_inscribed_ellipse() { + let b = Rect::new(0, 0, 80, 40); + let s = pattern(SampleType::F32, b); + for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] { + let out = apply(&s, &p, b, b, None); + // 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse. + assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}"); + assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}"); + for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] { + assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse"); + } + } +} + #[test] fn motion_blur_spreads_along_angle_only() { let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); From 275d4cbcf77f054a2740b609a28ffdffaea8878b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:33:09 +0200 Subject: [PATCH 0557/1452] Reject out-of-range leading, baseline shift and scales in type styles (#6196) Found while testing crates/engine/src/type_cmds.rs:287 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/type_cmds.rs | 29 ++++++++++++- crates/engine/tests/nonfinite_style.rs | 56 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/nonfinite_style.rs diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..cd4ca7a27b 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -89,6 +89,8 @@ pub const TYPE_SIZE_MIN_PT: f64 = 0.1; pub const TYPE_SIZE_MAX_PT: f64 = 1296.0; pub const TRACKING_MIN: f64 = -1000.0; pub const TRACKING_MAX: f64 = 10_000.0; +/// Photoshop's horizontal and vertical scale range, in percent. +pub const SCALE_MAX_PERCENT: f64 = 1000.0; /// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or /// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`). @@ -104,7 +106,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> { } } -/// Validates text metrics that can otherwise exceed the rasterizer's supported range. +/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise +/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold. pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { if let Some(v) = p.get("size") { match v.as_f64() { @@ -118,6 +121,30 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { _ => return Err(format!("tracking must be between {TRACKING_MIN} and {TRACKING_MAX} (1/1000 em)")), } } + if let Some(v) = p.get("leading") { + let (min, max) = (f64::from(LEADING_MIN_PT), f64::from(LEADING_MAX_PT)); + match v { + Value::Null => {} + Value::String(a) if a == "auto" => {} + Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && (min..=max).contains(&x)) => {} + _ => return Err(format!("leading must be between {min} and {max} pt, or \"auto\"")), + } + } + if let Some(v) = p.get("baselineShift") { + let max = f64::from(BASELINE_SHIFT_MAX_PT); + match v.as_f64() { + Some(shift) if shift.is_finite() && shift.abs() <= max => {} + _ => return Err(format!("baselineShift must be between -{max} and {max} pt")), + } + } + for key in ["horizontalScale", "verticalScale"] { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(scale) if scale.is_finite() && (0.0..=SCALE_MAX_PERCENT).contains(&scale) => {} + _ => return Err(format!("{key} must be between 0 and {SCALE_MAX_PERCENT} %")), + } + } + } Ok(()) } diff --git a/crates/engine/tests/nonfinite_style.rs b/crates/engine/tests/nonfinite_style.rs new file mode 100644 index 0000000000..994f3dbe6e --- /dev/null +++ b/crates/engine/tests/nonfinite_style.rs @@ -0,0 +1,56 @@ +//! Character style numbers that overflow `f32` are refused, so the document stays saveable. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.execute("type.create", json!({"text": "Hi", "size": 24})).unwrap()["layer"].as_u64().unwrap(); + (s, id) +} + +#[test] +fn out_of_range_style_numbers_are_refused() { + for key in ["leading", "baselineShift", "horizontalScale", "verticalScale"] { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [1e39, -1e39, 1e12] { + let r = s.execute("type.setStyle", json!({"layer": id, key: value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key}={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "{key}: no history step"); + let r = s.execute("type.create", json!({"text": "x", key: 1e39})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "create {key}: {r:?}"); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_{key}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("{key}: save failed: {e:?}")); + } +} + +#[test] +fn in_range_style_numbers_still_apply_and_save() { + let (mut s, id) = fixture(); + s.execute("type.setStyle", json!({"layer": id, "leading": 30, "baselineShift": -4, "horizontalScale": 120, "verticalScale": 80})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "leading": "auto"})).unwrap(); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_ok_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("save failed: {e:?}")); +} + +#[test] +fn non_numeric_leading_is_refused() { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [json!("18pt"), json!("Auto "), json!(true), json!([12])] { + let r = s.execute("type.setStyle", json!({"layer": id, "leading": value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "leading={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "no history step"); +} From a002e66824f980e902f18b18b2d074ac99f95aa9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:33:14 +0200 Subject: [PATCH 0558/1452] Snapping: layer targets ignore the selection, so a moved selection snaps to guides and the document (#6290) (#6355) Snap To Layers built each layer's edges and centre from transform_bounds, which clips the layer to the selection. With a selection, every layer's targets were the selection's own bounds, so dragging the outline with a marquee snapped it back to where it started (shown as layer alignments) and beat nearby guides and document edges. Layer targets now use the layer's whole content bounds, as in Photoshop; the moving box (layer_rect) is unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/snap.rs | 32 ++++++++++++++++++++++++----- crates/engine/src/transform_cmds.rs | 2 +- crates/ui-egui/src/marquee_tests.rs | 23 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 3ab1ad73bf..ef0e72f5db 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -89,10 +89,21 @@ pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> { } fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> { - if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) { - return None; - } - let b = crate::transform_cmds::transform_bounds(doc, l); + snappable(l).then(|| crate::transform_cmds::transform_bounds(doc, l)).and_then(to_rect) +} + +/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection +/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the +/// selection's, so a moved selection snapped back to where it started). +fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> { + snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect) +} + +fn snappable(l: &photocraft_doc::Layer) -> bool { + !(l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none())) +} + +fn to_rect(b: photocraft_geom::Rect) -> Option<[f64; 4]> { (!b.is_empty()).then_some([b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]) } @@ -121,7 +132,7 @@ impl SnapTargets { if !l.visible || exclude.contains(&l.id) { continue; } - if let Some(r) = rect_of(doc, l) { + if let Some(r) = target_rect_of(l) { t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); } } @@ -365,6 +376,17 @@ mod tests { let t = SnapTargets::from_document(&doc, &SnapOptions { guides: false, ..opts }, &[id]); assert!(!t.x.iter().any(|t| t.kind == SnapKind::Guide || (t.kind == SnapKind::LayerEdge && t.pos == 30.0))); assert_eq!(layer_rect(&doc, id), Some([10.0, 20.0, 30.0, 60.0])); + // #6290: a selection doesn't move the layers' targets onto its own bounds. + let mut sel = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + sel.fill_rect(photocraft_geom::Rect::new(64, 66, 124, 86), &[1.0]); + doc.selection = Some(sel); + let t = SnapTargets::from_document(&doc, &SnapOptions { selection: false, ..opts }, &[]); + assert!(t.x.iter().any(|t| t.kind == SnapKind::LayerEdge && t.pos == 30.0)); + assert!( + !t.x.iter() + .chain(&t.y) + .any(|t| matches!(t.kind, SnapKind::LayerEdge | SnapKind::LayerCenter) && [64.0, 66.0, 76.0, 86.0, 94.0, 124.0].contains(&t.pos)) + ); assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0])); } } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..310bb13bb9 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -54,7 +54,7 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { } /// A group moves all its descendants, so its frame must not clip raster children to the selection. -fn whole_layer_bounds(layer: &Layer) -> Rect { +pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect { let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 169d4588b9..321a31e73a 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -546,6 +546,29 @@ fn mouse_drag_inside_the_selection_moves_it() { assert_eq!(selection(&h), Rect::new(120, 90, 220, 170), "moved by (20, 10)"); } +/// #6290: with a fresh app's View › Snap To, a selection moved through the real canvas snaps its +/// edges to a guide and the document bounds. Layers' targets were clipped to the selection, so +/// it snapped back to its own starting edges and centre (shown as layer alignments) instead. +#[test] +fn a_moved_selection_snaps_to_guides_and_the_document_not_its_start() { + for (to, want) in [ + // Right edge 3 px left of the guide at x = 200; 3 px down stays put (not back to y = 52). + ([145.0, 103.0], Rect::new(100, 55, 200, 105)), + // 5 and 7 px from the document's top-left corner (its centre was 3 px off its start). + ([53.0, 55.0], Rect::new(0, 0, 100, 50)), + ] { + let mut h = harness(Tool::RectMarquee); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 52, "y": 52, "width": 100, "height": 50})).unwrap(); + h.run_steps(2); + drag_to(&mut h, [100.0, 100.0], to); + let up = screen(&h, to[0], to[1]); + button(&mut h, up, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), want, "dragged to {to:?}"); + } +} + /// #5646: the Move tool keeps Photoshop's arrow pointer instead of the 4-way move cursor (the /// hover outline still marks the layer under the pointer). #[test] From 977cf5f6de89b0750c8006d67fda1c39e5305b91 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:33:19 +0200 Subject: [PATCH 0559/1452] Krita: uncompressed tiles are read as interleaved pixels (#7082) (#7083) * Krita: uncompressed tiles are read as interleaved pixels (#7082) Co-Authored-By: Claude Opus 5.5 * kra: the agent-surface test writes its raw tile interleaved, like Krita (#7082) The krita.rs fixture wrote its flag-0 tile as byte planes, which only the old decoder read back correctly. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/krita.rs | 7 ++++--- crates/io/src/kra/tests.rs | 19 ++++++++++++++++++- crates/io/src/kra/tiles.rs | 25 ++++++++++++++----------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs index 85bde4f5fc..44de53ff2a 100644 --- a/crates/automation/tests/krita.rs +++ b/crates/automation/tests/krita.rs @@ -4,14 +4,15 @@ use photocraft_automation::Headless; use serde_json::json; -/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile +/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes. fn tiles(bgra: [u8; 4]) -> Vec { let n = 64 * 64; let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); out.push(0); - for b in bgra { - out.extend(std::iter::repeat_n(b, n)); + for _ in 0..n { + out.extend(bgra); } out } diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs index 4316a74c10..92e951cdb8 100644 --- a/crates/io/src/kra/tests.rs +++ b/crates/io/src/kra/tests.rs @@ -28,7 +28,8 @@ fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { planar[k * n + p] = px[p * pixel_size + k]; } } - let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + // Krita splits only LZF tiles into byte planes; raw tiles stay interleaved. + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [&[0][..], px].concat() }; out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); out.extend(body); } @@ -160,6 +161,22 @@ fn a_nonzero_default_pixel_fills_untouched_areas() { assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); } +#[test] +fn raw_tiles_hold_interleaved_pixels() { + // Krita writes a tile uncompressed (flag 0) when LZF can't shrink it, and then the pixels + // stay interleaved: only LZF tiles are split into byte planes. + let mut body = vec![0u8]; + body.extend((0..T * T).flat_map(|_| [10u8, 20, 30, 255])); + let mut file = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,{}\n", body.len()).into_bytes(); + file.extend(body); + let doc = maindoc("RGBA", r#""#); + let r = crate::import("a.kra", &archive(&doc, &[("img/layers/l", file)])).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + for (x, y) in [(0, 0), (1, 0), (3, 3)] { + assert_eq!(s.to_interleaved(Rect::from_xywh(x, y, 1, 1)), [30, 20, 10, 255], "pixel {x},{y}"); + } +} + fn merged_png() -> Vec { let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index 8af6638aa8..67fd02fca5 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,19 +231,22 @@ impl<'a> TileFile<'a> { for &(x, y, data) in &self.tiles { ctl.check().map_err(|_| IoError::Cancelled)?; let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; - let planar: std::borrow::Cow<'_, [u8]> = match flag { - 1 => lzf_decompress(payload, expected)?.into(), - 0 if payload.len() == expected => payload.into(), - 0 => return Err(invalid("raw tile has the wrong size")), - f => return Err(invalid(format!("unknown tile compression flag {f}"))), - }; - // Byte planes: byte k of pixel i is at k * n + i. - for (k, plane) in planar.chunks_exact(n).enumerate() { - for (i, b) in plane.iter().enumerate() { - if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { - *slot = *b; + match flag { + 1 => { + // LZF tiles hold byte planes: byte k of pixel i is at k * n + i. + let planar = lzf_decompress(payload, expected)?; + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } } } + // Raw tiles (written when LZF doesn't shrink them) are already interleaved. + 0 if payload.len() == expected => interleaved.copy_from_slice(payload), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), } space.convert(&interleaved, fmt, &mut out); let (x0, y0) = From 436ca3569cb2305650de7fafc9e8e13d25ed1605 Mon Sep 17 00:00:00 2001 From: Andrew Barnes <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:33:24 -0400 Subject: [PATCH 0560/1452] fix(gpu): clamp device limits to adapter support (#5986) (#6050) Keep backend defaults within supported limits while preserving the adapter's texture and buffer maxima. Add regressions for V3D shader variables, weaker GL adapters, minimum alignments and capable devices. --- crates/ui-egui/src/gpu_canvas.rs | 79 ++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 012bcf801c..1770979db1 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -880,9 +880,9 @@ pub struct Refresh { pub fallback: Option, } -/// How the app creates its wgpu device: egui's defaults, except that the texture and buffer size -/// limits are the adapter's own rather than egui's 8192 px cap, so large documents stay on the -/// GPU compositor. +/// How the app creates its wgpu device: egui's defaults clamped to the adapter's supported limits, +/// with the adapter's own texture and buffer sizes rather than egui's 8192 px cap, so large +/// documents stay on the GPU compositor. pub fn wgpu_setup() -> egui_wgpu::WgpuSetup { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); use_adapter_limits(&mut setup); @@ -891,7 +891,8 @@ pub fn wgpu_setup() -> egui_wgpu::WgpuSetup { /// Make `setup` request the adapter's own texture and buffer size limits instead of egui's /// default 8192 px cap (which sent every document wider or taller than 8192 px to the CPU -/// compositor). Everything else (features, the WebGL2 base limits on GL) stays egui's default. +/// compositor). Other limits use egui's defaults (WebGL2's on GL), clamped to adapter support; +/// features stay egui's default. pub fn use_adapter_limits(setup: &mut egui_wgpu::WgpuSetup) { use_adapter_limits_with(setup, |_| {}); } @@ -907,11 +908,15 @@ pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: imp } } -/// egui's base limits (WebGL2's on GL) with the adapter's maximum texture and buffer sizes. +/// egui's base limits (WebGL2's on GL), clamped to adapter support, with its maximum texture and buffer sizes. pub fn device_limits(adapter: &wgpu::Adapter) -> wgpu::Limits { - let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; - let a = adapter.limits(); - wgpu::Limits { max_texture_dimension_2d: a.max_texture_dimension_2d, max_buffer_size: a.max_buffer_size, ..base } + device_limits_for(adapter.get_info().backend, adapter.limits()) +} + +fn device_limits_for(backend: wgpu::Backend, supported: wgpu::Limits) -> wgpu::Limits { + let base = if backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; + let base = base.or_worse_values_from(&supported); + wgpu::Limits { max_texture_dimension_2d: supported.max_texture_dimension_2d, max_buffer_size: supported.max_buffer_size, ..base } } /// Milliseconds since first call (monotonic). Always 0 on wasm, where `std::time` is unavailable. @@ -2375,6 +2380,64 @@ mod mip_tests; mod tests { use super::*; + #[test] + fn device_limits_support_v3d_shader_variable_limit() { + let supported = wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..wgpu::Limits::default() }; + let requested = device_limits_for(wgpu::Backend::Vulkan, supported.clone()); + assert_eq!(requested.max_inter_stage_shader_variables, 15); + assert!(requested.check_limits(&supported)); + assert_eq!(requested.max_texture_dimension_2d, 7680); + assert_eq!(requested.max_buffer_size, supported.max_buffer_size); + } + + #[test] + fn device_limits_support_weaker_gl_adapters() { + let supported = wgpu::Limits { + max_texture_dimension_2d: 1024, + max_texture_dimension_3d: 128, + max_bind_groups: 2, + max_buffer_size: 128 << 20, + ..wgpu::Limits::downlevel_webgl2_defaults() + }; + let requested = device_limits_for(wgpu::Backend::Gl, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested, supported); + } + + #[test] + fn device_limits_preserve_backend_defaults_on_capable_adapters() { + let supported = wgpu::Limits { + max_texture_dimension_2d: 16_384, + max_buffer_size: 1 << 30, + max_bind_groups: 8, + max_inter_stage_shader_variables: 32, + min_uniform_buffer_offset_alignment: 64, + min_storage_buffer_offset_alignment: 64, + ..wgpu::Limits::default() + }; + for (backend, base) in [ + (wgpu::Backend::Vulkan, wgpu::Limits::default()), + (wgpu::Backend::Dx12, wgpu::Limits::default()), + (wgpu::Backend::Metal, wgpu::Limits::default()), + (wgpu::Backend::Gl, wgpu::Limits::downlevel_webgl2_defaults()), + ] { + let requested = device_limits_for(backend, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested, wgpu::Limits { max_texture_dimension_2d: 16_384, max_buffer_size: 1 << 30, ..base }, "{backend:?}"); + } + } + + #[test] + fn device_limits_support_larger_minimum_alignments() { + let supported = wgpu::Limits { min_uniform_buffer_offset_alignment: 512, min_storage_buffer_offset_alignment: 1024, ..wgpu::Limits::default() }; + for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Gl] { + let requested = device_limits_for(backend, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested.min_uniform_buffer_offset_alignment, 512); + assert_eq!(requested.min_storage_buffer_offset_alignment, 1024); + } + } + /// The scratch-texture workaround is for Intel's DX12 drivers (#892); other DX12 adapters /// render straight into the level like every other backend (#4515). #[test] From 0007cad01adda5249f58f0bbd03ddf474c9a384b Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:33:29 -0400 Subject: [PATCH 0561/1452] Paste: an image pastes with Ctrl+V whichever key is let go first (#6329) (#6360) With no text on the clipboard (an image copied from a browser, a file from Explorer) egui-winit swallows the Ctrl+V press and sends only the V release; the paste then ran only when that release still carried Ctrl. Letting go of Ctrl first, as many people do, pasted nothing, so Ctrl+V seemed to need several tries. A V release whose press never arrived, after Ctrl was held and before any other key, is now that swallowed Ctrl+V and pastes once. A plain V tap (the Move tool) and the web build, which always gets the key-down, are unchanged. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/shortcuts.rs | 69 ++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 41705ba933..99613d5a26 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -233,6 +233,10 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option /// (the key-down's default action) arrives as `Event::Paste` in the same frame. A clipboard event /// right after its own ⌘ key press is that press, not a second one (#2638). /// +/// With an image on the clipboard the V release is all that reaches us, and ⌘ is often let go +/// first: a V release whose press never arrived, after ⌘ was held and before any other key, is +/// that swallowed ⌘V too (#6329). Releasing in either order then pastes. +/// /// Ctrl+Insert copies and Shift+Insert pastes: egui-winit maps them on Windows (a Shift+Insert /// release without a paste event pastes the image, like ⌘V), and we map them here on Linux. pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInput) { @@ -279,18 +283,31 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu return; } let seen = egui::Id::new("pc-paste-key-seen"); + // A V press without ⌘ that reached us (the Move tool's key): its release is not a paste. + let plain_v = egui::Id::new("pc-plain-v-down"); + // ⌘ was held since the last key press without it: a swallowed ⌘V can be under way. + let command_lately = egui::Id::new("pc-paste-command-lately"); // The modifiers held at each event: last frame's, updated as the events report changes. let mut mods = ctx.input(|i| i.modifiers); + if mods.command { + ctx.data_mut(|d| d.insert_temp(command_lately, true)); + } let mut out = Vec::with_capacity(raw.events.len()); // The last ⌘C/⌘X/⌘V press this frame whose clipboard event hasn't arrived (yet). let mut unpaired: Option = None; for e in raw.events.drain(..) { if let egui::Event::ModifiersChanged(m) | egui::Event::Key { modifiers: m, .. } = &e { mods = *m; + if mods.command { + ctx.data_mut(|d| d.insert_temp(command_lately, true)); + } } if let Some((key, held)) = clipboard_press(&e, mods, cfg!(target_os = "windows")) { if key == Key::V { - ctx.data_mut(|d| d.insert_temp(seen, true)); + ctx.data_mut(|d| { + d.insert_temp(seen, true); + d.remove_temp::(plain_v); + }); } if unpaired.take_if(|k| *k == key).is_none() { out.push(press(key, held)); @@ -304,15 +321,32 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu egui::Event::Key { key: key @ (Key::C | Key::X | Key::V), pressed: true, modifiers, .. } if modifiers.command => { unpaired = Some(*key); if *key == Key::V { - ctx.data_mut(|d| d.insert_temp(seen, true)); + ctx.data_mut(|d| { + d.insert_temp(seen, true); + d.remove_temp::(plain_v); + }); } } + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command => { + ctx.data_mut(|d| { + d.remove_temp::(command_lately); + if *key == Key::V { + d.insert_temp(plain_v, true); + } + }); + } // A paste key's release ends that press, with or without ⌘ still held (else a stale // mark would swallow the next image paste); without a press it is the paste itself. egui::Event::Key { key: key @ (Key::V | Key::Insert), pressed: false, modifiers, .. } => { let had_press = ctx.data_mut(|d| d.remove_temp::(seen)).unwrap_or(false); + let had_plain_press = *key == Key::V && ctx.data_mut(|d| d.remove_temp::(plain_v)).unwrap_or(false); + // Taken: one swallowed ⌘V is one paste. + let lately = *key == Key::V && ctx.data_mut(|d| d.remove_temp::(command_lately)).unwrap_or(false); let held = match key { Key::V if modifiers.command => Some(*modifiers), + // ⌘ let go before V: the press egui-winit swallowed was ⌘V (#6329). The web + // always forwards the key-down, so there a lone release is no paste. + Key::V if lately && !had_plain_press && !cfg!(target_arch = "wasm32") => Some(Modifiers::COMMAND), Key::Insert if cfg!(target_os = "windows") && modifiers.shift && !modifiers.ctrl => Some(Modifiers::COMMAND), _ => None, }; @@ -1011,6 +1045,37 @@ mod tests { assert_eq!(presses(vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1, "image paste"); } + /// #6329: with an image on the clipboard egui-winit swallows the ⌘V press and sends only the V + /// release, which carries no ⌘ when ⌘ was let go first: that release still pastes, once. A V + /// pressed and released on its own (the Move tool) never pastes. + #[test] + fn an_image_paste_released_after_command_still_pastes() { + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let pastes = |ctx: &egui::Context, events: Vec| { + let mut r = egui::RawInput { events, ..Default::default() }; + clipboard_keys(ctx, false, &mut r); + r.events.iter().filter(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)).count() + }; + let none = Modifiers::NONE; + // ⌘ down, (the swallowed V press,) ⌘ up, V up: in one frame or over several. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), egui::Event::ModifiersChanged(none), key(Key::V, false, none)]), 1); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND)]), 0); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(none)]), 0); + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 1, "released after ⌘"); + // Once: another lone release without ⌘ held again doesn't paste again. + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 0); + // A plain V tap after ⌘ was used is the Move tool, not a paste. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), egui::Event::ModifiersChanged(none)]), 0); + assert_eq!(pastes(&ctx, vec![key(Key::V, true, none), key(Key::V, false, none)]), 0); + // Without ⌘ at all, a lone V release is nothing. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 0); + // Released with ⌘ still held it pastes as before. + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1); + } + /// #2638: on the web one ⌘V brings its key press and the browser's paste event; that is one paste. #[test] fn web_paste_key_and_paste_event_are_one_press() { From 2301ecf9e9a3fa2807870ac5947a76742f1bdc5d Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:33:35 -0400 Subject: [PATCH 0562/1452] fix(linux): file dialog probe sees a portal with no FileChooser backend (#5739) (#6422) The probe read the FileChooser `version` property and only counted a few D-Bus error names as "absent". zbus wraps property errors in `Error::FDO`, and a portal with no FileChooser backend doesn't export the interface at all, so the read fails with `InvalidArgs` ("No such interface"). Neither matched, the probe said "inconclusive", and Save As / Export did nothing without the "No file dialog can open" notice. Introspect the portal object and look for the interface instead, and treat InvalidArgs, UnknownMethod and NameHasNoOwner (as `FDO` or `MethodError`) as absent. Fixes #5739 Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/file_dialog_probe.rs | 129 ++++++++++++++++++++--- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/apps/photocraft/src/file_dialog_probe.rs b/apps/photocraft/src/file_dialog_probe.rs index 9ee14c0afb..eed74a58f2 100644 --- a/apps/photocraft/src/file_dialog_probe.rs +++ b/apps/photocraft/src/file_dialog_probe.rs @@ -70,25 +70,61 @@ pub fn start() { /// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the /// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply). +/// +/// Asks the portal object to introspect itself rather than reading the interface's `version` +/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without +/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check +/// didn't count as absent (#5739). The interface list answers the question directly. #[cfg(target_os = "linux")] fn file_chooser_portal() -> Option { let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else { return Some(false); }; - let proxy = zbus::blocking::proxy::Builder::>::new(&conn) - .destination("org.freedesktop.portal.Desktop") - .and_then(|b| b.path("/org/freedesktop/portal/desktop")) - .and_then(|b| b.interface("org.freedesktop.portal.FileChooser")) - .and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build()) - .ok()?; - match proxy.get_property::("version") { - Ok(_) => Some(true), - Err(zbus::Error::MethodError(name, _, _)) => { - let name = name.as_str(); - let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n)); - absent.then_some(false) - } - Err(_) => None, + let reply = conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.DBus.Introspectable"), + "Introspect", + &(), + ); + match reply { + Ok(message) => match message.body().deserialize::() { + Ok(xml) => Some(interface_listed(&xml)), + Err(_) => None, + }, + Err(e) => classify_error(&e), + } +} + +/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as +/// `FileChooser2`). +#[cfg(target_os = "linux")] +fn interface_listed(xml: &str) -> bool { + ["\"", "'"].iter().any(|quote| xml.contains(&format!(" Option { + use zbus::fdo::Error as Fdo; + // Names a bus daemon or portal answers with when the thing asked about is absent. + const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"]; + match e { + zbus::Error::FDO(fdo) => matches!( + **fdo, + Fdo::ServiceUnknown(_) + | Fdo::NameHasNoOwner(_) + | Fdo::UnknownObject(_) + | Fdo::UnknownInterface(_) + | Fdo::UnknownMethod(_) + | Fdo::UnknownProperty(_) + | Fdo::InvalidArgs(_) + ) + .then_some(false), + zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false), + _ => None, } } @@ -126,4 +162,69 @@ mod tests { assert!(!on_path("kdialog", &path)); std::fs::remove_dir_all(&dir).unwrap(); } + + #[cfg(target_os = "linux")] + mod portal { + use super::super::*; + + const WITH_FILE_CHOOSER: &str = r#" + + + + + + +"#; + const WITHOUT_FILE_CHOOSER: &str = r#" + + +"#; + + fn method_error(name: &'static str) -> zbus::Error { + let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap(); + let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap(); + zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply) + } + + #[test] + fn the_interface_list_says_whether_a_file_chooser_is_offered() { + assert!(interface_listed(WITH_FILE_CHOOSER)); + assert!(interface_listed(""), "single quotes"); + assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend"); + assert!(!interface_listed(r#""#), "longer name"); + assert!(!interface_listed("")); + } + + #[test] + fn dbus_errors_for_a_missing_interface_mean_absent() { + // zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as + // `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface") + // when no backend offers the interface (#5739). + for e in [ + zbus::fdo::Error::InvalidArgs("No such interface".into()), + zbus::fdo::Error::UnknownInterface("x".into()), + zbus::fdo::Error::ServiceUnknown("x".into()), + zbus::fdo::Error::NameHasNoOwner("x".into()), + zbus::fdo::Error::UnknownObject("x".into()), + ] { + let desc = format!("{e:?}"); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}"); + } + } + + #[test] + fn method_error_names_still_count_as_absent() { + for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] { + assert_eq!(classify_error(&method_error(name)), Some(false), "{name}"); + } + assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None); + } + + #[test] + fn inconclusive_errors_are_not_absent() { + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None); + assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None); + } + } } From f6f53f657d99ecec2d2825b87af89514f8d1d0ba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:33:40 +0200 Subject: [PATCH 0563/1452] Reject notes.set moves that make the popup non-finite (#6174) Found while testing crates/engine/src/notes_cmds.rs:105 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 ++++ crates/engine/src/notes_cmds.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 30e50bb007..3948e2992b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -165,3 +165,7 @@ sync_github_name = true [people.1Git2Clone] sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 54bbd60b7a..a57eb3d548 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -106,6 +106,9 @@ fn set(s: &mut Session, p: &Value) -> Result { n.position = [nx, ny]; // The popup travels with its icon. n.popup = [n.popup[0] + dx, n.popup[1] + dy, n.popup[2] + dx, n.popup[3] + dy]; + if !n.popup.iter().all(|v| v.is_finite()) { + return Err(bad(CMD, "the move is too far: the note popup would leave the representable range")); + } } if n == d.notes[i] { return Ok(json!({"index": i, "note": note_json(i, &n), "changed": false})); @@ -279,6 +282,19 @@ mod tests { assert!(s.execute("notes.set", json!({"index": 0})).is_err()); } + #[test] + fn huge_move_is_rejected_and_document_stays_saveable() { + let mut s = session(); + s.execute("notes.add", json!({"x": 1.7e308, "y": 0})).unwrap(); + let before = s.active().unwrap().doc.notes[0].clone(); + let past = s.active().unwrap().history.past_len(); + assert!(s.execute("notes.set", json!({"index": 0, "x": -1.7e308})).is_err()); + let a = s.active().unwrap(); + assert_eq!(a.doc.notes[0], before); + assert_eq!(a.history.past_len(), past); + assert!(photocraft_format::save_to_bytes(&a.doc, &Default::default()).is_ok()); + } + #[test] fn bad_params() { let mut s = session(); From 068ca572107355a490a5b5896dad64580efbe7e2 Mon Sep 17 00:00:00 2001 From: ash Date: Sun, 18 Oct 2026 13:03:45 +0530 Subject: [PATCH 0564/1452] Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop (#6660) * Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop In Quick Mask mode, painting and Free Transform target the mask, not the layer. The tab still read "(Layer 1, RGB/8)", so a user who pressed Q by accident and then rotated saw the red overlay turn with the box and the layer stay put, with nothing on screen saying why. The tab now reads "(Quick Mask/8)", the same condition `paint_target` routes on. Co-Authored-By: Claude Opus 5.5 * Credit arnav-hiwarkar in the About window Co-Authored-By: Claude Opus 5.5 * Keep contributors/people.toml as on main The branch removed another contributor's entry; the added entry only repeated the username. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 16 +++++++++++++--- crates/ui-egui/src/canvas/tabs_tests.rs | 11 +++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ca0bac7c2..69ed5ebcdc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2496,13 +2496,23 @@ const STUDIO_TAB_PAD: f32 = 48.0; const STUDIO_TAB_GAP: f32 = 4.0; /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask -/// is targeted; the Background layer's name is omitted. +/// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { + use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.documents().get(i) else { return String::new() }; let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); - let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id)); + // Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free + // Transform change the red overlay, so the tab says so, as Photoshop's does. + let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite; + let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !quick_mask && !st.doc.is_background(l.id)); let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some()); - let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() }; + let model = if quick_mask { + tl!("Quick Mask").to_string() + } else if mask { + tl!("Layer Mask").to_string() + } else { + mode_label(&st.doc).to_string() + }; let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default(); // A save in progress: "name @ 100% (Layer 1, RGB/16) - Saving 45%", as Photoshop does (#3389). let saving = crate::jobs_ui::save_progress(app, st.doc.id).map(|s| format!(" - {s}")).unwrap_or_default(); diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 4cc2bca0f0..642ea413f4 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -166,3 +166,14 @@ fn ctrl_tab_with_one_document_or_a_focused_field() { } assert!(!Focus::Widget.allows(&crate::shortcuts::parse("Tab").unwrap()), "a focused widget keeps plain Tab"); } + +#[test] +fn quick_mask_mode_names_the_quick_mask_in_the_tab() { + let mut h = harness(1, ThemeKind::Pro); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Layer 1, RGB/8)*"); + h.state_mut().run("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Quick Mask/8)*"); + h.state_mut().run("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Layer 1, RGB/8)*"); +} From b1de6e8ade936cd69478b45883504e5902f6be3b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:33:50 +0200 Subject: [PATCH 0565/1452] Preset Manager: renaming or deleting a brush keeps the current preset and names consistent (#5681) (#5682) Edit > Presets > Preset Manager renamed and deleted brush presets directly, so a rename left the current preset pointing at the old name (brush.presets.update then failed), accepted a case-insensitive duplicate name, and a delete left the current preset dangling. It now uses the same rules as brush.presets.rename / brush.presets.delete through two small shared helpers (rename_at, forget_deleted_preset). Patterns and custom shapes are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds.rs | 10 ++++++-- crates/engine/src/brush_preset_cmds.rs | 16 +++++++++---- crates/engine/src/edit_menu_cmds.rs | 14 +++++------ crates/engine/src/edit_menu_cmds/tests.rs | 29 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 5e1ba2f82e..3de6bc84a1 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -793,13 +793,19 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result { if s.tools.presets.len() == before { return Err(bad(cmd, format!("no brush preset named `{name}`"))); } - if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) { + forget_deleted_preset(s, &name); + Ok(json!({ "count": s.tools.presets.len() })) +} + +/// After preset `name` was deleted (here or in Edit › Presets › Preset Manager): the current +/// brush no longer comes from a preset, and the library is saved. +pub(crate) fn forget_deleted_preset(s: &mut Session, name: &str) { + if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(name)) { s.tools.current_preset = None; // The remembered brush names the preset it was picked from: the name went away. s.tools.brush_changed(); } s.brush_presets_changed(); - Ok(json!({ "count": s.tools.presets.len() })) } /// Overwrite an existing preset with the current brush (or the current brush plus a `brush` diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 46331126ac..1c126ec477 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -109,21 +109,29 @@ fn rename(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.rename"; let i = find(s, p, "name", cmd)?; let new = text(p, "newName", cmd)?; - if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) { + rename_at(s, i, &new, cmd)?; + Ok(json!({ "name": new })) +} + +/// Rename preset `i` (shared by Brush Settings and Edit › Presets › Preset Manager): refuses a +/// name another preset has (case-insensitively), makes the preset the user's and keeps the +/// current preset pointing at it. +pub(crate) fn rename_at(s: &mut Session, i: usize, new: &str, cmd: &str) -> Result<()> { + if index_of(&s.tools.presets, new).is_some_and(|j| j != i) { return Err(bad(cmd, format!("a brush preset named `{new}` already exists"))); } let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?; if pr.name != new { let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name)); - pr.name = new.clone(); + pr.name = new.to_string(); pr.builtin = false; if was_current { - s.tools.current_preset = Some(new.clone()); + s.tools.current_preset = Some(new.to_string()); s.tools.brush_changed(); } s.brush_presets_changed(); } - Ok(json!({ "name": new })) + Ok(()) } fn move_preset(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index da9a4d0482..c5f312e2cb 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -1306,18 +1306,18 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result { let new = p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string(); match kind.as_str() { - "brushes" => { - // A renamed built-in is the user's preset now (and persists as one). - let b = &mut s.tools.presets[i]; - b.name = new; - b.builtin = false; - } + // Same rules as Brush Settings: no duplicate names (case-insensitive), the current + // preset follows, a renamed built-in is the user's preset now (and persists as one). + "brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?, "patterns" => s.patterns.items[i].name = new, _ => s.edit_state.custom_shapes[i].name = new, } } "delete" => match kind.as_str() { - "brushes" => drop(s.tools.presets.remove(i)), + "brushes" => { + let gone = s.tools.presets.remove(i); + crate::brush_cmds::forget_deleted_preset(s, &gone.name); + } "patterns" => drop(s.patterns.items.remove(i)), _ => drop(s.edit_state.custom_shapes.remove(i)), }, diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index bc274795a8..ecbb8a7cda 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -473,6 +473,35 @@ fn define_custom_shape_and_preset_manager() { assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "import", "data": {"format": "other"}})).is_err()); } +#[test] +fn preset_manager_brush_rename_follows_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Chalk", "newName": "Chalky"})).unwrap(); + assert_eq!(s.tools.current_preset.as_deref(), Some("Chalky")); + s.execute("brush.presets.update", json!({})).unwrap(); +} + +#[test] +fn preset_manager_brush_rename_refuses_duplicate_name() { + let mut s = Session::new(); + let before: Vec = s.tools.presets.iter().map(|b| b.name.clone()).collect(); + let r = s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "hard round"})); + assert!(r.is_err(), "a case-insensitive duplicate name must be refused"); + assert_eq!(s.tools.presets.iter().map(|b| b.name.clone()).collect::>(), before); + // Renaming to a different case of its own name is fine. + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "soft round"})).unwrap(); +} + +#[test] +fn preset_manager_brush_delete_clears_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "delete", "kind": "brushes", "name": "Chalk"})).unwrap(); + assert!(!s.tools.presets.iter().any(|b| b.name == "Chalk")); + assert_eq!(s.tools.current_preset, None); +} + #[test] fn define_brush_preset_wraps_define_from_selection() { let mut s = session(8); From 0f0f58c2ab369a07241e0dcfb3d1cf6792829488 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:33:55 +0200 Subject: [PATCH 0566/1452] PSD: layer_tree_max_depth hoists layers under unclosed dividers past the cap (#5758) (#5759) Frames past the cap collected nothing, so the layers under dividers that are never closed were lost once those dividers nested past max_depth, and nothing was left to hoist at the end. A first pass now matches dividers to folders and only closed dividers open a frame, so every frame's depth is the final depth of its nodes and the capped tree equals layer_tree cut at max_depth, still at most max_depth + 1 levels deep. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/psd/src/tree.rs | 29 ++++++++++++++++++--- crates/psd/tests/tree.rs | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/psd/src/tree.rs b/crates/psd/src/tree.rs index 75ea750e6a..606d9b2087 100644 --- a/crates/psd/src/tree.rs +++ b/crates/psd/src/tree.rs @@ -68,10 +68,28 @@ impl PsdFile { /// [`layer_tree`](Self::layer_tree) with at most `max_depth` levels of /// groups: a group nested `max_depth` or more groups deep (a root node is - /// at depth 0) keeps its node but not its children. Built iteratively, so + /// at depth 0) keeps its node but not its children. Layers under a divider + /// that is never closed sit at the enclosing level, as in `layer_tree`. Built iteratively, so /// no file can make it recurse, and the result is no deeper than /// `max_depth + 1` levels, so walking or dropping it recursively is bounded. pub fn layer_tree_max_depth(&self, max_depth: usize) -> Vec { + let layers = self.layers(); + // First pass: find the dividers a folder closes. A divider that is never closed opens no + // group; its layers belong to the enclosing level, so it opens no frame and every + // frame's depth is the final depth of its nodes (#5758). + let mut closed = vec![false; layers.len()]; + let mut open: Vec = Vec::new(); + for (i, rec) in layers.iter().enumerate() { + match rec.section_type() { + SectionType::BoundingDivider => open.push(i), + SectionType::OpenFolder | SectionType::ClosedFolder => { + if let Some(slot) = open.pop().and_then(|d| closed.get_mut(d)) { + *slot = true; + } + } + _ => {} + } + } // Stack of (divider index, children); frame `k` holds the nodes at depth `k`. Frames // past `max_depth` are tracked (to match dividers to folders) but collect nothing. let mut stack: Vec<(Option, Vec)> = vec![(None, Vec::new())]; @@ -82,9 +100,13 @@ impl PsdFile { top.1.push(node); } }; - for (i, rec) in self.layers().iter().enumerate() { + for (i, rec) in layers.iter().enumerate() { match rec.section_type() { - SectionType::BoundingDivider => stack.push((Some(i), Vec::new())), + SectionType::BoundingDivider => { + if closed.get(i).copied().unwrap_or(false) { + stack.push((Some(i), Vec::new())); + } + } SectionType::OpenFolder | SectionType::ClosedFolder => { let (divider, children) = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) }; push(&mut stack, LayerNode::Group { index: i, divider, children }); @@ -92,6 +114,7 @@ impl PsdFile { _ => push(&mut stack, LayerNode::Layer { index: i }), } } + // Only closed dividers open frames, so none is left here; this just guards the invariant. while stack.len() > 1 { let (_, children) = stack.pop().unwrap_or_default(); if let Some(top) = stack.last_mut() { diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/tree.rs index ea4eaeec2a..ef448651fc 100644 --- a/crates/psd/tests/tree.rs +++ b/crates/psd/tests/tree.rs @@ -137,6 +137,60 @@ fn layer_tree_max_depth_drops_deeper_children() { assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[],top"); } +fn depth(nodes: &[LayerNode]) -> usize { + // Iterative so a deep tree cannot overflow the test's stack. + let mut max = 0; + let mut todo: Vec<(&LayerNode, usize)> = nodes.iter().map(|n| (n, 1)).collect(); + while let Some((n, d)) = todo.pop() { + max = max.max(d); + todo.extend(n.children().iter().map(|c| (c, d + 1))); + } + max +} + +/// #5758: layers under a divider that is never closed are hoisted to the enclosing level, as +/// `layer_tree` does, also when the cap is shallower than the unclosed divider. +#[test] +fn layer_tree_max_depth_hoists_unclosed_divider_children() { + let f = file_with(vec![rec("a", None), rec("", END), rec("b", None), rec("c", None)]); + assert_eq!(names(&f, &f.layer_tree()), "a,b,c"); + assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "a,b,c"); + assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "a,b,c"); +} + +/// #5758: a closed group inside unclosed dividers sits at the depth it ends up at after hoisting, +/// so the cap keeps its node and drops only its deeper children. +#[test] +fn layer_tree_max_depth_keeps_closed_group_inside_unclosed_dividers() { + let mut v = vec![rec("", END), rec("", END)]; + v.extend([rec("", END), rec("", END), rec("x", None), rec("g1", OPEN), rec("g0", OPEN)]); + let f = file_with(v); + assert_eq!(names(&f, &f.layer_tree()), "g0[g1[x]]"); + assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "g0[g1[]]"); + assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[]"); +} + +/// #5758: thousands of unclosed dividers around a closed chain stay bounded by the cap and keep +/// every layer the full tree has. +#[test] +fn layer_tree_max_depth_bounds_deep_unclosed_chain() { + let mut v = Vec::new(); + for i in 0..16_000 { + v.push(rec("", END)); + v.push(rec(&format!("l{i}"), None)); + } + v.extend([rec("", END), rec("", END), rec("", END), rec("x", None)]); + v.extend([rec("g2", OPEN), rec("g1", OPEN), rec("g0", OPEN)]); + let f = file_with(v); + let full = f.layer_tree(); + assert_eq!(full.len(), 16_001); + assert_eq!(depth(&full), 4); + let capped = f.layer_tree_max_depth(1); + assert!(depth(&capped) <= 2); + assert_eq!(capped.len(), 16_001); + assert_eq!(names(&f, &capped[16_000..]), "g0[g1[]]"); + assert_eq!(names(&f, &capped[..2]), "l0,l1"); +} #[test] fn folder_without_divider_is_empty_group() { let f = file_with(vec![rec("a", None), rec("G", OPEN), rec("b", None)]); From ea647146db28f94085f704367252bba77969b0b9 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 18 Oct 2026 00:34:00 -0700 Subject: [PATCH 0567/1452] Swatches: the .ase reader refuses files with more than 10,000 groups (#5811) photocraft_psd::ase::parse counted colors toward MAX_SWATCHES but not group starts, so the number of groups it read grew with the file's size. An empty group start is six bytes, and parse held about 64 bytes for each: in a test build, parsing a 60 MB file of 10,000,000 empty group starts raised the process's peak memory from 63 MB to 705 MB. swatches.import reads files of up to 512 MiB, and read_file built a group for each group start before the import refused more than 10,000 groups. parse now counts group starts and returns LimitExceeded past MAX_GROUPS (10,000), the most groups the Swatches panel holds. Files with at most 10,000 groups parse as before, so every file the import accepted still loads. Files with more were refused before as well; now parse refuses them before it holds more than 10,000 groups. write also refuses more than MAX_GROUPS groups, as it refuses more than MAX_SWATCHES colors. ase_group_cap_is_enforced_on_hostile_input parses 10,001 empty group starts and expects LimitExceeded, then parses the same file with one block fewer and expects all 10,000 groups. too_many_groups checks that write refuses 10,001 groups and that 10,000 groups are written and read back. --- crates/psd/src/ase.rs | 29 ++++++++++++++++++++++++---- crates/psd/tests/swatch_malformed.rs | 26 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/psd/src/ase.rs b/crates/psd/src/ase.rs index 7433c4797e..f63db5481a 100644 --- a/crates/psd/src/ase.rs +++ b/crates/psd/src/ase.rs @@ -22,6 +22,8 @@ use crate::io::{Reader, WriteExt}; pub const SIGNATURE: &[u8; 4] = b"ASEF"; /// Most colour entries read from one file. pub const MAX_SWATCHES: usize = 100_000; +/// Most groups read from one file. +pub const MAX_GROUPS: usize = 10_000; /// Longest name read or written, in UTF-16 code units. pub const MAX_NAME_UNITS: usize = 1024; // `put_name` writes the name length as `units.len() as u16 + 1`, so the cap must leave room in a @@ -172,9 +174,9 @@ fn read_color(body: &mut Reader<'_>) -> Result { } /// Parse an `.ase` file. A wrong signature or version, a truncated block, a block count beyond -/// the data or more than [`MAX_SWATCHES`] colours is an error; never panics. Unknown block -/// types are skipped, a group end without a start is ignored, and a group left open at the end -/// (or a group started inside another) is closed. +/// the data, more than [`MAX_SWATCHES`] colors or more than [`MAX_GROUPS`] groups is an error; +/// never panics. Unknown block types are skipped, a group end without a start is ignored, and a +/// group left open at the end (or a group started inside another) is closed. pub fn parse(data: &[u8]) -> Result { let mut r = Reader::new(data); let sig: [u8; 4] = r.array()?; @@ -192,12 +194,17 @@ pub fn parse(data: &[u8]) -> Result { let mut entries = Vec::new(); let mut open: Option<(String, Vec)> = None; let mut total = 0usize; + let mut groups = 0usize; for _ in 0..count { let kind = r.u16()?; let len = u64::from(r.u32()?); let mut body = r.sub(len)?; match kind { GROUP_START => { + groups += 1; + if groups > MAX_GROUPS { + return Err(PsdError::LimitExceeded("too many groups in an .ase file")); + } if let Some((name, swatches)) = open.take() { entries.push(AseEntry::Group { name, swatches }); } @@ -279,11 +286,15 @@ fn put_color(out: &mut Vec, s: &AseSwatch) -> Result<()> { } /// Write an `.ase` file (version 1.0). Fails only when a block or the block count overflows the -/// format's 32-bit fields or there are more than [`MAX_SWATCHES`] colours. +/// format's 32-bit fields or there are more than [`MAX_SWATCHES`] colors or more than +/// [`MAX_GROUPS`] groups. pub fn write(file: &AseFile) -> Result> { if file.swatch_count() > MAX_SWATCHES { return Err(PsdError::LimitExceeded("too many swatches for an .ase file")); } + if file.entries.iter().filter(|e| matches!(e, AseEntry::Group { .. })).count() > MAX_GROUPS { + return Err(PsdError::LimitExceeded("too many groups for an .ase file")); + } let mut body = Vec::with_capacity(64 * file.swatch_count().max(1)); let mut blocks = 0u64; for e in &file.entries { @@ -415,4 +426,14 @@ mod tests { let f = AseFile { entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![s; MAX_SWATCHES + 1] }] }; assert!(write(&f).is_err()); } + + #[test] + fn too_many_groups() { + let g = AseEntry::Group { name: "g".into(), swatches: vec![] }; + let mut f = AseFile { entries: vec![g; MAX_GROUPS + 1] }; + assert!(write(&f).is_err()); + // At the limit the file is written, and every group reads back. + f.entries.pop(); + assert_eq!(parse(&write(&f).unwrap()).unwrap().entries.len(), MAX_GROUPS); + } } diff --git a/crates/psd/tests/swatch_malformed.rs b/crates/psd/tests/swatch_malformed.rs index 8fa88e7d68..91a83b4285 100644 --- a/crates/psd/tests/swatch_malformed.rs +++ b/crates/psd/tests/swatch_malformed.rs @@ -444,6 +444,32 @@ fn ase_swatch_cap_is_enforced_on_hostile_input() { assert!(ase::parse(&f).is_ok(), "a file exactly at MAX_SWATCHES must still parse"); } +/// A file with more group starts than [`ase::MAX_GROUPS`] must be refused. Each block here is an +/// empty group start, six bytes long. +#[test] +fn ase_group_cap_is_enforced_on_hostile_input() { + let mut block = Vec::new(); + block.extend_from_slice(&0xC001u16.to_be_bytes()); // block type: group start + block.extend_from_slice(&0u32.to_be_bytes()); // empty body: no name + + let total = ase::MAX_GROUPS + 1; + let mut f = b"ASEF".to_vec(); + f.extend_from_slice(&1u16.to_be_bytes()); // major + f.extend_from_slice(&0u16.to_be_bytes()); // minor + f.extend_from_slice(&(total as u32).to_be_bytes()); + for _ in 0..total { + f.extend_from_slice(&block); + } + assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_GROUPS must be refused"); + // With one block fewer the file is exactly at the cap and parses. + f.truncate(f.len() - block.len()); + let trimmed = total - 1; + // signature (4) + major (2) + minor (2) puts the block count at offset 8. + f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes()); + let parsed = ase::parse(&f).expect("a file exactly at MAX_GROUPS must still parse"); + assert_eq!(parsed.entries.len(), ase::MAX_GROUPS, "every group up to the cap is kept"); +} + /// A name longer than [`ase::MAX_NAME_UNITS`] is truncated on read rather than refused: the file /// is legal, only the name is long, and `read_name` is the only thing standing between the declared /// length and the string it builds. From c41a7957f9f7c45b837f6fefb18b97dfe478f5c8 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:34:05 -0700 Subject: [PATCH 0568/1452] fix(io): exclude transparent palette entries for opaque PNG pixels (#5869) --- crates/io/src/flat.rs | 22 ++++++--- crates/io/tests/modes.rs | 103 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index a18367467a..674c605610 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -810,14 +810,24 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> match doc.mode { ColorMode::Indexed if format == Format::Png => { let Some(table) = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256) else { return Ok(None) }; + // PNG transparency belongs to a palette index, not its RGB value. Keep the + // transparent entry out of opaque lookup, even when it duplicates an opaque colour. + let (opaque_indices, colors): (Vec, Vec<_>) = + table.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != table.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip(); + let opaque = photocraft_doc::ColorTable { colors, transparent: None }; let mut idx = try_buffer(doc.size.area() as usize, 1)?; - let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { - idx.extend(band.px.iter().map(|p| match table.transparent { - Some(t) if p[3] < 0.5 => t, - _ => table.nearest([p[0], p[1], p[2]]) as u8, - })); + photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), IoError> { + for p in &band.px { + let i = match table.transparent { + Some(t) if p[3] < 0.5 => t, + _ => *opaque_indices + .get(opaque.nearest([p[0], p[1], p[2]])) + .ok_or_else(|| IoError::Unsupported("Indexed PNG palette has no opaque entry; add an opaque colour or convert to RGB".into()))?, + }; + idx.push(i); + } Ok(()) - }); + })?; // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; diff --git a/crates/io/tests/modes.rs b/crates/io/tests/modes.rs index f33fe5927c..c166766215 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/modes.rs @@ -25,6 +25,109 @@ fn indexed_png_is_palette_png() { assert!(!j.bytes.is_empty()); } +#[test] +fn indexed_png_keeps_duplicate_palette_colors_opaque() { + // PLTE entries may have identical RGB but different tRNS alpha. RGB-only lookup used to + // choose the first red entry for both pixels, making the opaque one disappear on save. + let palette = [[255, 0, 0], [0, 255, 0], [255, 0, 0], [0, 0, 255]]; + for transparent in [0, 2] { + let png = photocraft_codecs::encode_png_indexed(4, 1, &[0, 1, 2, 3], &palette, Some(transparent), None).unwrap(); + let d = photocraft_io::import("in.png", &png).unwrap().document; + assert_eq!(d.mode, ColorMode::Indexed); + let before = d.color_table.clone(); + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + let table = photocraft_codecs::png_palette(&r.bytes).unwrap().unwrap(); + assert_eq!(table.colors, palette, "palette order must not change"); + assert_eq!(table.transparent, Some(transparent)); + assert_eq!(photocraft_codecs::decode(&r.bytes).unwrap().data(), photocraft_codecs::decode(&png).unwrap().data()); + let back = photocraft_io::import("out.png", &r.bytes).unwrap().document; + assert_eq!(back.color_table, before); + assert_eq!(d.color_table, before, "export must not edit the document's table"); + for x in 0..4 { + assert_eq!(back.layers[0].surface().unwrap().rgba(x, 0)[3], if x == i32::from(transparent) { 0.0 } else { 1.0 }); + } + } +} + +#[test] +fn indexed_png_uses_the_nearest_opaque_color() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for transparent in [0, 1, 2] { + let mut palette = vec![[0, 0, 255], [255, 128, 0]]; + palette.insert(usize::from(transparent), [255, 0, 0]); + let mut d = Document::with_background("m", Size::new(1, 1), ColorMode::Rgb, depth, Color::rgba(1.0, 0.0, 0.0, 1.0)); + d.mode = ColorMode::Indexed; + d.color_table = Some(ColorTable { colors: palette.clone(), transparent: Some(transparent) }); + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + let image = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(image.data(), &[255, 128, 0, 255], "{depth:?}, transparent = {transparent}"); + let table = photocraft_codecs::png_palette(&r.bytes).unwrap().unwrap(); + assert_eq!(table.colors, palette); + assert_eq!(table.transparent, Some(transparent)); + } + } +} + +#[test] +fn indexed_png_breaks_opaque_color_ties_in_palette_order() { + // Both opaque entries are equally far from magenta; the transparent entry is an exact + // match. Filtering transparency must still choose the earlier original opaque entry. + for opaque in [[[255, 0, 0], [0, 0, 255]], [[0, 0, 255], [255, 0, 0]]] { + let mut d = Document::with_background("m", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(1.0, 0.0, 1.0, 1.0)); + d.mode = ColorMode::Indexed; + d.color_table = Some(ColorTable { colors: vec![[255, 0, 255], opaque[0], opaque[1]], transparent: Some(0) }); + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + let image = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(image.data(), &[opaque[0][0], opaque[0][1], opaque[0][2], 255]); + } +} + +#[test] +fn indexed_png_keeps_the_alpha_threshold() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = Document::with_background("m", Size::new(3, 1), ColorMode::Rgb, depth, Color::rgba(1.0, 0.0, 0.0, 1.0)); + d.mode = ColorMode::Indexed; + d.color_table = Some(ColorTable { colors: vec![[255, 0, 0], [255, 0, 0]], transparent: Some(0) }); + let surface = d.layers[0].surface_mut().unwrap(); + surface.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.49]); + surface.write_pixel(1, 0, &[1.0, 0.0, 0.0, 0.5]); + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + assert_eq!(photocraft_codecs::decode(&r.bytes).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255], "{depth:?}"); + } +} + +#[test] +fn indexed_png_keeps_the_last_palette_index() { + let mut palette = vec![[0, 0, 255]; 256]; + palette[0] = [255, 0, 0]; + palette[255] = [255, 0, 0]; + for transparent in [0, 255] { + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 255], &palette, Some(transparent), None).unwrap(); + let d = photocraft_io::import("in.png", &png).unwrap().document; + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); + assert_eq!(photocraft_codecs::decode(&r.bytes).unwrap().data(), photocraft_codecs::decode(&png).unwrap().data()); + } +} + +#[test] +fn indexed_png_keeps_a_single_transparent_entry() { + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 0], &[[255, 0, 0]], Some(0), None).unwrap(); + let d = photocraft_io::import("in.png", &png).unwrap().document; + let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); + assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); + assert_eq!(photocraft_codecs::decode(&r.bytes).unwrap().data(), photocraft_codecs::decode(&png).unwrap().data()); +} + +#[test] +fn indexed_png_rejects_opaque_pixels_without_an_opaque_entry() { + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[255, 0, 0]], transparent: Some(0) }); + let err = photocraft_io::export(&d, "out.png", &Default::default()).unwrap_err(); + assert!(matches!(err, photocraft_io::IoError::Unsupported(_)), "{err}"); + assert!(err.to_string().contains("no opaque entry"), "{err}"); +} + #[test] fn duotone_exports_the_inks_as_rgb() { // Mid gray background. From f2ae78ed93d16cb3f1e48cfc955e4b7e1fad24fe Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Sun, 18 Oct 2026 15:34:11 +0800 Subject: [PATCH 0569/1452] perf(psd): 8-bit merged composite unmattes through a table, not a division per sample (#5694) (#6029) composite_rgba8 removed the white matte from 8-bit RGB composites with one integer division per colour sample (3 per pixel, ~108 million on a 36 MP file). The result depends only on the sample and its alpha, so the same expression, now unmatte_u8, fills a 256 x 256 table once per call and each sample is a lookup. Built per call, not as a static, so no binary (wasm included) grows by the table. The new test runs all 65 536 (sample, alpha) pairs through composite_rgba8 against the replaced loop, kept verbatim as the reference. --- crates/psd/src/pixels.rs | 44 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index d4948457f9..5af4903c55 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -180,6 +180,20 @@ fn color_channel_count(mode: ColorMode) -> Result { } } +/// One 8-bit sample matted against white at alpha `a`, back to straight colour (rounded, at most +/// 255). At `a == 0` there is no colour to recover and the sample is kept. +fn unmatte_u8(c: u8, a: u8) -> u8 { + let a = u32::from(a); + ((u32::from(c) + a).saturating_sub(255) * 255 + a / 2).checked_div(a).map_or(c, |straight| straight.min(255) as u8) +} + +/// [`unmatte_u8`] of every sample, one row per alpha: `table[a][c]`. Built per call (64 KiB, a +/// division per entry) rather than per pixel, and not as a static, which would add 64 KiB to +/// every binary that links this crate. +fn unmatte_u8_table() -> Vec<[u8; 256]> { + (0..=255).map(|a| std::array::from_fn(|c| unmatte_u8(c as u8, a))).collect() +} + fn unmatte_to_u8(color: &[u8], alpha: &[u8], depth: u16) -> Result> { let convert = |c: f32, a: f32| { let straight = if a > 0.0 { (c + a - 1.0) / a } else { c }; @@ -341,12 +355,11 @@ impl PsdFile { let alpha = if self.merged_has_alpha() { Some(get(cc)?) } else { None }; let mut data = interleave(h.color_mode, &color, alpha.as_deref(), &self.color_mode_data, n)?; if h.color_mode == ColorMode::Rgb && h.depth == 8 && alpha.is_some() { + let table = unmatte_u8_table(); for pixel in data.as_chunks_mut::<4>().0 { - let a = u32::from(pixel[3]); + let row = &table[usize::from(pixel[3])]; for c in pixel.iter_mut().take(3) { - if let Some(straight) = ((u32::from(*c) + a).saturating_sub(255) * 255 + a / 2).checked_div(a) { - *c = straight.min(255) as u8; - } + *c = row[usize::from(*c)]; } } } @@ -368,6 +381,29 @@ mod tests { assert_eq!(file.decode_merged().unwrap(), native); } + /// #5694: the 8-bit RGB composite unmattes through a table; every (sample, alpha) pair comes + /// out exactly as the per-pixel division it replaced (kept here as the reference). + #[test] + fn rgba8_composite_unmattes_every_sample_and_alpha_as_the_division_did() { + let mut file = crate::testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, crate::Compression::Raw, 256, 256); + file.header.channels = 4; + // Pixel (x, y): red x, green 255 - x, blue x ^ y, alpha y. + let (x, y) = (|i: usize| (i % 256) as u8, |i: usize| (i / 256) as u8); + let plane = |f: &dyn Fn(usize) -> u8| (0..256 * 256).map(f).collect::>(); + let planes = [plane(&|i| x(i)), plane(&|i| 255 - x(i)), plane(&|i| x(i) ^ y(i)), plane(&|i| y(i))]; + file.image_data = crate::ImageData::encode(crate::Compression::Raw, &planes.concat(), &file.header).unwrap(); + let mut expected: Vec = (0..256 * 256).flat_map(|i| planes.each_ref().map(|p| p[i])).collect(); + for pixel in expected.as_chunks_mut::<4>().0 { + let a = u32::from(pixel[3]); + for c in pixel.iter_mut().take(3) { + if let Some(straight) = ((u32::from(*c) + a).saturating_sub(255) * 255 + a / 2).checked_div(a) { + *c = straight.min(255) as u8; + } + } + } + assert_eq!(file.composite_rgba8().unwrap().data, expected); + } + #[test] fn oversized_layer_preview_rejects_missing_channels_before_allocating() { let mut file = crate::testgen::small(Version::Psb, crate::Compression::Raw); From e6c31f4d4a7dfb15cb0494e072b3cd9d645420f2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:34:16 +0200 Subject: [PATCH 0570/1452] Menus: rows scrolled out of view no longer open their submenus (#5896, #6225) (#6356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tall menu reopens where it was last scrolled to, so after reaching Edit › Preferences › Settings… or Window › Theme its top rows lie hidden above the view, over the menu bar. egui opens a submenu whenever the pointer is over its row, clipped or not: the hidden submenu row under the title opened on the press, and the release on the title counted as a click outside that submenu, which closed the whole menu. Hovering the top scroll arrow scrolled the menu back up, which is why that "fixed" it. Submenu rows are now drawn like `ui.menu_button` but skip egui's submenu logic while the pointer is over a part of the row that is scrolled out of view (an already open submenu, e.g. from the keyboard, is drawn as before). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/menu_nav_tests.rs | 59 ++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 34 ++++++++++++++-- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 1643e2765b..d46c50bbbf 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -844,3 +844,62 @@ fn heading_is_the_triangle_to_the_submenus_near_edge() { assert!(!heading(egui::pos2(150.0, 110.0), egui::pos2(170.0, 160.0), left)); assert!(!heading(from, from, egui::Rect::NOTHING)); } + +/// #5896, #6225: a menu scrolled down (to reach Edit › Preferences or Window › Theme) reopens +/// scrolled, with the rows above its view lying over the menu bar. egui opens a submenu when the +/// pointer is over its row even where the row is scrolled out of sight, so a hidden submenu row +/// under the title opened on the press and the click on the title then closed the whole menu. +/// Rows out of view must not open their submenus: the menu stays open. +#[test] +fn a_scrolled_menu_reopens_and_stays_open_with_a_hidden_submenu_row_under_its_title() { + use egui::{Modifiers, PointerButton}; + for top in ["Edit", "Window"] { + let mut h = harness((1280.0, 420.0, 1.0)); + open(&mut h, top); + let title = h.get_by_label(top).rect().center(); + let view = Nav::current(&h.ctx).views[0]; + assert!(Nav::current(&h.ctx).contents[0] > view.height() + 1.0, "{top} must overflow for this test to mean anything"); + // Scroll to the end, then back up until a submenu row is out of view right under the title. + h.hover_at(egui::pos2(view.center().x, view.bottom() - 2.0)); + let wheel = |h: &mut Harness<'static, PhotocraftApp>, dy: f32| { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(1); + }; + for _ in 0..60 { + wheel(&mut h, -60.0); + } + let under = |h: &Harness<'static, PhotocraftApp>| { + rows(h, 0).iter().any(|r| r.command.is_none() && r.enabled && r.rect.x_range().contains(title.x) && r.rect.y_range().contains(title.y)) + }; + for _ in 0..400 { + if under(&h) { + break; + } + wheel(&mut h, 8.0); + } + assert!(under(&h), "{top}: a submenu row scrolled under the title"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx), "{top}: Esc closed it"); + // Click the title again: press, release, as a mouse does. + h.hover_at(title); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: title, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: title, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + // Per frame after the click: is the menu open, and how many levels are drawn? + let frames: Vec<(bool, usize)> = (0..6) + .map(|_| { + h.run_steps(1); + let nav = Nav::current(&h.ctx); + (nav.rows.first().is_some_and(|r| !r.is_empty()), nav.rows.len()) + }) + .collect(); + assert!(frames.iter().all(|f| f.0), "{top}: the menu closed again right after the click: {frames:?}"); + assert!(frames.iter().all(|f| f.1 == 1), "{top}: a submenu scrolled out of view opened under the title: {frames:?}"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 074d87a8b0..7a37f0c35e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1504,12 +1504,12 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click }; ui.add_enabled_ui(enabled, |ui| { nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { - let r = ui.menu_button(title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); - if r.inner.is_some() { + let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); + if inner.is_some() { // Where the submenu hangs from, to keep it below the menu bar (#319). - nav.set_anchor(depth, r.response.rect); + nav.set_anchor(depth, response.rect); } - (r.response, ()) + (response, ()) }); }); last_was_sep = false; @@ -1517,6 +1517,32 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } } +/// A submenu row, drawn as `ui.menu_button` draws it, except that a row scrolled out of view +/// doesn't open its submenu. egui opens a submenu whenever the pointer is over its row, clipped +/// or not: a menu reopened scrolled down (after Edit › Preferences or Window › Theme) has rows +/// hidden above its view, over the menu bar, and the hidden one under the title opened on the +/// press; the release on the title then counted as a click outside that submenu and closed the +/// whole menu (#5896, #6225). An open submenu (e.g. opened from the keyboard) is drawn as usual. +fn submenu_row(ui: &mut egui::Ui, title: egui::WidgetText, content: impl FnOnce(&mut egui::Ui) -> R) -> (egui::Response, Option) { + use egui::containers::menu::{MenuState, SubMenu, SubMenuButton}; + let sub = SubMenu::id_from_widget_id(ui.next_auto_id()); + let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(sub)); + let inactive = ui.style().visuals.widgets.inactive; + if open { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let response = ui.add(egui::Button::new(title).right_text(SubMenuButton::RIGHT_ARROW)); + ui.style_mut().visuals.widgets.inactive = inactive; + // The rect egui tests the pointer against (`SubMenu::show`), against the part that can be seen. + let reach = response.rect.expand2(ui.spacing().item_spacing / 2.0); + let hidden = ui.ctx().pointer_hover_pos().is_some_and(|p| reach.contains(p) && !ui.clip_rect().contains(p)); + if hidden && !open { + return (response, None); + } + let inner = SubMenu::new().show(ui, &response, content); + (response, inner.map(|r| r.inner)) +} + /// A submenu header is enabled only while some item under it (at any depth) can run, so a /// submenu of greyed items is greyed too instead of opening onto them (#1976). fn submenu_enabled(children: &[&MenuItem]) -> bool { From 68dfaf1ae6d9dd7479002fc4c09dc7b8aa512c41 Mon Sep 17 00:00:00 2001 From: denstu473 <63150090+denstu473@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:34:21 +0200 Subject: [PATCH 0571/1452] Check unclamped transform dimensions against the pixel budget (#6493) --- crates/algo/src/transform.rs | 56 +++++++++++++++++-- crates/engine/src/transform_cmds.rs | 31 ++++++++-- .../tests/transform_budget_unclamped.rs | 49 ++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/transform_budget_unclamped.rs diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 050d08c322..3e67f1cdee 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { /// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners /// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to -/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before +/// admitting a transform: clipping these bounds must not hide an excessive scale. pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + corner_bounds(&warp_corners(src_rect, h)?) +} + +/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`]. +/// Position does not affect the price, so translating a small layer far away stays cheap. +/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse +/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output. +pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> { + let corners = warp_corners(src_rect, h)?; + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) { + return Some((u64::MAX, u64::MAX)); + } + let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor(); + let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor(); + let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil(); + let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil(); + // Float-to-integer casts saturate, including when the finite endpoints' difference overflows. + Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2))) +} + +fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> { if src_rect.is_empty() || h.inverse().is_none() { return None; } - let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] - .map(|(x, y)| h.apply(x as f64, y as f64)); - corner_bounds(&corners) + Some( + [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)), + ) } fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { @@ -510,6 +533,31 @@ fn warp( mod tests { use super::*; + #[test] + fn output_size_prices_the_unclipped_extent_independently_of_position() { + let src = Rect::new(0, 0, 24, 16); + for dx in [0.0, 900_000.0, 10_000_000.0] { + let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((26, 18))); + } + for sx in [1e7, -1e7] { + let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18))); + } + } + + #[test] + fn output_size_saturates_overflows_and_preserves_filter_support() { + let src = Rect::new(0, 0, 24, 16); + let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]); + let bounds = warp_output_bounds(src, &fractional).unwrap(); + assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height())))); + let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX))); + assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None); + assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None); + } + #[test] fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 310bb13bb9..2fafc222f7 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,6 +1,6 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; +use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; @@ -273,10 +273,13 @@ const BUDGET: u64 = 1 << 20; /// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, /// before anything is built. `what` names the surface in the message. pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { - let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; - let (w, ht) = (u64::from(b.width()), u64::from(b.height())); - if w.saturating_mul(ht) > BUDGET { - let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); + check_budget_with_limit(src, h, what, BUDGET) +} + +fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> { + let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) }; + if w.saturating_mul(ht) > budget { + let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit"); return Err(crate::allocation::error("transforming pixels", &detail)); } Ok(()) @@ -735,6 +738,24 @@ mod tests { // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + #[test] + fn huge_transform_scales_exceed_the_production_budget_before_clipping() { + let src = Rect::new(0, 0, 24, 16); + for scale in [1e7, 1e28, 1e306] { + for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] { + let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused"); + } + } + } + + #[test] + fn an_ordinary_transform_remains_within_the_production_budget() { + let src = Rect::new(0, 0, 24, 16); + let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok()); + } + /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. fn column() -> Session { let mut s = Session::new(); diff --git a/crates/engine/tests/transform_budget_unclamped.rs b/crates/engine/tests/transform_budget_unclamped.rs new file mode 100644 index 0000000000..c603bee7b5 --- /dev/null +++ b/crates/engine/tests/transform_budget_unclamped.rs @@ -0,0 +1,49 @@ +//! #6430: integration tests use the production pixel budget, not the unit tests' reduced one. +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +fn painted() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + session +} + +fn assert_refused_without_changes(session: &mut Session, command: &str, params: Value) { + let state = session.active().unwrap(); + let (document, revision, history) = (state.doc.clone(), state.revision, state.history.past_len()); + let error = session.execute(command, params).unwrap_err().to_string(); + assert!(error.contains("limit"), "{error}"); + let state = session.active().unwrap(); + assert!(state.doc == document, "refusal changed the document"); + assert_eq!((state.revision, state.history.past_len()), (revision, history)); +} + +#[test] +fn extreme_layer_scales_are_refused_before_a_clipped_strip_is_warped() { + let mut session = painted(); + session.execute("filter.render.clouds", json!({})).unwrap(); + for scale in [1e9, 1e30, 1e308, -1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "edit.transform", json!({(axis): scale})); + } + } +} + +#[test] +fn extreme_selection_scales_are_refused_without_losing_the_selection() { + let mut session = painted(); + session.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + for scale in [1e30, 1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "select.transformSelection", json!({(axis): scale})); + } + } +} From 0296c8e0b5c28d474968eda927edeb539c2a237f Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:34:27 +0200 Subject: [PATCH 0572/1452] Remove tool: every queued stroke is tinted in full (#6555) (#6642) With Remove after each stroke off, every finished stroke was fed into the one shared tint trail, whose point counter still held the previous stroke's length, so later strokes were stamped only past that count. Trail::feed_stroke starts each new stroke's count at zero. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/retouch_ui.rs | 42 +++++++++++++++++++++++++++++- crates/ui-egui/src/stroke_trail.rs | 25 ++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index adf3c8cf69..417df92b6e 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -177,7 +177,7 @@ pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { app.remove_pending_doc = Some(doc); } let diameter = app.session.tools.brush.size; - app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed(points, diameter); + app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed_stroke(points, diameter); app.remove_pending.push(points.to_vec()); app.ui.status = crate::i18n::fmt(tl!("Strokes to remove: {n} (Enter applies, Esc cancels)"), &[("n", &app.remove_pending.len().to_string())]); app.ui.status_error = false; @@ -1141,6 +1141,46 @@ mod tests { assert!(!remove_pending_commit(&mut app), "nothing left to apply"); } + #[test] + fn every_waiting_remove_stroke_is_tinted_in_full() { + // #6555: each stroke used to be stamped only past the previous stroke's point count. + let mut app = app(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + let strokes: [&[[f64; 3]]; 3] = [ + &[[10.0, 10.0, 1.0], [40.0, 10.0, 1.0], [70.0, 10.0, 1.0]], + &[[10.0, 30.0, 1.0], [40.0, 30.0, 1.0]], + &[[10.0, 50.0, 1.0], [20.0, 50.0, 1.0], [30.0, 50.0, 1.0], [50.0, 50.0, 1.0], [90.0, 50.0, 1.0]], + ]; + for s in strokes { + remove_pending_add(&mut app, s); + } + assert_eq!(app.remove_pending.len(), 3); + let t = app.remove_trail.as_ref().expect("the waiting strokes are tinted"); + let k = f64::from(t.scale()); + for s in strokes { + for w in s.windows(2) { + for i in 0..=8 { + let f = f64::from(i) / 8.0; + let (x, y) = (w[0][0] + (w[1][0] - w[0][0]) * f, w[0][1] + (w[1][1] - w[0][1]) * f); + assert_eq!(t.coverage((x / k) as usize, (y / k) as usize), 255, "({x}, {y}) on a waiting stroke is tinted"); + } + } + } + // Enter, Esc, another tool and another document all drop the tint with the strokes. + assert!(remove_pending_commit(&mut app)); + assert!(app.remove_trail.is_none()); + remove_pending_add(&mut app, strokes[0]); + assert!(remove_pending_cancel(&mut app) && app.remove_trail.is_none()); + remove_pending_add(&mut app, strokes[1]); + app.ui.tool = Tool::SpotHealing; + assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); + app.ui.tool = Tool::Remove; + remove_pending_add(&mut app, strokes[2]); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); + } + #[test] fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { let mut app = app(); diff --git a/crates/ui-egui/src/stroke_trail.rs b/crates/ui-egui/src/stroke_trail.rs index 6a5465ca7f..af09fca1c4 100644 --- a/crates/ui-egui/src/stroke_trail.rs +++ b/crates/ui-egui/src/stroke_trail.rs @@ -74,6 +74,13 @@ impl Trail { self.fed = points.len(); } + /// Stamp a whole new drag into the same mask (the Remove tool's waiting strokes, #6555): its + /// points are counted from its own start, not from where the previous drag ended. + pub fn feed_stroke(&mut self, points: &[[f64; 3]], diameter: f32) { + self.fed = 0; + self.feed(points, diameter); + } + /// Show the coverage the brush's own dabs leave along the drag, as the stroke commit renders /// it (`photocraft_paint::retouch::stroke_coverage`): soft edges for a soft brush, the size and /// shape each dab takes from pen pressure and dynamics. `brush` is asked for once, on the first @@ -224,6 +231,24 @@ mod tests { } } + #[test] + fn each_stroke_fed_whole_is_stamped_in_full() { + let mut t = Trail::new([300, 300]); + let strokes: [&[[f64; 3]]; 3] = [ + &[[50.0, 50.0, 1.0], [120.0, 50.0, 1.0], [200.0, 50.0, 1.0]], + &[[50.0, 120.0, 1.0], [120.0, 120.0, 1.0]], + &[[50.0, 200.0, 1.0], [80.0, 200.0, 1.0], [120.0, 200.0, 1.0], [160.0, 200.0, 1.0], [200.0, 200.0, 1.0], [260.0, 200.0, 1.0]], + ]; + for s in strokes { + t.feed_stroke(s, 10.0); + } + for (x, y) in [(50, 50), (85, 50), (199, 50), (50, 120), (85, 120), (119, 120), (50, 200), (65, 200), (100, 200), (259, 200)] { + assert_eq!(t.coverage(x, y), 255, "({x}, {y}) lies on a stroke"); + } + assert_eq!(t.coverage(50, 85), 0, "no capsule joins one stroke's end to the next one's start"); + assert_eq!(t.coverage(150, 160), 0); + } + #[test] fn hostile_points_and_huge_documents_are_safe() { let mut t = Trail::new([300_000, 2]); From dfb4aa70ae420223cee465a9717950308bf57385 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:34:32 +0200 Subject: [PATCH 0573/1452] Keep Color Sampler points on their pixel after flips and turns (#6720) Found while testing crates/engine/src/commands.rs:117-120 (turn) with crates/engine/src/canvas_geom.rs:224 (Turn::affine); applied in crates/engine/src/lib.rs:807-814 (Session::edit) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/commands.rs | 5 ++++- crates/engine/src/sampler_cmds.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ced8462c9e..09ea34233c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -117,7 +117,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> { fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result { if let Some(d) = s.active() { let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height)); - s.set_sampler_map(a); + // Sampler points are pixel indices, not pixel edges: map the pixel's centre, so the point + // lands on the same pixel `Turn::pixel_map` moves the pixels to (`w - 1 - x`). + let half = photocraft_geom::Affine::translate(0.5, 0.5); + s.set_sampler_map(half.then(&a).then(&photocraft_geom::Affine::translate(-0.5, -0.5))); } s.edit(label, |doc, _| { crate::canvas_geom::turn_canvas(doc, t); diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 472ecb672a..0894ab72e1 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -377,6 +377,35 @@ mod tests { assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); } + #[test] + fn flips_and_right_angle_turns_keep_points_on_their_pixel() { + use crate::canvas_geom::Turn; + for (id, turn) in [ + ("image.imageRotation.flipCanvasHorizontal", Turn::FlipHorizontal), + ("image.imageRotation.flipCanvasVertical", Turn::FlipVertical), + ("image.imageRotation.180", Turn::Rotate180), + ("image.imageRotation.90cw", Turn::Cw90), + ("image.imageRotation.90ccw", Turn::Ccw90), + ] { + // A black pixel at (3, 3) of a white 100×80 document, with a point on it. + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute(id, json!({})).unwrap(); + let (x, y) = turn.pixel_map(100, 80)(3, 3); + let p = points(&mut s); + assert_eq!(p.len(), 1, "{id}"); + assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}"); + } + } + #[test] fn a_rotated_crop_moves_points() { // The crop frame is centred on the point, so the point lands at the new canvas centre. From 866e7bfe4e414346029dcd1234bc1875cde852a0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:34:37 +0200 Subject: [PATCH 0574/1452] Upload 16-bit layer masks as R16Float instead of R32Float (#6845) Fixes #6534 Co-authored-by: Claude Haiku 5.5 --- crates/gpu/src/lib.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 690d144d68..3d60adaf1e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -143,7 +143,9 @@ enum TexKind { Rgba16F, /// GRAY8 masks copied verbatim. R8Direct, - /// Other masks as R32F. + /// 16-bit masks as R16F (the precision 16-bit pixels get as RGBA16F). + R16F, + /// 32-bit masks as R32F. R32F, } @@ -154,7 +156,8 @@ impl TexKind { Role::Content | Role::Stroke if f.sample == SampleType::U8 => TexKind::Rgba8, Role::Content | Role::Stroke => TexKind::Rgba16F, Role::Mask if f == PixelFormat::GRAY8 => TexKind::R8Direct, - Role::Mask => TexKind::R32F, + Role::Mask if f.sample == SampleType::F32 => TexKind::R32F, + Role::Mask => TexKind::R16F, } } fn format(self) -> wgpu::TextureFormat { @@ -162,6 +165,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => wgpu::TextureFormat::Rgba8Unorm, TexKind::Rgba16F => wgpu::TextureFormat::Rgba16Float, TexKind::R8Direct => wgpu::TextureFormat::R8Unorm, + TexKind::R16F => wgpu::TextureFormat::R16Float, TexKind::R32F => wgpu::TextureFormat::R32Float, } } @@ -170,6 +174,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => 4, TexKind::Rgba16F => 8, TexKind::R8Direct => 1, + TexKind::R16F => 2, TexKind::R32F => 4, } } @@ -2203,6 +2208,7 @@ fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: T } } TexKind::R8Direct => out.push((px[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8), + TexKind::R16F => out.extend(f32_to_f16(px[0]).to_le_bytes()), TexKind::R32F => out.extend(px[0].to_le_bytes()), } } @@ -2532,6 +2538,28 @@ mod tests { assert_eq!(op_words(&p, None, None, None).len() as u64, OP_UNIFORM); } + /// #6534: a 16-bit mask (16-bit documents, feathered or vector combined masks) costs two + /// bytes per pixel on the GPU, twice an 8-bit mask, not four; 32-bit masks stay R32Float. + #[test] + fn sixteen_bit_masks_upload_at_two_bytes_per_pixel() { + let gray = |sample| PixelFormat { sample, ..PixelFormat::GRAY8 }; + assert_eq!(TexKind::for_surface(Role::Mask, PixelFormat::GRAY8).bytes_per_pixel(), 1); + let k16 = TexKind::for_surface(Role::Mask, gray(SampleType::U16)); + assert_eq!(k16.bytes_per_pixel(), 2, "{k16:?}"); + assert_eq!(k16.format().block_copy_size(None), Some(2), "{k16:?}"); + let k32 = TexKind::for_surface(Role::Mask, gray(SampleType::F32)); + assert_eq!(k32.format(), wgpu::TextureFormat::R32Float); + // The texels hold the mask values (a half each), the default pixel included. + let mut s = Surface::with_default(gray(SampleType::U16), &[1.0]); + s.fill_rect(Rect::new(0, 0, 4, 1), &[0.25]); + let c = TileCoord::new(0, 0); + let bytes = convert_tile(&s, s.tile(c), k16, c); + assert_eq!(bytes.len(), (TILE_SIZE * TILE_SIZE) as usize * 2); + assert_eq!(bytes.get(..2), Some(f32_to_f16(0.25).to_le_bytes().as_slice())); + assert_eq!(bytes.get(8..10), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + assert_eq!(convert_tile(&s, None, k16, c).get(..2), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + } + /// #1774: a solid fill shares one tile across every coordinate; it is converted once, and /// every coordinate (shared or not) still gets exactly the bytes `convert_tile` gives it, at /// every depth and texture kind. @@ -2544,7 +2572,8 @@ mod tests { (PixelFormat::RGBA16, TexKind::Rgba16F), (PixelFormat::RGBA32F, TexKind::Rgba16F), (PixelFormat::GRAY8, TexKind::R8Direct), - (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R32F), + (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R16F), + (PixelFormat { sample: SampleType::F32, ..PixelFormat::GRAY8 }, TexKind::R32F), ] { let mut s = Surface::new(fmt); let px = [0.2, 0.4, 0.6, 1.0]; From a914c58a63a390bead21478f1a61b506c9228933 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:34:42 -0400 Subject: [PATCH 0575/1452] fix(ui): wide ellipse for Elliptical Marquee, dashed C + brush for Quick Selection (#6875) (#6917) The Elliptical Marquee used a dashed circle and Quick Selection a brush inside a dashed circular arc, so the two read alike. Photoshop draws the marquee as a horizontal ellipse and Quick Selection as a brush in an upright dashed rounded C. New original ellipse-dashed.svg; quick-selection.svg redrawn; tests check the ellipse is wider than tall and the C runs down the left edge. Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 5 +++-- assets/icons/ellipse-dashed.svg | 13 +++++++++++ assets/icons/quick-selection.svg | 8 +++---- crates/ui-egui/src/icon_data.rs | 2 ++ crates/ui-egui/src/icons.rs | 37 ++++++++++++++++++++++++++++---- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 assets/icons/ellipse-dashed.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1ae19efa66..cbbbe069a5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -38,11 +38,12 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | -| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/assets/icons/ellipse-dashed.svg b/assets/icons/ellipse-dashed.svg new file mode 100644 index 0000000000..f852dcf3a7 --- /dev/null +++ b/assets/icons/ellipse-dashed.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/quick-selection.svg b/assets/icons/quick-selection.svg index 454b38bae6..7e63dc66b9 100644 --- a/assets/icons/quick-selection.svg +++ b/assets/icons/quick-selection.svg @@ -9,8 +9,8 @@ stroke-linecap="round" stroke-linejoin="round" > - - - - + + + + diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 9301f709ce..7fdaf4f7d0 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -39,6 +39,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), + // Original: a dashed horizontal ellipse (Elliptical Marquee), on Lucide's grid and stroke. + ("ellipse-dashed", include_bytes!("../../../assets/icons/ellipse-dashed.svg")), ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), // Derived from Lucide's eraser (ISC): with a sparkle (Magic Eraser) / a transparency grid (Background Eraser). diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 131886ae6b..37d0b4aeab 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -87,7 +87,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Move => "move", Tool::Artboard => "artboard", Tool::RectMarquee => "square-dashed", - Tool::EllipseMarquee => "circle-dashed", + Tool::EllipseMarquee => "ellipse-dashed", Tool::SingleRowMarquee => "marquee-row", Tool::SingleColumnMarquee => "marquee-column", Tool::Brush => "brush", @@ -272,12 +272,41 @@ mod tests { assert!(union > 0, "icons must have ink at {size}px"); let overlap = shared as f32 / union as f32; assert!(overlap < 0.5, "the two glyphs must read as distinct: {overlap:.2} of their ink overlaps at {size}px"); - // The dashed selection arc must actually rasterize: more ink than the bare brush. - let brush = ink(tool_icon(Tool::Brush), size); - assert!(quick.iter().filter(|a| **a).count() > brush.iter().filter(|a| **a).count(), "the dashed arc is missing at {size}px"); + // The dashed selection "C" must actually rasterize down the left edge, where the brush has no ink. + let s = size as usize; + assert!(quick.iter().enumerate().any(|(i, a)| *a && i % s < s / 5), "the dashed selection edge is missing at {size}px"); } } + /// Ink bounding box `(width, height)` of a rasterized mask. + fn ink_extent(mask: &[bool], size: u32) -> (usize, usize) { + let s = size as usize; + let (xs, ys): (Vec, Vec) = mask.iter().enumerate().filter(|(_, a)| **a).map(|(i, _)| (i % s, i / s)).unzip(); + let span = |v: &[usize]| v.iter().max().zip(v.iter().min()).map_or(0, |(hi, lo)| hi - lo + 1); + (span(&xs), span(&ys)) + } + + #[test] + fn ellipse_marquee_icon_is_a_wide_ellipse() { + // #6875: Photoshop's Elliptical Marquee glyph is a horizontal ellipse, not a circle. + assert_ne!(tool_icon(Tool::EllipseMarquee), "circle-dashed"); + for size in [14_u32, 28] { + let (w, h) = ink_extent(&ink(tool_icon(Tool::EllipseMarquee), size), size); + assert!(w as f32 >= h as f32 * 1.15, "ellipse marquee ink is {w}x{h} at {size}px: not wider than tall"); + } + } + + #[test] + fn quick_selection_icon_is_a_tall_selection_with_a_brush() { + // #6875: Photoshop's Quick Selection glyph is a dashed, upright rounded "C" with a brush. + let size = 28_u32; + let s = size as usize; + let quick = ink(tool_icon(Tool::QuickSelection), size); + // The left edge of the selection runs most of the icon's height. + let left_rows = (0..s).filter(|y| (0..s / 5).any(|x| quick.get(y * s + x).copied().unwrap_or(false))).count(); + assert!(left_rows * 5 > s * 2, "left selection edge covers only {left_rows} of {s} rows"); + } + #[test] fn shared_catalog_preserves_existing_icons() { // Characterize the pre-extraction loader across every real asset. The UI's tint, From 44dc7c2c7736c7be5909b4fb79da3bb459614802 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:34:47 +0200 Subject: [PATCH 0576/1452] OpenRaster: layer locks no longer change silently on save (#7018) (#7019) * OpenRaster: layer locks no longer change silently on save (#7018) Co-Authored-By: Claude Opus 5.5 * ORA: the Background's position lock is not reported as lost (#7018) Every new document's Background carries a position lock, so the warning made a plain .ora unsavable in place. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/ora.rs | 9 +++++++++ crates/io/src/ora/tests.rs | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index f365154333..6d1bcfd87f 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -566,6 +566,15 @@ impl Writer<'_> { if layer.locks.all || layer.locks.pixels { attrs.push_str(" edit-locked=\"true\""); } + // OpenRaster's one edit lock reopens as Lock All; it has no position lock (#7018). + if layer.locks.pixels && !layer.locks.all { + self.warnings.layer(&layer.name, "Lock Image Pixels was saved as OpenRaster's edit lock; it reopens as Lock All"); + } + // The Background's position lock is part of being the Background, not one the user set: + // every new document has it, and OpenRaster has no Background to keep it on. + if layer.locks.position && !layer.locks.all && !self.doc.is_background(layer.id) { + self.warnings.layer(&layer.name, "Lock Position is not saved to OpenRaster"); + } if layer.locks.transparency { attrs.push_str(" alpha-preserve=\"true\""); } diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 4f0b1c4c47..c7c253ef29 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -494,6 +494,9 @@ fn lossy_documents() -> Vec<(&'static str, Document)> { }), ("clipped", with(&|d| d.layers[1].clipped = true)), ("fill opacity", with(&|d| d.layers[0].fill_opacity = 0.5)), + ("pixel lock", with(&|d| d.layers[0].locks.pixels = true)), + // Not the bottom layer: with its transparency lock, `Base` would become the Background. + ("position lock", with(&|d| d.layers[1].locks.position = true)), ("float", doc(SampleType::F32)), ("profile", with(&|d| d.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()))), ("metadata", with(&|d| d.metadata.xmp = Some("".into()))), @@ -520,6 +523,15 @@ fn plain_documents_lose_nothing() { d.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); assert_eq!(losses(&d), Vec::::new(), "{depth:?} tagged sRGB"); assert_eq!(export(&d).unwrap().warnings, Vec::::new()); + // OpenRaster's edit-locked and alpha-preserve hold Lock All and Lock Transparent Pixels. + d.layers[0].locks.all = true; + d.layers[1].locks.transparency = true; + assert_eq!(losses(&d), Vec::::new(), "{depth:?} locked"); + // A Background (every new document's bottom layer) loses nothing for its position lock. + d.layers[0].locks.all = false; + d.layers[0].locks.position = true; + assert!(d.background().is_some()); + assert_eq!(losses(&d), Vec::::new(), "{depth:?} Background"); } let mut cmyk = doc(SampleType::U8); cmyk.mode = ColorMode::Cmyk; From 295a28a50be0e82fdd2adedb193bd586f2a75a3d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:34:52 +0300 Subject: [PATCH 0577/1452] fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872) (#7140) * fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872) * test(ui): cover the Navigator zoom rectangle (#6872) --- crates/ui-egui/src/panels.rs | 162 ++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fc8e2d8f4..10476013ee 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1939,13 +1939,42 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); } let s = rect.width() / size.width.max(1) as f32; - if (resp.clicked() || resp.dragged()) + // ⌘/Ctrl-drag over the preview draws a rectangle the view then zooms to, as in Photoshop + // (#6872); a plain click or drag moves the view. + let command = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).command; + if resp.drag_started() && command { + let start = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + ui.data_mut(|d| match start { + Some(p) => { + d.insert_temp(navigator_zoom_drag_id(), p); + } + None => d.remove::(navigator_zoom_drag_id()), + }); + } + let zoom_from = ui.data(|d| d.get_temp::(navigator_zoom_drag_id())); + if let Some(from) = zoom_from { + if let Some(to) = resp.interact_pointer_pos() { + let r = Rect::from_two_pos(from, to).intersect(rect); + crate::tool_feedback::draw_ants(ui.painter(), &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); + if resp.drag_stopped() { + let doc_rect = Rect::from_min_max(((r.min - rect.min) / s).to_pos2(), ((r.max - rect.min) / s).to_pos2()); + let (canvas, ppp, aspect) = (app.last_canvas_rect, app.canvas_ppp(), app.ui.view.display_aspect()); + navigator_zoom_to(&mut app.ui.views[idx], doc_rect, [size.width, size.height], canvas.size(), ppp, aspect); + } + } + if resp.drag_stopped() || !resp.dragged() { + ui.data_mut(|d| d.remove::(navigator_zoom_drag_id())); + } + } else if (resp.clicked() || resp.dragged()) && let Some(pp) = resp.interact_pointer_pos() { let d = (pp - rect.min) / s; app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; app.ui.views[idx].fit_pending = false; } + if command && resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::ZoomIn); + } // The Navigator has its own zoom target: an unmodified wheel over the preview // should zoom even when the document canvas is configured to scroll on wheel. // Use the canvas wheel classifier to honour one ×1.1 step per notch without @@ -2024,6 +2053,42 @@ fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect { Rect::from_center_size(frame.center(), vec2(w * s, h * s)) } +/// Where a ⌘/Ctrl-drag over the Navigator's preview started, while it lasts. +fn navigator_zoom_drag_id() -> egui::Id { + egui::Id::new("navigator-zoom-drag") +} + +/// Zoom `view` so the document rectangle `doc_rect` (pixels) fills a canvas of `canvas` points, +/// centred on it, as a Navigator ⌘/Ctrl-drag does (#6872). `doc` is the document's size in +/// pixels (the view's own copy is only set once the canvas has been drawn), `ppp` the canvas's +/// device pixels per point and `aspect` the pixel aspect ratio shown; the zoom stays within the +/// zoom range. A rectangle under 2 document pixels across zooms one step in on its centre. +fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2], canvas: egui::Vec2, ppp: f32, aspect: f32) { + // Checked before clipping: `min`/`max` would turn a NaN edge into a document edge. + if ![doc_rect.min.x, doc_rect.min.y, doc_rect.max.x, doc_rect.max.y].iter().all(|v| v.is_finite()) { + return; + } + let (w, h) = (doc[0] as f32, doc[1] as f32); + let r = doc_rect.intersect(Rect::from_min_size(Pos2::ZERO, vec2(w, h))); + if r.width() < 0.0 || r.height() < 0.0 { + return; + } + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let zoom = if r.width() < 2.0 || r.height() < 2.0 || canvas.x <= 0.0 || canvas.y <= 0.0 { + crate::zoom_levels::step(view.zoom, 1, view.doc_size) + } else { + // Document columns shown across the canvas are `canvas.x / (point_zoom * aspect)`. + let point_zoom = (canvas.x / (r.width() * aspect)).min(canvas.y / r.height()); + point_zoom * ppp + }; + if !zoom.is_finite() { + return; + } + view.zoom = crate::zoom_levels::clamp(zoom, view.doc_size); + view.center = [r.center().x, r.center().y]; + view.fit_pending = false; +} + fn navigator_preview_id() -> egui::Id { egui::Id::new("navigator-preview-rect") } @@ -2086,6 +2151,101 @@ mod navigator_wheel_tests { assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged"); } + /// #6872: the zoom a Navigator ⌘/Ctrl-drag sets: the rectangle fills the canvas, centred. + #[test] + fn navigator_zoom_to_fits_the_rectangle_in_the_canvas() { + let fresh = || crate::state::View { doc_size: [200, 100], zoom: 1.0, center: [100.0, 50.0], ..Default::default() }; + let canvas = vec2(800.0, 600.0); + let r = Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0)); + // 50 × 25 px on 800 × 600 pt: the width limits it, 16 points per pixel. + let mut v = fresh(); + navigator_zoom_to(&mut v, r, [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center, v.fit_pending), (crate::zoom_levels::clamp(16.0, [200, 100]), [75.0, 37.5], false)); + // Device pixels per point and a 2:1 pixel aspect scale it as the canvas shows pixels. + let mut v = fresh(); + navigator_zoom_to(&mut v, r, [200, 100], canvas, 2.0, 2.0); + assert_eq!(v.zoom, crate::zoom_levels::clamp(16.0, [200, 100]), "8 points per pixel across, at 2 device pixels per point"); + // Off the document: only the part on it counts. + let mut v = fresh(); + navigator_zoom_to(&mut v, Rect::from_min_max(pos2(150.0, 50.0), pos2(400.0, 300.0)), [200, 100], canvas, 1.0, 1.0); + assert_eq!(v.center, [175.0, 75.0]); + // Under 2 px across: one step in on its centre. + let mut v = fresh(); + navigator_zoom_to(&mut v, Rect::from_min_max(pos2(10.0, 10.0), pos2(11.0, 40.0)), [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center), (crate::zoom_levels::step(1.0, 1, [200, 100]), [10.5, 25.0])); + // Not a number, or wholly off the document: nothing changes. + for bad in [Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0)), Rect::from_min_max(pos2(300.0, 300.0), pos2(400.0, 400.0))] { + let mut v = fresh(); + navigator_zoom_to(&mut v, bad, [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center), (1.0, [100.0, 50.0]), "{bad:?}"); + } + } + + /// One frame of the Navigator with pointer `events` and `modifiers` held. + fn frame_with(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, mut events: Vec) { + events.insert(0, Event::ModifiersChanged(modifiers)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }; + ctx.run_ui(input, |ui| navigator(app, ui)).textures_delta.clear(); + } + + /// A drag over the Navigator's preview between document points `from` and `to`. + fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, from: [f32; 2], to: [f32; 2]) { + let preview = last_navigator_preview(ctx).expect("the preview"); + let s = preview.width() / 200.0; + let at = |p: [f32; 2]| preview.min + vec2(p[0], p[1]) * s; + let (a, b) = (at(from), at(to)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a), button(a, true)]); + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a.lerp(b, 0.5))]); + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(b)]); + frame_with(app, ctx, modifiers, vec![button(b, false)]); + frame_with(app, ctx, Modifiers::NONE, vec![]); + } + + /// #6872: ⌘/Ctrl-dragging a rectangle over the preview zooms the view to it, as in + /// Photoshop; a plain drag still only moves the view. + #[test] + fn navigator_command_drag_zooms_to_the_rectangle() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + // The preview settles once the zoom row under it has been measured. + for _ in 0..3 { + frame(&mut app, &ctx, pos2(5.0, 5.0), 0.0); + } + // A plain drag pans and keeps the zoom. + drag(&mut app, &ctx, Modifiers::NONE, [50.0, 25.0], [100.0, 50.0]); + assert_eq!(app.ui.views[0].zoom, 1.0); + let c = app.ui.views[0].center; + assert!((c[0] - 100.0).abs() < 1.0 && (c[1] - 50.0).abs() < 1.0, "the view follows the pointer: {c:?}"); + // ⌘/Ctrl-drag: the rectangle fills the canvas (`last_canvas_rect`, 800 × 600 pt). + drag(&mut app, &ctx, Modifiers::COMMAND, [50.0, 25.0], [100.0, 50.0]); + let mut expected = app.ui.views[0].clone(); + navigator_zoom_to( + &mut expected, + Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0)), + [200, 100], + vec2(800.0, 600.0), + app.canvas_ppp(), + app.ui.view.display_aspect(), + ); + let v = &app.ui.views[0]; + assert!((v.zoom - expected.zoom).abs() / expected.zoom < 0.05, "{} vs {}", v.zoom, expected.zoom); + assert!( + (v.center[0] - 75.0).abs() < 1.0 && (v.center[1] - 37.5).abs() < 1.0, + "centred on the rectangle: {:?} (zoom {}, expected {:?})", + v.center, + v.zoom, + (expected.zoom, expected.center) + ); + assert!(v.zoom > 1.0 && !v.fit_pending); + assert!(ctx.data(|d| d.get_temp::(navigator_zoom_drag_id())).is_none(), "the drag is over"); + } + /// #4840: under the preview, one row as in Photoshop: the zoom in percent, Zoom Out, the /// slider and Zoom In. The buttons step the zoom like View › Zoom In / Out. #[test] From 8a5a680674e6f6c6c72fb450d3ff11202042237e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:34:58 +0200 Subject: [PATCH 0578/1452] Menus: one checkmark column keeps every label aligned (#5705) (#5809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu rows prefixed their label with "✔ ", three spaces or nothing, so unchecked toggles (Lock Guides, Lock Slices) sat at a different x than checked and plain rows, most visibly in CJK translations. Every row, submenu headers included, now reserves one fixed-width checkmark column (the width of "✔ " in the button font) and draws the mark in it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/menu_keys.rs | 45 +++++++++++++++++++++++++++ crates/ui-egui/src/menu_keys_tests.rs | 38 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 15 ++------- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..dee906ccb1 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -118,6 +118,51 @@ pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, job.into() } +/// The checkmark menu rows draw in their gutter. +pub const CHECK_MARK: &str = "✔ "; + +/// Width of the checkmark column every menu row reserves (#5705): the width of [`CHECK_MARK`] in +/// the button font, so checked, unchecked and plain rows (and submenu headers) all start their +/// label at the same x, whatever the script of the label. +pub fn check_gutter(ui: &Ui) -> f32 { + let font = egui::TextStyle::Button.resolve(ui.style()); + ui.painter().layout_no_wrap(CHECK_MARK.to_owned(), font, Color32::PLACEHOLDER).size().x +} + +/// A menu row's text (#5705): the label behind a fixed-width checkmark column ([`check_gutter`]), +/// with ✔ in the column when `checked`, and the mnemonic underlined when given (as +/// [`mnemonic_text`]). +pub fn menu_row_text(ui: &Ui, checked: bool, label: &str, mnemonic: Option) -> egui::WidgetText { + let font = egui::TextStyle::Button.resolve(ui.style()); + let plain = egui::TextFormat { font_id: font, color: Color32::PLACEHOLDER, ..Default::default() }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + // The label starts after the column: a checked row draws the mark in it, the others leave it + // empty (a leading space of the same width, not padding characters whose width varies). + let mut lead = check_gutter(ui); + if checked { + job.append(CHECK_MARK, 0.0, plain.clone()); + lead = 0.0; + } + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let parts = match at { + Some((i, len)) => [label.get(..i), label.get(i..i + len), label.get(i + len..)], + None => [Some(label), None, None], + }; + for (k, part) in parts.into_iter().enumerate() { + let Some(part) = part.filter(|p| !p.is_empty()) else { continue }; + let format = + if k == 1 && at.is_some() { egui::TextFormat { underline: Stroke::new(1.0, Color32::PLACEHOLDER), ..plain.clone() } } else { plain.clone() }; + job.append(part, lead, format); + lead = 0.0; + } + if job.sections.is_empty() { + // An empty label still keeps the row as wide as its column. + job.append("", lead, plain); + } + job.into() +} + impl Keys { /// Last frame's state. pub fn load(ctx: &egui::Context) -> Keys { diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 1f226054e0..4954053578 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -210,3 +210,41 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { h.run_steps(3); assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); } + +/// Checked, unchecked and plain rows (and submenu headers) start their label at the same x, in +/// any script: the checkmark column has a fixed width instead of padding characters (#5705). +#[test] +fn every_menu_row_starts_its_label_after_one_checkmark_column() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let start = |checked: bool, label: &str, mnemonic: Option| { + let galley = crate::menu_keys::menu_row_text(ui, checked, label, mnemonic).into_galley( + ui, + Some(egui::TextWrapMode::Extend), + f32::INFINITY, + egui::TextStyle::Button, + ); + let first = label.chars().next().unwrap_or(' '); + let row = galley.rows.first().expect("one row"); + let glyph = row.glyphs.iter().find(|g| g.chr == first).expect("the label's first glyph"); + assert!(galley.text().ends_with(label), "the label is shown whole: {:?}", galley.text()); + glyph.pos.x + }; + let gutter = crate::menu_keys::check_gutter(ui); + assert!(gutter > 0.0, "the column has room for the mark"); + for label in ["Lock Guides", "锁定参考线", "清除参考线"] { + let checked = start(true, label, None); + let unchecked = start(false, label, None); + assert!((checked - unchecked).abs() < 0.01, "{label}: checked {checked} vs unchecked {unchecked}"); + assert!((unchecked - gutter).abs() < 1.0, "{label}: the label starts right after the column ({unchecked} vs {gutter})"); + } + let underlined = start(false, "Clear Guides", Some('C')); + let plain = start(false, "Clear Guides", None); + assert!((underlined - plain).abs() < 0.01, "an underlined mnemonic doesn't move the label"); + let underlined_mid = start(true, "Lock Guides", Some('G')); + assert!((underlined_mid - plain).abs() < 0.01, "nor a mnemonic after the first letter"); + let t = crate::menu_keys::menu_row_text(ui, true, "Rulers", None); + assert_eq!(t.text(), "✔ Rulers", "a checked row keeps its mark (and its accessible label)"); + }); + out.textures_delta.clear(); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7a37f0c35e..bbaf6fa6a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1456,15 +1456,10 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click continue; } let label = translated_menu_label(lang, it); - let prefix = match it.checked { - Some(true) => "✔ ", - Some(false) => " ", - None => "", - }; let mnemonic = mnemonics.get(next_row).copied().flatten(); next_row += 1; - let text: egui::WidgetText = - if nav.underline { crate::menu_keys::mnemonic_text(ui, prefix, &label, mnemonic, None) } else { format!("{prefix}{label}").into() }; + // Checked, unchecked and plain rows share one checkmark column (#5705). + let text = crate::menu_keys::menu_row_text(ui, it.checked == Some(true), &label, mnemonic.filter(|_| nav.underline)); let mut b = egui::Button::new(text); if let Some(tint) = it.color.as_deref().and_then(menu_tint) { b = b.fill(tint.gamma_multiply(0.55)); @@ -1497,11 +1492,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click let enabled = submenu_enabled(&child); let mnemonic = mnemonics.get(next_row).copied().flatten(); next_row += 1; - let title: egui::WidgetText = if nav.underline { - crate::menu_keys::mnemonic_text(ui, "", crate::i18n::tr(lang, name), mnemonic, None) - } else { - crate::i18n::tr(lang, name).into() - }; + let title = crate::menu_keys::menu_row_text(ui, false, crate::i18n::tr(lang, name), mnemonic.filter(|_| nav.underline)); ui.add_enabled_ui(enabled, |ui| { nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); From 37beb1845425c6dc5f9eae3d7962582c665565f7 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:35:05 -0700 Subject: [PATCH 0579/1452] fix(codecs): keep EXR color channels within one layer (#5866) --- crates/codecs/src/codecs/exr.rs | 19 +---- crates/codecs/src/image.rs | 73 +++++++++++++++--- crates/codecs/tests/exr_multipart.rs | 108 ++++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 25 deletions(-) diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index a55810b4b8..1b268ac559 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -15,7 +15,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; -use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name}; +use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels}; use crate::options::{EncodeOptions, ExrCompression, Limits}; const F: Format = Format::OpenExr; @@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>) -> Result { let (w, h) = (layer.size.0, layer.size.1); let channels = &layer.channel_data.list; - let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n); - let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - match find("A") { - Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), - None => (vec![r, g, b], ChannelLayout::Rgb), - } - } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { - match find("A") { - Some(a) => (vec![y, a], ChannelLayout::GrayA), - None => (vec![y], ChannelLayout::Gray), - } - } else { - return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); - }; + let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect(); + let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?; + let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::>()?; let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 }; let npx = w * h; diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 98168d59d3..5a03e7d283 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -321,20 +321,75 @@ pub struct ExrPartInfo { impl ExrPartInfo { /// How much this part looks like a colour image the plain [`Image`] decode can show: - /// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// The colour and alpha channels must share a complete layer prefix. The highest-ranked + /// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically. /// The auto-pick takes the highest rank; ties go to the earlier part. pub fn color_rank(&self) -> u8 { - let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n); - if has("R") && has("G") && has("B") { - return if has("A") { 4 } else { 3 }; - } - if has("Y") { - return if has("A") { 2 } else { 1 }; - } - u8::from(self.channels.len() == 1) + exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8) } } +/// One selection policy for header ranking/probing and flat decoding: never synthesize +/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last +/// dot, including nested layers; a leading or trailing dot alone does not name a layer. +pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator) -> Option<(ChannelLayout, Vec)> { + #[derive(Default)] + struct Group { + r: Option, + g: Option, + b: Option, + a: Option, + y: Option, + } + + // Sorted prefixes make rank ties deterministic independently of channel order, with + // the empty (unprefixed) group first. Unsupported channels cannot complete a group. + let mut groups = std::collections::BTreeMap::<&str, Group>::new(); + let mut count = 0usize; + for (index, name) in names.into_iter().enumerate() { + count = count.saturating_add(1); + let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name)); + if !matches!(base, "R" | "G" | "B" | "A" | "Y") { + continue; + } + let group = groups.entry(prefix).or_default(); + let component = match base { + "R" => &mut group.r, + "G" => &mut group.g, + "B" => &mut group.b, + "A" => &mut group.a, + "Y" => &mut group.y, + _ => continue, + }; + component.get_or_insert(index); + } + + let mut best = None; + let mut best_rank = 0; + for group in groups.values() { + let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) { + match group.a { + Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]), + None => (ChannelLayout::Rgb, vec![r, g, b]), + } + } else if let Some(y) = group.y { + match group.a { + Some(a) => (ChannelLayout::GrayA, vec![y, a]), + None => (ChannelLayout::Gray, vec![y]), + } + } else { + continue; + }; + let rank = candidate.0.channels(); + if rank > best_rank { + best = Some(candidate); + best_rank = rank; + } + } + best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0]))) +} + /// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`). pub(crate) fn base_channel_name(name: &str) -> &str { name.rsplit('.').next().unwrap_or(name) diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/exr_multipart.rs index f106d37791..421d494710 100644 --- a/crates/codecs/tests/exr_multipart.rs +++ b/crates/codecs/tests/exr_multipart.rs @@ -8,7 +8,9 @@ use exr::prelude::{ WritableImage, }; use photocraft_codecs::f16; -use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info}; +use photocraft_codecs::{ + ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size, +}; fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec)]) -> Layer> { let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len()); @@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() { assert_eq!((img.width(), img.height()), (4, 4)); assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels"); } + +fn channel_group_file(channels: &[(&str, f32)]) -> Vec { + let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect(); + write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)]) +} + +fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) { + assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8); + assert_eq!(probe_size(bytes).unwrap(), (2, 2)); + for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] { + assert_eq!(img.layout(), layout); + assert_eq!(img.to_normalized(), expected.repeat(4)); + } +} + +#[test] +fn flat_decode_keeps_rgb_channels_in_one_group() { + // A partial group sorts before the nested beauty layer, but cannot donate its R. + let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_does_not_borrow_another_groups_alpha() { + for alpha in ["A", "aaa.A", "beauty.other.A"] { + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]); + assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]); + } +} + +#[test] +fn flat_decode_rejects_rgb_split_between_groups() { + for channels in [ + [("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)], + [("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)], + [(".R", 0.25), ("G", 0.5), ("B", 2.0)], + ] { + let bytes = channel_group_file(&channels); + assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0); + assert!(probe_size(&bytes).is_err()); + assert!(decode(&bytes).is_err()); + assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err()); + } +} + +#[test] +fn auto_pick_and_probe_skip_incoherent_rgb_parts() { + let bytes = write_image(vec![ + layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]), + layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]), + ]); + let parts = exr_info(&bytes, &Limits::default()).unwrap(); + assert_eq!(parts.iter().map(|part| part.color_rank()).collect::>(), [0, 2]); + assert_eq!(probe_size(&bytes).unwrap(), (4, 4)); + let img = decode(&bytes).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA)); + assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16)); +} + +#[test] +fn flat_decode_prefers_the_highest_ranked_complete_group() { + let bytes = + channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]); +} + +#[test] +fn flat_decode_breaks_group_ties_by_prefix() { + let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + // Even a prefix that sorts before the unprefixed channel names loses a rank tie. + let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() { + for prefix in ["", "beauty."] { + let y = format!("{prefix}Y"); + let a = format!("{prefix}A"); + assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]); + assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]); + } + for name in ["depth.Z", "A", "beauty.A"] { + // A lone alpha is one grayscale channel, not the same samples duplicated as YA. + assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]); + } +} + +#[test] +fn half_float_group_does_not_use_unrelated_float_channels() { + let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]); + for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] { + layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4]))); + } + layer.channel_data = AnyChannels::sort(layer.channel_data.list); + let bytes = write_image(vec![layer]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16); +} From e9036c72c656b888e39877f7c861ce69a0913ff1 Mon Sep 17 00:00:00 2001 From: Carter Smith <51297686+carterwsmith@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:35:12 -0700 Subject: [PATCH 0580/1452] Focus font search when opening the picker (#7222) * Focus font search when reopening the picker * Keep font search focused through the opening click release --- crates/ui-egui/src/type_font_picker_tests.rs | 94 +++++++++++++++++--- crates/ui-egui/src/type_tool.rs | 29 +++--- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 176e683a9d..3dfe34dedc 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -10,6 +10,7 @@ use photocraft_engine::prefs::FontPreview; struct State { current: String, families: Vec, + button: egui::Id, popup: egui::Id, preview: FontPreview, } @@ -22,10 +23,11 @@ fn harness_with(preview: FontPreview) -> Harness<'static, State> { // Enough families that the unfiltered menu hits its maximum height and scrolls. let mut families: Vec = (0..60).map(|i| format!("Family {i:02}")).collect(); families.push("Zeta Sans".into()); - let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL, preview }; + let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { - s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); let fams = s.families.clone(); font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); }, @@ -46,6 +48,16 @@ fn open(h: &mut Harness<'static, State>) { h.run(); } +/// A native click can leave the opening button held across several rendered frames. +fn hold_open(h: &mut Harness<'_, S>, button: egui::Pos2) { + h.hover_at(button); + h.run(); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); +} + #[test] fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { let mut h = harness(); @@ -93,15 +105,18 @@ fn clearing_the_query_restores_the_menu_height() { #[test] fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { let mut h = harness(); - open(&mut h); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + hold_open(&mut h, button); assert!(h.get_by_role(Role::TextInput).is_focused(), "search is focused on first open"); - h.get_by_role(Role::TextInput).type_text("zeta"); - h.run(); - // Close without choosing, then reopen: the query stays and the field is focused again. - h.key_press(Key::Escape); + h.event(egui::Event::Text("zeta".into())); h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta"), "typing goes straight to the search field"); + // Close and reopen on consecutive passes: the query stays and the field is focused again. + h.input_mut().events.push(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); assert!(popup_rect(&h).is_none(), "Escape closes the menu"); - open(&mut h); + h.query_all_by_role(Role::ComboBox).next().unwrap().click_accesskit(); + h.run(); let search = h.get_by_role(Role::TextInput); assert_eq!(search.value().as_deref(), Some("zeta")); assert!(search.is_focused(), "search is focused on reopen with a query"); @@ -120,6 +135,64 @@ fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { assert!(popup_rect(&h).is_none(), "choosing a font closes the menu"); } +#[test] +fn opening_click_release_returns_focus_to_search_before_typing() { + let mut h = harness(); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + h.hover_at(button); + h.run(); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(h.get_by_role(Role::TextInput).is_focused()); + // A native opening click can hand focus back to its button when it ends, after the + // search has already been focused for several passes. Model that platform handoff. + let id = h.state().button; + h.ctx.memory_mut(|m| m.request_focus(id)); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert!(h.get_by_role(Role::TextInput).is_focused(), "the opening release leaves the search focused"); + h.event(egui::Event::Text("zeta".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta")); + assert!(h.query_by_label("Family 01").is_none()); +} + +#[test] +fn first_options_bar_open_searches_without_changing_the_tool_or_edited_text() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + for editing in [false, true] { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + if editing { + super::edit_active(&mut app).unwrap(); + } + app.sync_views(); + app + }); + h.run_steps(8); + let button = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); + hold_open(&mut h, button); + // Native typing carries key events as well as text; an unfocused search would let + // the first key switch tools, or replace the selected inline text instead. + h.event(egui::Event::Key { key: Key::J, physical_key: Some(Key::J), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Text("JetBrains".into())); + h.run(); + let search = h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("font search focused on first open"); + assert_eq!(search.value().as_deref(), Some("JetBrains")); + assert_eq!(h.state().ui.tool, crate::state::Tool::Type); + let st = h.state().session.active().unwrap(); + let LayerContent::Text(text) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("type layer") }; + assert_eq!(text.text, "Sample"); + } +} + #[test] fn arrows_apply_fonts_without_closing_and_enter_accepts() { let mut h = harness(); @@ -411,6 +484,7 @@ fn localized_font_names_label_the_menu_but_pick_the_family() { let state = State { current: "Arial".into(), families: vec!["Arial".into(), "MS Gothic".into(), "Zeta Sans".into()], + button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Medium, }; @@ -519,8 +593,8 @@ fn options_bar_hover_previews_with_the_character_picker_also_shown() { }; let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); - pickers[0].click(); - h.run(); + let button = pickers[0].rect().center(); + hold_open(&mut h, button); h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").type_text("JetBrains Mono"); h.run(); h.get_by_label("JetBrains Mono").hover(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index a8125201d2..68c33b78c8 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1447,6 +1447,7 @@ pub(crate) fn family_picker_in( ) -> bool { let mut changed = false; let search_id = ui.id().with(("font-search", salt)); + let focus_id = search_id.with("focus"); // The list opens on the press, so a press-drag-release picks a font too (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, salt); let labels: Option>> = ui.ctx().data(|d| d.get_temp(font_labels_id())); @@ -1462,26 +1463,25 @@ pub(crate) fn family_picker_in( .close_behavior(if matches!(close, egui::PopupCloseBehavior::IgnoreClicks) { close } else { egui::PopupCloseBehavior::CloseOnClickOutside }); let response = combo.show_ui(ui, |ui| { - let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height")); - let pass = ui.ctx().cumulative_pass_nr(); - let last_pass: Option = ui.data(|d| d.get_temp(pass_id)); - // Freshly opened (not drawn last pass): focus the search field whatever the query, and keep - // asking until it has focus (the first pass is egui's invisible sizing pass) (#1369). - let opened = last_pass.is_none_or(|p| p.saturating_add(1) < pass); - let mut focus = opened || ui.data(|d| d.get_temp(focus_id)).unwrap_or(false); + let height_id = search_id.with("height"); + // Each opening focuses the search, including a reopen on the very next pass. Keep + // asking through the invisible sizing pass, until the field receives focus. + let mut focus = ui.data(|d| d.get_temp(focus_id)).unwrap_or(true); let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); // Consume these before TextEdit and the canvas can interpret them as caret movement. let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search fonts")).desired_width(200.0)); - if r.has_focus() { - focus = false; - } else if focus { - r.request_focus(); + if focus { + ui.memory_mut(|m| m.request_focus(search_id.with("input"))); + } + let r = ui.add(egui::TextEdit::singleline(&mut q).id(search_id.with("input")).hint_text(tl!("Search fonts")).desired_width(200.0)); + if focus { + // The opening click can release after several focused frames. Keep the request + // pending through that release, which can hand focus back to the combo button. + focus = !r.has_focus() || ui.input(|i| i.pointer.primary_down() || i.pointer.primary_released()); } ui.data_mut(|d| { - d.insert_temp(pass_id, pass); d.insert_temp(focus_id, focus); d.insert_temp(search_id, q.clone()); }); @@ -1551,6 +1551,9 @@ pub(crate) fn family_picker_in( if accept { None } else { hovered } }); press.after(&response.response); + if !egui::ComboBox::is_open(ui.ctx(), response.response.id) { + ui.data_mut(|d| d.remove::(focus_id)); + } let hovered_font = response.inner.unwrap_or_default(); // The wheel over the closed button steps through the families, a notch each, like Photoshop's From b3c8a2a7f3c7abd88990a411aedf95cd4f363df9 Mon Sep 17 00:00:00 2001 From: Tom Ingling Date: Sun, 18 Oct 2026 03:35:17 -0400 Subject: [PATCH 0581/1452] Assign Profile and Convert to Profile: Preview, as in Photoshop; Black Point Compensation spelled out (#6825) - Both dialogs get the Preview checkbox (`PREVIEWED`). - The canvas shows a filter preview through the preview's own colour management (its profile and mode), not the committed document's: Assign Profile changes only the profile, so its preview looked unchanged. The preview keeps the document's id, so Proof Colors still apply. - Previews run with the app's Color Settings: `working` is the chosen working space, not the built-in default. - The `bpc` checkbox (Convert to Profile, Proof Setup) reads "Black Point Compensation" instead of "Bpc"; every catalog already translates it. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 103 +++++++++++++++++++++--- crates/ui-egui/src/filter_dialog.rs | 39 ++++++++- crates/ui-egui/src/filter_preview_ui.rs | 1 + 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 69ed5ebcdc..15a5db6176 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1838,6 +1838,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e if !fresh && !app.filter_preview_worker.busy() { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let color = app.session.color.settings.clone(); let task = request.clone(); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); @@ -1850,6 +1851,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some(cancel), task.target, &documents, + &color, ) .map(|doc| { let buffer = photocraft_compose::flatten(&doc); @@ -1888,12 +1890,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e None, request.target, &documents, + &app.session.color.settings, ) .map(std::sync::Arc::new); if let Some(r) = &result { let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); - let (display, _) = canvas_display(app, &doc, None); + // The preview's own profile: Assign and Convert to Profile change it (see `publish_filter_preview`). + let (display, _) = canvas_display(app, r, None); if let Some(gpu) = app.gpu.as_ref() { gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); } @@ -1929,7 +1933,9 @@ fn publish_filter_preview( let result = computed.and_then(|(result, buffer)| { let t0 = crate::gpu_canvas::now_ms(); let doc = app.session.documents().get(idx)?.doc.clone(); - let (display, _) = canvas_display(app, &doc, None); + // Shown through the preview's own colour management, as the result will be: Assign and + // Convert to Profile change the profile (and Convert the mode), not just the pixels. + let (display, _) = canvas_display(app, &result, None); // A GPU texture is an optimisation: headless/CPU tests must still see the preview. if let Some(gpu) = app.gpu.as_ref() { gpu.upload_buffer_full(request.doc.0 ^ (1u64 << 61), &texture_buffer(display.as_deref(), &buffer), doc.depth); @@ -1940,6 +1946,16 @@ fn publish_filter_preview( app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request, result, committing: false }); } +/// The document whose colour management the canvas texture under `key` is shown through: a filter +/// preview's own result (it keeps the document's id, so Proof Colors still apply), else `doc`. +/// Assign Profile changes only the profile, so through `doc`'s its preview looked unchanged. +fn filter_preview_display_doc(app: &PhotocraftApp, doc: &std::sync::Arc, key: u64) -> std::sync::Arc { + if key != doc.id.0 ^ (1u64 << 61) { + return doc.clone(); + } + app.filter_preview.as_ref().and_then(|p| p.result.clone()).filter(|r| r.id == doc.id).unwrap_or_else(|| doc.clone()) +} + /// After a filter dialog's OK: `Some(shown)` while its preview stands in for the background job /// that commits it (the document unchanged and the job still running, for this document's views), /// `None` otherwise (other documents' views included). A job that lands, fails or is cancelled @@ -3061,6 +3077,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: on_gpu = true; app.navigator_gpu.remove(&doc.id); let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)]; + let shown = filter_preview_display_doc(app, &doc, key); let params = crate::gpu_canvas::ViewParams { doc: key, doc_size: preview_size, @@ -3077,7 +3094,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }, pixel_grid: false, view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), - display: sync_display_lut(app, &doc, key, output), + display: sync_display_lut(app, &shown, key, output), output: output.unwrap_or(0), hdr: hdr_preview(app, &doc), offset: [0.0, 0.0], @@ -6308,13 +6325,21 @@ mod tests { app.filter_preview_worker .start(key.clone(), ctx.clone(), move |_| { wait.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); - let result = - crate::filter_dialog::preview_document_for_target(&doc, key.active, &key.command, &key.params, key.k, None, key.target, &documents).map( - |d| { - let buffer = photocraft_compose::flatten(&d); - (std::sync::Arc::new(d), buffer) - }, - ); + let result = crate::filter_dialog::preview_document_for_target( + &doc, + key.active, + &key.command, + &key.params, + key.k, + None, + key.target, + &documents, + &Default::default(), + ) + .map(|d| { + let buffer = photocraft_compose::flatten(&d); + (std::sync::Arc::new(d), buffer) + }); crate::filter_preview_worker::Computed { result, ms: 0.0 } }) .expect("the worker starts"); @@ -8690,6 +8715,64 @@ mod rotation_preview_isolation_tests { } } + /// Assign and Convert to Profile have Photoshop's Preview. Assign changes only the profile, so + /// its preview must be shown through the profile it assigns (through the document's own it + /// looked unchanged); Convert keeps the look of in-gamut colours. Their `working` is the + /// working space from Color Settings, not the built-in default. + #[test] + fn profile_dialogs_preview_through_the_profile_they_choose() { + let mut session = photocraft_engine::Session::new(); + session.color.settings.monitor_profile = "srgb".into(); + session.color.settings.working_rgb = "display-p3".into(); + session.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + session.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap(); + session + .edit("Fixture", |doc, _| { + let layer = doc.layers.last_mut().unwrap(); + for y in 0..4 { + for x in 0..4 { + layer.surface_mut().unwrap().write_pixel(x, y, &[0.6, 0.35, 0.25, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let shown_pixels = |app: &PhotocraftApp, doc: &Document| { + let (display, _) = canvas_display(app, doc, None); + display_image(display.as_deref(), &photocraft_compose::flatten(doc)).pixels[0] + }; + let original = app.session.active().unwrap().doc.clone(); + let before = shown_pixels(&app, &original); + let preview = |app: &mut PhotocraftApp, command: &str, profile: &str| { + crate::filter_dialog::open(app, command).unwrap(); + let fields = &mut app.ui.dialogs.last_mut().unwrap().fields; + assert_eq!(fields.get("__preview"), Some(&json!(true)), "{command} has a Preview checkbox, on"); + fields.insert("profile".into(), json!(profile)); + ensure_filter_preview(app, 0, &egui::Context::default()).expect("a preview"); + let result = app.filter_preview.as_ref().unwrap().result.clone().unwrap(); + let key = original.id.0 ^ (1u64 << 61); + assert_eq!(filter_preview_display_doc(app, &original, key).icc_profile, result.icc_profile, "the canvas shows it through its own profile"); + assert_eq!(app.session.active().unwrap().doc.icc_profile, original.icc_profile, "previewing changes nothing"); + app.ui.dialogs.clear(); + app.filter_preview = None; + result + }; + let description = |doc: &Document| photocraft_engine::color_cmds::profile_from_bytes(doc.icc_profile.as_ref().unwrap()).unwrap().description.clone(); + + let assigned = preview(&mut app, "edit.assignProfile", "prophoto-compat"); + assert_eq!(description(&assigned), photocraft_cms::Builtin::ProPhotoCompat.description()); + let shown = shown_pixels(&app, &assigned); + assert!(shown.r().abs_diff(before.r()) > 10 || shown.b().abs_diff(before.b()) > 10, "the assigned profile shows: {before:?} → {shown:?}"); + + let converted = preview(&mut app, "edit.convertToProfile", "working"); + assert_eq!(description(&converted), photocraft_cms::Builtin::DisplayP3.description(), "`working` is Color Settings' working space"); + let shown = shown_pixels(&app, &converted); + for (a, b) in [(shown.r(), before.r()), (shown.g(), before.g()), (shown.b(), before.b())] { + assert!(a.abs_diff(b) <= 2, "converting keeps the look: {before:?} → {shown:?}"); + } + } + /// A CPU-only app must be able to generate the actual rotation preview without a /// GPU adapter; checking the result also verifies Preview off and document integrity. #[test] diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..8cf7f3519a 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use photocraft_doc::Document; +use photocraft_engine::color_cmds::ColorSettings; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -279,6 +280,9 @@ pub const PREVIEWED: &[&str] = &[ "layer.matting.colorDecontaminate", "layer.layerStyle.scaleEffects", "type.warpText", + // The preview shows the image through the chosen profile, as Photoshop's Preview does. + "edit.assignProfile", + "edit.convertToProfile", ]; pub fn has_dialog(command: &str) -> bool { @@ -293,8 +297,6 @@ pub fn has_dialog(command: &str) -> bool { command, "image.trim" | "select.refineEdge" - | "edit.assignProfile" - | "edit.convertToProfile" | "view.proofSetup" | "layer.layerStyle.globalLight" | "image.mode.colorTable" @@ -556,6 +558,10 @@ pub(crate) fn source_label(key: &str) -> String { if key == "localized" { return "Localized Color Clusters".into(); } + // Convert to Profile's, Proof Setup's…: spelled out, as in Photoshop (not "Bpc"). + if key == "bpc" { + return "Black Point Compensation".into(); + } // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. if key.contains(' ') { return key.to_string(); @@ -875,12 +881,23 @@ pub fn preview_document_with( k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, ) -> Option { - preview_document_for_target(doc, active, command, params, k, cancel, photocraft_engine::channel_cmds::ChannelTarget::Composite, &[]) + preview_document_for_target( + doc, + active, + command, + params, + k, + cancel, + photocraft_engine::channel_cmds::ChannelTarget::Composite, + &[], + &ColorSettings::default(), + ) } /// Run the preview with the same pixel target as the final command, including individual channels. /// `documents` are the open documents in session order: document params (Match Color's Source, -/// Displace's map) index into them. +/// Displace's map) index into them. `color` is the app's Edit › Color Settings: Assign and Convert +/// to Profile's `working` is the working space chosen there, not the built-in default. #[allow(clippy::too_many_arguments)] pub(crate) fn preview_document_for_target( doc: &Document, @@ -891,6 +908,7 @@ pub(crate) fn preview_document_for_target( cancel: Option<&photocraft_engine::jobs::JobCtx>, target: photocraft_engine::channel_cmds::ChannelTarget, documents: &[Arc], + color: &ColorSettings, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -900,6 +918,7 @@ pub(crate) fn preview_document_for_target( let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; let proxy = crate::proxy::proxy_document(doc, run_k); let mut s = photocraft_engine::Session::new(); + s.color.settings = color.clone(); s.set_inline_job_ctx(cancel.cloned()); let doc_id = proxy.id; s.add_document(proxy, None); @@ -1050,6 +1069,16 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } + /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated. + #[test] + fn black_point_compensation_is_spelled_out() { + assert_eq!(source_label("bpc"), "Black Point Compensation"); + // English is the source: it has no catalog. + for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { + assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}"); + } + } + fn key() -> FilterPreviewKey { FilterPreviewKey { doc: photocraft_doc::DocId(7), @@ -1569,6 +1598,7 @@ mod tests { None, photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, + &ColorSettings::default(), ) .unwrap_or_else(|| panic!("{label}: no preview")); let got = preview.layers[0].surface().unwrap().pixel(24, 16); @@ -1584,6 +1614,7 @@ mod tests { None, photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, + &ColorSettings::default(), ) .unwrap(); let px = preview.layers[0].surface().unwrap().pixel(12, 8); diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 27f530980a..e4afdb7566 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -329,6 +329,7 @@ mod tests { None, ChannelTarget::Alpha(0), &[], + &Default::default(), ) .unwrap(); let ctx = egui::Context::default(); From 386368956f9b1f5824cbf37c2247e81051ee0871 Mon Sep 17 00:00:00 2001 From: JunHyun Park <110433156+junhyun-dev@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:35:22 +0900 Subject: [PATCH 0582/1452] Korean: consistent terms in the Gradient Editor, Object Selection messages and shortcut hints (#6882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen labels in ko.tsv used a different word than the rest of the Korean catalog for the same term, or named a menu item by a label the menu does not use (Reset All to Defaults reads 모두 기본값으로 초기화). Only translation values change; placeholders and ellipses are untouched. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ko.tsv | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3d6d815559..472c28d921 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -507,7 +507,7 @@ Save Default Type Styles 기본 문자 스타일 저장 Deselect 선택 해제 Reselect 다시 선택 - Inverse 반대로 + Inverse 반전 Inverse Selection 선택 영역 반전 Similar 유사한 항목 Color Range… 색상 범위… @@ -1399,10 +1399,10 @@ Imported {n} shortcuts from {file}. Click OK to keep them. {file}에서 단축키 {n}개를 가져왔습니다. 유지하려면 확인을 클릭하세요. {n} commands are not in PhotoCraft. 명령 {n}개는 PhotoCraft에 없습니다. Keyboard shortcuts imported 키보드 단축키를 가져왔습니다 - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. 키보드 단축키 세트 {set}을(를) 여기서 사용합니다. 단축키 {n}개가 기본값과 다릅니다. 편집 › 키보드 단축키에서 확인할 수 있고, 모두 기본값으로 재설정으로 되돌릴 수 있습니다. - Import keyboard shortcuts? 키보드 단축키를 가져올까요? - PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft가 이 컴퓨터에서 다른 이미지 편집기의 키보드 단축키 세트 {set}을(를) 찾았습니다. 단축키 {n}개가 PhotoCraft와 다릅니다. 가져올까요? - Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. 편집 › 키보드 단축키 › 모두 기본값으로 재설정으로 가져오기를 되돌릴 수 있습니다. + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. 키보드 단축키 세트 {set}을(를) 여기서 사용합니다. 단축키 {n}개가 기본값과 다릅니다. 편집 › 키보드 단축키에서 확인할 수 있고, 모두 기본값으로 초기화로 되돌릴 수 있습니다. + Import keyboard shortcuts? 키보드 단축키를 가져오시겠습니까? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft가 이 컴퓨터에서 다른 이미지 편집기의 키보드 단축키 세트 {set}을(를) 찾았습니다. 단축키 {n}개가 PhotoCraft와 다릅니다. 가져오시겠습니까? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. 편집 › 키보드 단축키 › 모두 기본값으로 초기화를 누르면 가져오기를 되돌릴 수 있습니다. Don't Import 가져오지 않음 The keyboard shortcut set was not imported: {error} 키보드 단축키 세트를 가져오지 못했습니다: {error} Reset Section 섹션 초기화 @@ -1817,8 +1817,8 @@ Zoom Tool 돋보기 도구 {name} Properties {name} 속성 Revert “{name}” to the last saved version? Your changes will be lost. “{name}”을(를) 마지막으로 저장한 버전으로 되돌릴까요? 변경 사항이 사라집니다. - Do you want to save the changes you made to “{name}” before quitting? 종료하기 전에 “{name}”의 변경 사항을 저장할까요? - Do you want to save the changes you made to “{name}” before closing? 닫기 전에 “{name}”의 변경 사항을 저장할까요? + Do you want to save the changes you made to “{name}” before quitting? 종료하기 전에 “{name}”의 변경 사항을 저장하시겠습니까? + Do you want to save the changes you made to “{name}” before closing? 닫기 전에 “{name}”의 변경 사항을 저장하시겠습니까? Doc: {flat}/{layered} 문서: {flat}/{layered} Scratch: {size} 스크래치: {size} Untagged {mode} 프로파일 없는 {mode} @@ -2127,12 +2127,12 @@ The current gradient (pick one in Window › Gradients) 현재 그레이디언트(창 › 그레이디언트에서 선택) Select a type layer to edit its character and paragraph settings. 문자 및 단락 설정을 편집하려면 문자 레이어를 선택하세요. # Gradient Editor window, opened from the options bar's gradient swatch. - Gradient Editor 그라디언트 편집기 - Gradient picker 그라디언트 선택기 - Save As Preset 프리셋으로 저장 - The gradient the Gradient tool paints with 그라디언트 도구가 칠하는 그라디언트입니다 - Adds the current gradient to the Gradients panel 현재 그라디언트를 그라디언트 패널에 추가합니다 - Gradient stops 그라디언트 스톱 + Gradient Editor 그레이디언트 편집기 + Gradient picker 그레이디언트 선택기 + Save As Preset 사전 설정으로 저장 + The gradient the Gradient tool paints with 그레이디언트 도구가 칠하는 그레이디언트입니다 + Adds the current gradient to the Gradients panel 현재 그레이디언트를 그레이디언트 패널에 추가합니다 + Gradient stops 그레이디언트 스톱 Text color 텍스트 색상 Type Options 문자 옵션 Anti-aliasing method 앤티 앨리어스 방식 @@ -3041,7 +3041,7 @@ fontWeight Italic 기울임 Rotate View 보기 회전 Rotate View Tool 보기 회전 도구 Reset View 보기 재설정 - Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기를 회전합니다 · Shift는 15°로 제한 + Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기 회전 · Shift로 15° 단위 제한 # Painting symmetry presets. Dual Axis 이중 축 @@ -3070,7 +3070,7 @@ fontWeight Italic 기울임 PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 Rasterize svg groups deeper than 이 깊이보다 깊은 SVG 그룹 래스터화 - 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 임계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 한계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. Rule of Thirds 삼등분 법칙 Golden Ratio 황금 비율 Golden Spiral 황금 나선 @@ -3140,15 +3140,15 @@ fontWeight Italic 기울임 Dashed line 파선 Dash 선 Gap 간격 - Dash / gap lengths × stroke width 선 / 간격 길이 × 획 너비 + Dash / gap lengths × stroke width 선 / 간격 길이 × 획 폭 Add pair 쌍 추가 Remove pair 쌍 제거 Dash offset 파선 오프셋 Open paths use centered strokes 열린 패스는 가운데 획을 사용합니다 Use finite lengths and a positive pattern total 유한한 길이와 양수 패턴 합계를 사용하세요 - No subject found — try Object Selection or the Lasso 피사체를 찾을 수 없습니다 — 개체 선택 또는 올가미를 사용해 보세요 + No subject found — try Object Selection or the Lasso 피사체를 찾을 수 없습니다 — 오브젝트 선택 또는 올가미를 사용해 보세요 Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다 - Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 + Object Selection found nothing there — try a tighter rectangle 오브젝트 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 No subject found — try a rectangle around the object 피사체를 찾을 수 없습니다 — 개체 주위에 사각형을 그려 보세요 Auto Erase 자동 지우기 Click the first point or press {key} to close · {del} removes a point · Esc cancels 첫 점을 클릭하거나 {key}를 눌러 닫기 · {del}로 점 제거 · Esc로 취소 From 04967b12f98d0d30f9e4db6233383508014e0dd6 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:35:28 +0400 Subject: [PATCH 0583/1452] docs: write product name as PhotoCraft in engine module doc, LUT export header and image sources (#5454) --- crates/engine/src/file_cmds.rs | 2 +- crates/engine/src/lib.rs | 2 +- docs/images/SOURCES.md | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f9230d1293..f99aefcff0 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1477,7 +1477,7 @@ fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String { lattice.layers.push(a); } let out = photocraft_compose::flatten(&lattice); - let mut s = format!("TITLE \"{}\"\n# Created by Photocraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); + let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); // .cube order: red changes fastest, then green, then blue. for b in 0..n { for g in 0..n { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b076769040..ba7556901b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,4 +1,4 @@ -//! The Photocraft engine façade: open documents, history, and the command registry. +//! The PhotoCraft engine façade: open documents, history, and the command registry. //! //! Every user-visible action is a command with a stable id (Photoshop-style, such as //! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same diff --git a/docs/images/SOURCES.md b/docs/images/SOURCES.md index 723d1837fc..1d68651a57 100644 --- a/docs/images/SOURCES.md +++ b/docs/images/SOURCES.md @@ -2,15 +2,15 @@ | File | Content | Source / licence | |---|---|---| -| `photocraft-demo.jpg` | Photocraft UI screenshot (rendered offscreen with `cargo run -p photocraft-ui-egui --example snapshot`) editing *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg). UI: this project. | -| `photocraft-layer-styles.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Earthrise*, William Anders, Apollo 8, 24 December 1968 (NASA photograph) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg). UI: this project. | -| `photocraft-masks.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg). UI: this project. | -| `photocraft-vector.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Water Lilies*, Claude Monet, 1906 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Claude_Monet_-_Water_Lilies_-_1906,_Ryerson.jpg). UI: this project. | -| `photocraft-filters.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Starry Night*, Vincent van Gogh, 1889 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg). UI: this project. | -| `photocraft-adjustments.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Impression, Sunrise*, Claude Monet, 1872 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Monet_-_Impression,_Sunrise.jpg). UI: this project. | -| `photocraft-transform.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg). UI: this project. | -| `photocraft-type.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg). UI: this project. | -| `photocraft-export-light.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Kiss*, Gustav Klimt, 1907–1908 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg). UI: this project. | +| `photocraft-demo.jpg` | PhotoCraft UI screenshot (rendered offscreen with `cargo run -p photocraft-ui-egui --example snapshot`) editing *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg). UI: this project. | +| `photocraft-layer-styles.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Earthrise*, William Anders, Apollo 8, 24 December 1968 (NASA photograph) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg). UI: this project. | +| `photocraft-masks.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg). UI: this project. | +| `photocraft-vector.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Water Lilies*, Claude Monet, 1906 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Claude_Monet_-_Water_Lilies_-_1906,_Ryerson.jpg). UI: this project. | +| `photocraft-filters.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Starry Night*, Vincent van Gogh, 1889 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg). UI: this project. | +| `photocraft-adjustments.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Impression, Sunrise*, Claude Monet, 1872 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Monet_-_Impression,_Sunrise.jpg). UI: this project. | +| `photocraft-transform.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg). UI: this project. | +| `photocraft-type.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg). UI: this project. | +| `photocraft-export-light.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Kiss*, Gustav Klimt, 1907–1908 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg). UI: this project. | Screenshots must use famous public-domain artwork (or content made in the app), never personal photos. From cc772ad9dc82a965d5089dd9cd091eee000035bd Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:35:35 +0200 Subject: [PATCH 0584/1452] Saving onto a dangling symlink writes through to its target (#6605) (#6649) * fix(format): saving onto a dangling symlink writes through to its target (#6605) resolve_symlink fell back to the link path when canonicalize failed, so an atomic save renamed over the symlink and replaced it with a regular file. Dangling chains now resolve lexically (relative targets included, with a depth cap), so the bytes land at the target and the link survives; a symlink loop is a clear error instead of a silent link replacement. * Follow dangling links without folding .. by text A folder on the way may itself be a symlink, so leave .. for the OS to resolve. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: highperfocused Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/format/src/atomic.rs | 35 +++++++++++++++++++---- crates/format/src/atomic/tests.rs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 348374d3cf..8a02ce5c78 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -175,7 +175,7 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt /// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { - let target = resolve_symlink(path); + let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?; let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), @@ -245,12 +245,37 @@ fn sleep_ms(ms: u64) { #[cfg(target_arch = "wasm32")] fn sleep_ms(_ms: u64) {} +/// How deep a symlink chain we follow before giving up, like the OS limit. +const MAX_LINK_DEPTH: u32 = 40; + /// Write through a symlink to the file it points at, instead of replacing the link. -fn resolve_symlink(path: &Path) -> PathBuf { - match std::fs::symlink_metadata(path) { - Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), - _ => path.to_path_buf(), +/// +/// A dangling link (its target, or a folder on the way to it, doesn't exist yet) resolves to its +/// target too, so saving through one creates the target instead of silently turning the link +/// into a regular file (#6605). A chain that never ends is an error, never a link replacement. +fn resolve_symlink(path: &Path) -> io::Result { + if !std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(path.to_path_buf()); } + // A resolvable chain: canonicalize follows every link and relative target in one go. + if let Ok(real) = std::fs::canonicalize(path) { + return Ok(real); + } + // Dangling chain: walk the links lexically; the final target may not exist yet. + let mut current = path.to_path_buf(); + for _ in 0..MAX_LINK_DEPTH { + let Ok(link) = std::fs::read_link(¤t) else { + return Ok(current); // not (or no longer) a link: the chain ends here + }; + let base = current.parent().unwrap_or(Path::new(".")).to_path_buf(); + // `..` stays in the path for the OS to resolve: folding it by text is wrong when a folder + // on the way is itself a symlink. + current = if link.is_absolute() { link } else { base.join(link) }; + if !std::fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(current); + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) } /// A unique hidden temporary file name to write beside `file` before renaming over it. diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index a85f998a16..ce3ed1b7c6 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -296,6 +296,52 @@ fn writes_through_a_symlink() { assert_eq!(std::fs::read(&real).unwrap(), b"new"); } +/// #6605: saving onto a dangling symlink writes through to its target instead of replacing the +/// link with a regular file, even when a folder on the way to the target doesn't exist yet. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_symlink() { + let d = TempDir::new("dangling"); + let real = d.0.join("missing/dir/a.psd"); + let link = d.0.join("link.psd"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(&real).unwrap(), b"new", "the bytes land at the target"); + assert_no_temp(real.parent().unwrap_or(Path::new("."))); +} + +/// A dangling link with a relative target resolves against the link's own folder. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_relative_symlink() { + let d = TempDir::new("dangling-rel"); + let here = d.0.join("here"); + std::fs::create_dir(&here).unwrap(); + let link = here.join("link.psd"); + std::os::unix::fs::symlink("../elsewhere/real.psd", &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(d.0.join("elsewhere/real.psd")).unwrap(), b"new"); + assert_no_temp(&d.0.join("elsewhere")); +} + +/// A symlink loop is an error; it never replaces either link. +#[cfg(unix)] +#[test] +fn symlink_loop_errors_without_replacing_the_links() { + let d = TempDir::new("loop"); + let a = d.0.join("a.psd"); + let b = d.0.join("b.psd"); + std::os::unix::fs::symlink(&b, &a).unwrap(); + std::os::unix::fs::symlink(&a, &b).unwrap(); + let e = atomic_write(&a, b"new").unwrap_err(); + assert!(e.to_string().contains("symbolic links"), "{e}"); + assert!(std::fs::symlink_metadata(&a).unwrap().file_type().is_symlink()); + assert!(std::fs::symlink_metadata(&b).unwrap().file_type().is_symlink()); + assert_no_temp(&d.0); +} + #[test] fn directory_target_or_bare_root_is_an_error_not_a_panic() { let d = TempDir::new("dir"); From d6ea04299f53726a8e83172f603be832844c12ef Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:48:54 +0200 Subject: [PATCH 0585/1452] =?UTF-8?q?Document=20tabs:=20keep=20whole=20nam?= =?UTF-8?q?es,=20hide=20what=20doesn't=20fit,=20list=20every=20document=20?= =?UTF-8?q?in=20the=20=C2=BB=20menu=20(#4525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (fixes #4487) * Document tabs: check the active document in the » menu (#4487) Like Photoshop's, the » menu marks the active document with a check mark, drawn as the menu bar's menus draw theirs. --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 30 ++++++++++----- crates/ui-egui/src/canvas/tabs_tests.rs | 51 ++++++++++++++++++++++++- crates/ui-egui/src/tab_strip.rs | 16 ++++---- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 15a5db6176..91644b5f73 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2230,7 +2230,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); let frame = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| { let (row, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), tab_h), Sense::hover()); - let f = crate::tab_strip::fit(&natural, selected, row.width(), DOC_TAB_MIN_W, crate::tab_strip::CHEVRON_W); + let f = fit_doc_tabs(&natural, selected, row.width()); let mut x = row.left(); let mut placed: Vec<(usize, Rect)> = Vec::with_capacity(f.shown.len()); for &(i, w) in &f.shown { @@ -2328,7 +2328,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let labels: Vec<&str> = app.session.documents().iter().map(|st| st.doc.name.as_str()).chain(opening.iter().map(|(_, name, _)| name.as_str())).collect(); let mut picked = None; - crate::tab_strip::overflow_button(ui, ui.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &f.overflow, &mut picked); + // Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks + // the active one (#4487). + let every: Vec = (0..labels.len()).collect(); + crate::tab_strip::overflow_button(ui, ui.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { Some(i) if i < tab_count => activate = Some(i), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), @@ -2502,15 +2505,21 @@ fn open_tab_clicks( } } -/// Document tabs: the chrome around a title (the × and its gaps), and the width a tab shrinks to -/// before it moves into the » overflow menu. +/// Document tabs: the chrome around a title (the × and its gaps). const DOC_TAB_PAD: f32 = 42.0; -const DOC_TAB_MIN_W: f32 = 72.0; /// The Studio strip's tabs carried 4 pt of spacing between them and 44 pt of chrome; `fit` sees /// one number per tab, so the gap rides in the natural width and comes back out when painted. const STUDIO_TAB_PAD: f32 = 48.0; const STUDIO_TAB_GAP: f32 = 4.0; +/// Fit document tabs as Photoshop does (#4487): a tab never shrinks below its whole title, so the +/// tabs that don't fit move into the » menu instead. Only the active tab, when it alone is wider +/// than the strip, is cut. +fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_strip::TabFit { + let widest = natural.iter().copied().filter(|w| w.is_finite()).fold(0.0, f32::max); + crate::tab_strip::fit(natural, selected, avail, widest, crate::tab_strip::CHEVRON_W) +} + /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask /// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { @@ -2542,8 +2551,8 @@ fn studio_tab_name(app: &PhotocraftApp, st: &photocraft_engine::DocState) -> Str } /// Photoshop document tabs: "name @ 33.3% (RGB/8)" on a dark strip; active tab matches panels. -/// Tabs that don't fit shrink and elide, and the ones that still don't fit move into a » menu at -/// the end of the strip; the active document's tab always stays on the strip (#1276). +/// Tabs keep their whole titles, and the ones that don't fit move into a » menu at the end of the +/// strip; the active document's tab always stays on the strip (#1276, #4487). fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let t = crate::theme::Tokens::get(ui.ctx()); let active = app.session.active_index().filter(|_| app.jobs.focus.is_none()); @@ -2572,7 +2581,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .focus .and_then(|job| opening.iter().position(|(open, _, _)| *open == job)) .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); - let f = crate::tab_strip::fit(&natural, selected, strip.width(), DOC_TAB_MIN_W, crate::tab_strip::CHEVRON_W); + let f = fit_doc_tabs(&natural, selected, strip.width()); let mut x = strip.left(); let mut placed: Vec<(usize, Rect)> = Vec::with_capacity(f.shown.len()); for &(i, w) in &f.shown { @@ -2651,7 +2660,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let r = Rect::from_min_size(egui::pos2(left, strip.top()), egui::vec2(crate::tab_strip::CHEVRON_W, strip.height())); let labels: Vec<&str> = titles.iter().map(String::as_str).collect(); let mut picked = None; - crate::tab_strip::overflow_button(ui, ui.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &f.overflow, &mut picked); + // Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks + // the active one (#4487). + let every: Vec = (0..labels.len()).collect(); + crate::tab_strip::overflow_button(ui, ui.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { Some(i) if i < tab_count => activate = Some(i), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 642ea413f4..42f5c58d8e 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -1,5 +1,6 @@ -//! #1276: a document tab strip with more tabs than fit keeps the active document's tab on the -//! strip, shrinks and elides the others, and moves the rest into a » menu (whole app, real tree). +//! #1276, #4487: a document tab strip with more tabs than fit keeps the active document's tab on +//! the strip, keeps whole titles on the tabs it shows, and moves the rest into a » menu (whole +//! app, real tree). use egui::vec2; use egui_kittest::Harness; @@ -177,3 +178,49 @@ fn quick_mask_mode_names_the_quick_mask_in_the_tab() { h.state_mut().run("select.editInQuickMaskMode", json!({"on": false})).unwrap(); assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Layer 1, RGB/8)*"); } + +/// Whether any title painted on the strip was cut with '…'. +fn elided_on_strip(h: &Harness<'_, PhotocraftApp>) -> bool { + fn elided(shape: &egui::Shape, strip: egui::Rect) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.elided && strip.intersects(text.visual_bounding_rect()), + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| elided(shape, strip)), + _ => false, + } + } + let strip = strip(h).rect; + h.output().shapes.iter().any(|shape| elided(&shape.shape, strip)) +} + +/// #4487: a crowded strip keeps whole names on the tabs it shows and moves the rest into the » +/// menu instead of shrinking them all, and the menu lists every open document with the active one +/// checked, as Photoshop's does. +#[test] +fn crowded_tabs_keep_their_whole_names_and_the_menu_lists_every_document() { + for theme in [ThemeKind::Pro, ThemeKind::Studio] { + let mut h = harness(14, theme); + let s = strip(&h); + assert!(s.tabs.len() > 1 && s.tabs.len() < 14, "{theme:?}: {} shown", s.tabs.len()); + assert!(!elided_on_strip(&h), "{theme:?}: no tab title is cut"); + // The last new document is active; then one that was behind the menu, then the first. + for active in [13, 5, 0] { + h.state_mut().session.set_active(active); + h.run_steps(2); + let pro = crate::theme::Tokens::get(&h.ctx).pro; + let labels: Vec = + (0..14).map(|i| if pro { pro_tab_title(h.state(), i) } else { h.state().session.documents()[i].doc.name.clone() }).collect(); + h.get_by_label("More documents").click(); + h.run_steps(2); + for (i, label) in labels.iter().enumerate() { + // The menu bar's padding: an unchecked row keeps the check mark's room, so names line up. + let (on, off) = (format!("✔ {label}"), format!(" {label}")); + let (want, other) = if i == active { (&on, &off) } else { (&off, &on) }; + assert_eq!(h.query_all_by_label(want).count(), 1, "{theme:?}, active {active}: document {i} is in the menu as {want:?}"); + assert_eq!(h.query_all_by_label(other).count(), 0, "{theme:?}, active {active}: document {i} is not {other:?}"); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label(&format!("✔ {}", labels[active])).is_none(), "{theme:?}: the menu closed"); + } + } +} diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index 61650a1182..1419471f1f 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -54,8 +54,10 @@ pub struct StripOut { pub const CHEVRON_W: f32 = 18.0; /// Draw the » overflow button at `r`: hover tip `tip`, listing the tabs named by `labels` at the -/// indices in `overflow`. A picked index is left in `picked`. -pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[&str], overflow: &[usize], picked: &mut Option) { +/// indices in `overflow`, the `checked` one with a check mark as in the menu bar's menus. A picked +/// index is left in `picked`. +#[allow(clippy::too_many_arguments)] +pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[&str], overflow: &[usize], checked: Option, picked: &mut Option) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(r, id, Sense::click()); let mut paint_ui = ui.new_child(egui::UiBuilder::new().max_rect(r)); @@ -69,9 +71,9 @@ pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[ egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(140.0); for &i in overflow { - if let Some(name) = labels.get(i) - && ui.button(*name).clicked() - { + let Some(name) = labels.get(i) else { continue }; + let text = if checked.is_some() { format!("{} {name}", if checked == Some(i) { "✔" } else { " " }) } else { (*name).to_owned() }; + if ui.button(text).clicked() { *picked = Some(i); ui.close(); } @@ -161,7 +163,7 @@ fn tabs_in( if !f.overflow.is_empty() && f.overflow_width > 0.0 { let r = Rect::from_min_size(pos2(x, area.top()), vec2(f.overflow_width, area.height())); let mut picked = None; - overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), translated_tabs, &f.overflow, &mut picked); + overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), translated_tabs, &f.overflow, None, &mut picked); if let Some(i) = picked { out.clicked = true; *selected = i; @@ -358,7 +360,7 @@ mod tests { let mut picked = None; let input = egui::RawInput { events: vec![egui::Event::PointerMoved(rect.center())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - overflow_button(ui, ui.id().with("tiny-overflow"), rect, "More panels", &["Layers"], &[0], &mut picked); + overflow_button(ui, ui.id().with("tiny-overflow"), rect, "More panels", &["Layers"], &[0], None, &mut picked); }); output.textures_delta.clear(); let mut icons = 0; From c39d752758155492bbaba71d802cfaa03206fb4a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:48:59 +0200 Subject: [PATCH 0586/1452] Translate the brush size and hardness shortcut rows in every language (#6742) (#6831) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/cs.tsv | 4 ++++ crates/ui-egui/src/i18n/el.tsv | 4 ++++ crates/ui-egui/src/i18n/es.tsv | 4 ++++ crates/ui-egui/src/i18n/fr.tsv | 4 ++++ crates/ui-egui/src/i18n/hu.tsv | 4 ++++ crates/ui-egui/src/i18n/id.tsv | 4 ++++ crates/ui-egui/src/i18n/ja.tsv | 4 ++++ crates/ui-egui/src/i18n/ko.tsv | 4 ++++ crates/ui-egui/src/i18n/mod.rs | 15 +++++++++++++++ crates/ui-egui/src/i18n/nl.tsv | 4 ++++ crates/ui-egui/src/i18n/pl.tsv | 4 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++++ crates/ui-egui/src/i18n/ru.tsv | 4 ++++ crates/ui-egui/src/i18n/sr.tsv | 4 ++++ crates/ui-egui/src/i18n/tr.tsv | 4 ++++ crates/ui-egui/src/i18n/uk.tsv | 4 ++++ crates/ui-egui/src/i18n/vi.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++++ 19 files changed, 87 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d9177390ac..6175183cb5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3834,3 +3834,7 @@ navigator Zoom Zvětšení GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. + Decrease Brush Size Zmenšit velikost štětce + Increase Brush Size Zvětšit velikost štětce + Decrease Brush Hardness Snížit tvrdost štětce + Increase Brush Hardness Zvýšit tvrdost štětce diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f1c5a98fb3..d21c3ba6e4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3926,3 +3926,7 @@ curves Smooth Εξομάλυνση GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. + Decrease Brush Size Μείωση μεγέθους πινέλου + Increase Brush Size Αύξηση μεγέθους πινέλου + Decrease Brush Hardness Μείωση σκληρότητας πινέλου + Increase Brush Hardness Αύξηση σκληρότητας πινέλου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0b535aa251..4956fcd97b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4188,3 +4188,7 @@ navigator Zoom Zoom GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. + Decrease Brush Size Reducir tamaño del pincel + Increase Brush Size Aumentar tamaño del pincel + Decrease Brush Hardness Reducir dureza del pincel + Increase Brush Hardness Aumentar dureza del pincel diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5be59a66e9..62b2e8ea26 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3234,3 +3234,7 @@ curves Smooth Lisser Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. + Decrease Brush Size Réduire la taille du pinceau + Increase Brush Size Augmenter la taille du pinceau + Decrease Brush Hardness Réduire la dureté du pinceau + Increase Brush Hardness Augmenter la dureté du pinceau diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e24215c49f..a52cb5fdba 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4145,3 +4145,7 @@ cameraRaw Yellow Sárga GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. + Decrease Brush Size Ecsetméret csökkentése + Increase Brush Size Ecsetméret növelése + Decrease Brush Hardness Ecsetkeménység csökkentése + Increase Brush Hardness Ecsetkeménység növelése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ddbac14435..14e65b2b72 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3221,3 +3221,7 @@ curves Smooth Haluskan Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. + Decrease Brush Size Perkecil ukuran kuas + Increase Brush Size Perbesar ukuran kuas + Decrease Brush Hardness Kurangi kekerasan kuas + Increase Brush Hardness Tambah kekerasan kuas diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6f66ee603b..b14f5c1192 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3903,3 +3903,7 @@ curves Smooth スムーズ Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 + Decrease Brush Size ブラシサイズを小さく + Increase Brush Size ブラシサイズを大きく + Decrease Brush Hardness ブラシの硬さを下げる + Increase Brush Hardness ブラシの硬さを上げる diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 472c28d921..ce06b65cdc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3405,3 +3405,7 @@ curves Smooth 매끄럽게 Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. + Decrease Brush Size 브러시 크기 줄이기 + Increase Brush Size 브러시 크기 늘리기 + Decrease Brush Hardness 브러시 경도 줄이기 + Increase Brush Hardness 브러시 경도 늘리기 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..c7a0d574b2 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -514,6 +514,21 @@ mod tests { assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); } + /// The brush size/hardness shortcut rows (`tools.*` commands without a menu item) must be translated in + /// every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found by searching. + #[test] + fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { + let keys = ["Decrease Brush Size", "Increase Brush Size", "Decrease Brush Hardness", "Increase Brush Hardness"]; + for lang in Lang::all().filter(|lang| *lang != Lang::EN) { + for key in keys { + assert!(has(lang, key), "{} has no translation for {key:?}", lang.code()); + } + } + let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); + assert_eq!(tr(zh, "Decrease Brush Size"), "减小画笔大小"); + assert_eq!(tr(zh, "Increase Brush Size"), "增大画笔大小"); + } + #[test] fn simplified_chinese_covers_dynamic_shortcuts_and_layer_counts() { let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 16e31c91db..394aaecbbb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3986,3 +3986,7 @@ curves Smooth Vloeiend maken GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. + Decrease Brush Size Penseelgrootte verkleinen + Increase Brush Size Penseelgrootte vergroten + Decrease Brush Hardness Penseelhardheid verlagen + Increase Brush Hardness Penseelhardheid verhogen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1dc48ac834..660e93bbb7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3825,3 +3825,7 @@ curves Smooth Wygładź Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. + Decrease Brush Size Zmniejsz rozmiar pędzla + Increase Brush Size Zwiększ rozmiar pędzla + Decrease Brush Hardness Zmniejsz twardość pędzla + Increase Brush Hardness Zwiększ twardość pędzla diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c3c82e8fce..338c4fadb7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4016,3 +4016,7 @@ layerLabel No Color Sem cor GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. + Decrease Brush Size Diminuir tamanho do pincel + Increase Brush Size Aumentar tamanho do pincel + Decrease Brush Hardness Diminuir dureza do pincel + Increase Brush Hardness Aumentar dureza do pincel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3bc828b001..11fd43811a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3199,3 +3199,7 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + Decrease Brush Size Уменьшить размер кисти + Increase Brush Size Увеличить размер кисти + Decrease Brush Hardness Уменьшить жёсткость кисти + Increase Brush Hardness Увеличить жёсткость кисти diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2d4044d019..25175aeff2 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4299,3 +4299,7 @@ shape Appearance Izgled GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. + Decrease Brush Size Smanji veličinu četkice + Increase Brush Size Povećaj veličinu četkice + Decrease Brush Hardness Smanji tvrdoću četkice + Increase Brush Hardness Povećaj tvrdoću četkice diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce2ef023c3..6af5510125 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3956,3 +3956,7 @@ cameraData Lens Lens GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. + Decrease Brush Size Fırça boyutunu küçült + Increase Brush Size Fırça boyutunu büyüt + Decrease Brush Hardness Fırça sertliğini azalt + Increase Brush Hardness Fırça sertliğini artır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6450b4e6ca..3de0318810 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3926,3 +3926,7 @@ curves Smooth Згладити Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. + Decrease Brush Size Зменшити розмір пензля + Increase Brush Size Збільшити розмір пензля + Decrease Brush Hardness Зменшити жорсткість пензля + Increase Brush Hardness Збільшити жорсткість пензля diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d32ac2ff1f..fd7950b930 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3713,3 +3713,7 @@ curves Smooth Làm mượt Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. + Decrease Brush Size Giảm kích thước cọ + Increase Brush Size Tăng kích thước cọ + Decrease Brush Hardness Giảm độ cứng của cọ + Increase Brush Hardness Tăng độ cứng của cọ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f975c957e..01e0d57051 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2847,3 +2847,7 @@ curves Smooth 平滑 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 + Decrease Brush Size 减小画笔大小 + Increase Brush Size 增大画笔大小 + Decrease Brush Hardness 减小画笔硬度 + Increase Brush Hardness 增大画笔硬度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be6d0d364a..2d9ad2905a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2832,3 +2832,7 @@ curves Smooth 平滑 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 + Decrease Brush Size 縮小筆刷大小 + Increase Brush Size 放大筆刷大小 + Decrease Brush Hardness 降低筆刷硬度 + Increase Brush Hardness 提高筆刷硬度 From 8d2e615ab93d424c4ec25ab15b4edb5368d1f9a6 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:50:35 +0300 Subject: [PATCH 0587/1452] i18n(uk): translate color picker titles and rasterization warnings (#6153) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/live_tests.rs | 40 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 8 ++++++ 2 files changed, 48 insertions(+) diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 89c47ef1ac..7d8d6457ef 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -42,6 +42,46 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { }); } +#[test] +fn ukrainian_color_picker_titles_draw_in_the_selected_language() { + with_language(Lang::EN, || { + for (target, expected) in [("foreground", "Вибір кольору (основний колір)"), ("background", "Вибір кольору (колір тла)")] + { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); + crate::color_picker_ui::open(h.state_mut(), target); + h.run_steps(4); + let text = drawn_text(&h); + assert!(text.iter().any(|text| text == expected), "missing title {expected}"); + assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title"); + } + assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)"); + }); +} + +#[test] +fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { + use crate::rasterize_prompt::Kind; + + for kind in Kind::ALL { + let translated = tr(lang("uk"), kind.message()); + assert_ne!(translated, kind.message(), "{kind:?}: the warning must be translated"); + assert!(translated.contains("більше не можна буде редагувати"), "{kind:?}: explain the loss of editability"); + } + with_language(Lang::EN, || { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); + h.state_mut().run("type.create", json!({"text": "Editable text"})).unwrap(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + crate::rasterize_prompt::open(h.state_mut(), Kind::Type, layer, crate::state::Tool::Brush, [10.0, 10.0, 1.0]); + h.run_steps(4); + let expected = tr(lang("uk"), Kind::Type.message()); + let text = drawn_text(&h); + assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning"); + assert!(!text.iter().any(|text| text == Kind::Type.message())); + }); +} + #[test] fn french_and_korean_plural_messages_keep_placeholders() { for (n, expected) in [(0, "0 élément"), (1, "1 élément"), (2, "2 éléments"), (21, "21 éléments")] { diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3de0318810..37851e90f3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3930,3 +3930,11 @@ curves Smooth Згладити Increase Brush Size Збільшити розмір пензля Decrease Brush Hardness Зменшити жорсткість пензля Increase Brush Hardness Збільшити жорсткість пензля +# Dynamic color-picker titles and warnings about losing editable layer content. + Color Picker (Background Color) Вибір кольору (колір тла) + Color Picker (Foreground Color) Вибір кольору (основний колір) + Color Picker (Custom Canvas Color) Вибір кольору (власний колір полотна) + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Щоб продовжити, потрібно растеризувати цей текстовий шар. Після цього його текст більше не можна буде редагувати. Растеризувати текст? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? From a2372a9ce20523bc7b57e748fbc88caad88cc7e1 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 11:08:41 +0300 Subject: [PATCH 0588/1452] Fix rustfmt on viewport_icon for CI format check. Co-authored-by: Cursor --- apps/photocraft/src/app_icon.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index 142c47b9dc..e20e70206a 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -38,11 +38,7 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { /// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon /// on the Dock after the window opens (#7253). pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData { - if sets_runtime_icon(exe) { - window_icon() - } else { - egui::IconData::default() - } + if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() } } /// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. From 214b1a0710518c14239fd6625976419f63ea8989 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 11:25:02 +0300 Subject: [PATCH 0589/1452] Color panel: add RGB slider mode for accessibility (#7172) Users can switch the Color panel from HSB to RGB sliders under the field, with control-channel support and tests. Co-authored-by: Cursor --- crates/ui-egui/src/control.rs | 14 +++++++ crates/ui-egui/src/panels.rs | 73 +++++++++++++++++++++++++++++++++-- crates/ui-egui/src/state.rs | 12 ++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..cf4c4fed34 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -2273,6 +2273,20 @@ mod tests { assert!(app.ui.color_panel.background, "a bad value changes nothing"); } + #[test] + fn ui_set_color_panel_slider_mode() { + use crate::state::ColorPanelSliderMode; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "rgb"}}))["ok"], true); + assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Rgb); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "hsb"}}))["ok"], true); + assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "cmyk"}}))["ok"], false); + assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb); + } + #[test] fn ui_set_unknown_theme_error_names_every_theme_and_each_name_works() { use crate::theme::ThemeKind; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 10476013ee..79fc58bb9c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -6,7 +6,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId}; use serde_json::{Value, json}; use crate::mask_thumbs_ui::MaskKind; -use crate::state::Tool; +use crate::state::{ColorPanelSliderMode, Tool}; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; @@ -1765,7 +1765,14 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate match (group, tab) { (Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui), (Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui), - (Group::Color, 0) if pro => color_field(app, ui), + (Group::Color, 0) if pro => { + color_field(app, ui); + if app.ui.color_panel.slider_mode == ColorPanelSliderMode::Rgb { + ui.add_space(8.0); + rgb_sliders(app, ui); + } + color_slider_mode_row(app, ui); + }, (Group::Color, _) if pro => crate::swatches_ui::panel(app, ui), (Group::Color, 0) => crate::swatches_ui::panel(app, ui), (Group::Color, _) => color_picker(app, ui), @@ -2286,7 +2293,21 @@ fn empty(ui: &mut egui::Ui, s: &str) { fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { color_field(app, ui); ui.add_space(8.0); - hsb_sliders(app, ui); + match app.ui.color_panel.slider_mode { + ColorPanelSliderMode::Hsb => hsb_sliders(app, ui), + ColorPanelSliderMode::Rgb => rgb_sliders(app, ui), + } + color_slider_mode_row(app, ui); +} + +/// HSB or RGB sliders under the colour field (#7172). +fn color_slider_mode_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.add_space(6.0); + let mut mode = app.ui.color_panel.slider_mode; + let opts = [(ColorPanelSliderMode::Hsb, "HSB"), (ColorPanelSliderMode::Rgb, "RGB")]; + if !widgets::dropdown_wheel_hovered(ui, "color-panel-slider-mode", &mut mode, &opts, 72.0).0.is_empty() { + app.ui.color_panel.slider_mode = mode; + } } /// Hue, Saturation and Brightness sliders for the foreground colour. @@ -2319,6 +2340,32 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Red, Green and Blue sliders (0–255) for the colour the panel is editing (#7172). +fn rgb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let bg = app.ui.color_panel.background; + let mut c = if bg { app.session.tools.background } else { app.session.tools.foreground }; + let mut rgb = srgb_bytes(c).map(f32::from); + let dynamic = app.session.prefs().interface.dynamic_color_sliders; + let mut changed = false; + let [r0, g0, b0] = rgb.map(|v| v.round() as u8); + let r_stops = [Color32::from_rgb(0, g0, b0), Color32::from_rgb(255, g0, b0)]; + changed |= widgets::slider_row(ui, tl!("Red"), &mut rgb[0], 0.0..=255.0, "", dynamic.then_some(&r_stops)).changed(); + let g_stops = [Color32::from_rgb(r0, 0, b0), Color32::from_rgb(r0, 255, b0)]; + changed |= widgets::slider_row(ui, tl!("Green"), &mut rgb[1], 0.0..=255.0, "", dynamic.then_some(&g_stops)).changed(); + let b_stops = [Color32::from_rgb(r0, g0, 0), Color32::from_rgb(r0, g0, 255)]; + changed |= widgets::slider_row(ui, tl!("Blue"), &mut rgb[2], 0.0..=255.0, "", dynamic.then_some(&b_stops)).changed(); + if changed { + let q = |v: f32| (v.clamp(0.0, 255.0).round() / 255.0).clamp(0.0, 1.0); + c = [q(rgb[0]), q(rgb[1]), q(rgb[2]), 1.0]; + if bg { + app.session.tools.background = c; + } else { + app.session.tools.foreground = c; + crate::type_tool::foreground_changed(app); + } + } +} + // ----------------------------------------------------------------------------- layers /// The simple themes' single lock button. On the Background it converts the layer to a normal @@ -4467,6 +4514,26 @@ mod color_tests { } } + #[test] + fn rgb_slider_mode_shows_red_green_blue_rows() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb; + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 360.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + color_picker(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + for label in ["Red", "Green", "Blue"] { + h.get_by_label(label); + } + } + /// The Color panel's hex readout is editable, with or without the leading `#`. #[test] fn color_panel_hex_field_takes_a_typed_hex() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..a0cce8ced5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1048,6 +1048,15 @@ pub struct DockTabs { pub brushes: usize, } +/// Sliders under the Color panel's field: hue/saturation/brightness or red/green/blue (#7172). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColorPanelSliderMode { + #[default] + Hsb, + Rgb, +} + /// Color panel state. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -1057,6 +1066,9 @@ pub struct ColorPanelState { /// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995). #[serde(default)] pub web_only: bool, + /// HSB or RGB sliders under the colour field (#7172). + #[serde(default)] + pub slider_mode: ColorPanelSliderMode, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] From 89522c585993e9744bae1b38680ec79ff0f986d8 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 11:53:23 +0300 Subject: [PATCH 0590/1452] Fix panel-toggle shortcut test after modifier-aware capture (#7190). Press plain F5 in the F5-reassignment scenario so capture does not inherit Shift from unrelated key events; satisfy clippy on capture_key_press. Co-authored-by: Cursor --- crates/ui-egui/src/prefs_ui.rs | 8 ++++---- crates/ui-egui/src/shortcut_capture_tests.rs | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b86c459f75..a2e426db02 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -911,10 +911,10 @@ fn capture_key_press(capture_mods: egui::Modifiers, i: &egui::InputState) -> (eg pressed = Some((*key, mods | *modifiers)); break; } - if let egui::Event::Key { key, pressed: false, modifiers, .. } = e { - if is_modifier_key(*key) { - mods = *modifiers; - } + if let egui::Event::Key { key, pressed: false, modifiers, .. } = e + && is_modifier_key(*key) + { + mods = *modifiers; } } (mods, pressed) diff --git a/crates/ui-egui/src/shortcut_capture_tests.rs b/crates/ui-egui/src/shortcut_capture_tests.rs index f0dea8a783..769343e78f 100644 --- a/crates/ui-egui/src/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/shortcut_capture_tests.rs @@ -215,7 +215,13 @@ fn a_function_key_moved_off_a_panel_toggle_runs_its_new_command() { // row in the dialog is Photoshop's `window.panel.brushSettings` while the key ran the shell's // `window.toggle.brushSettings`. let (mut h, id) = dialog(1.0, "select.modify.feather", "feather"); - capture(&mut h, &crate::shortcuts::pretty("Shift+F6"), Key::ShiftLeft, Modifiers::NONE, Key::F5); + // Enter capture on the default row, then press plain F5 (not Shift+F5: #7190 records held modifiers). + h.get_by_label(&crate::shortcuts::pretty("Shift+F6")).click(); + h.run_steps(2); + key(&mut h, Key::F5, true, Modifiers::NONE); + h.run_steps(1); + key(&mut h, Key::F5, false, Modifiers::NONE); + h.run_steps(2); let message = field(&h, id, "message"); assert!(message.as_str().unwrap().contains("already in use"), "{message}"); crate::dialogs::confirm(h.state_mut(), id).unwrap(); From d1ee15a189de032bf847bb4ceb5f6e77c8a32c88 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Sun, 18 Oct 2026 11:04:18 +0200 Subject: [PATCH 0591/1452] ci: retrigger (portal_save timed out on ubuntu; 9193/9194 passed, branch is gpu-test-only) From 416e546562058abf63b9924219bd27f1a566cf0f Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 12:17:33 +0300 Subject: [PATCH 0592/1452] Format stroke_constraint call in lasso_ui for rustfmt. Co-authored-by: Cursor --- crates/ui-egui/src/lasso_ui.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 2774368bd4..e72d0b9f24 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -204,15 +204,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let (down, polygonal, previous) = (lasso.down, lasso.polygonal, lasso.cursor); let p = if down && tool == Tool::FreeformPen { let last = d.points.last().map_or(d.start, |pt| [pt[0], pt[1]]); - let [x, y] = crate::stroke_constraint::constrain( - d.tool, - &mut d.constrain, - d.start, - last, - p, - mods.shift, - zoom, - ); + let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, p, mods.shift, zoom); [x, y] } else { p From 59bb90ad1b96e4260ae5cb46b81f1eab31394615 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 12:30:48 +0300 Subject: [PATCH 0593/1452] Fix rustfmt on Color dock match arm trailing comma. Ubuntu CI runs cargo fmt --check; block arms must not use trailing commas. Co-authored-by: Cursor --- crates/ui-egui/src/panels.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 79fc58bb9c..1c5ad1b38a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1772,7 +1772,7 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate rgb_sliders(app, ui); } color_slider_mode_row(app, ui); - }, + } (Group::Color, _) if pro => crate::swatches_ui::panel(app, ui), (Group::Color, 0) => crate::swatches_ui::panel(app, ui), (Group::Color, _) => color_picker(app, ui), From ff5b1228c6d9bceb2efb2e74a33300a273d10dd5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:31:53 +0200 Subject: [PATCH 0594/1452] Tab in Full Screen Mode shows the menu bar and panels; leaving Full Screen restores them as before (#6760) --- crates/ui-egui/src/dock_tests.rs | 47 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 13 ++++++--- crates/ui-egui/src/menus.rs | 6 ++++ crates/ui-egui/src/state.rs | 5 ++++ crates/ui-egui/src/view_cmds.rs | 2 ++ 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index f0a64562c4..a7162dc37b 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -943,6 +943,53 @@ fn tab_hides_all_panels_and_shift_tab_only_the_dock() { assert!(serde_json::from_value::(v).unwrap().dock); } +#[test] +fn tab_in_full_screen_shows_and_hides_the_chrome_and_leaving_restores_the_panels() { + // #6760: Full Screen Mode starts with the chrome hidden; Tab shows the menu bar and panels over + // the canvas, Tab again hides them. Tab there never touches the panels' own state, so leaving + // Full Screen brings them back exactly as they were, however many Tabs were pressed. + let (mut app, _, _) = app_with_layers(); + let ctx = egui::Context::default(); + let run = |app: &mut PhotocraftApp, id: &str| crate::menus::invoke(app, &ctx, id, json!({})).unwrap(); + let state = |app: &PhotocraftApp| { + let p = &app.ui.panels; + (p.toolbar, p.options_bar, p.dock, p.hidden_by_tab) + }; + // Before entering: the options bar hidden by hand, then everything hidden by Tab (outside Full Screen). + for (hide_by_hand, tab_first) in [(false, false), (true, false), (false, true)] { + app.ui.panels = crate::state::Panels::default(); + app.ui.panels.options_bar = !hide_by_hand; + if tab_first { + run(&mut app, "window.togglePanels"); + } + let before = state(&app); + run(&mut app, "view.screenMode.fullScreen"); + assert!(!app.ui.panels.full_screen_chrome, "hidden on entry"); + run(&mut app, "window.togglePanels"); + assert!(app.ui.panels.full_screen_chrome, "Tab shows the chrome"); + assert_eq!(state(&app), before, "Tab in Full Screen leaves the panels alone"); + run(&mut app, "view.screenMode.standard"); + assert!(!app.ui.panels.full_screen_chrome); + assert_eq!(state(&app), before, "one Tab: restored as before entering"); + run(&mut app, "view.screenMode.fullScreen"); + run(&mut app, "window.togglePanels"); + run(&mut app, "window.togglePanels"); + assert!(!app.ui.panels.full_screen_chrome, "Tab twice hides the chrome again"); + assert_eq!(state(&app), before); + run(&mut app, "view.screenMode.standard"); + assert_eq!(state(&app), before, "two Tabs: restored as before entering"); + } + // Entering again starts hidden, even when the last visit left the chrome shown. + run(&mut app, "view.screenMode.fullScreen"); + run(&mut app, "window.togglePanels"); + run(&mut app, "view.screenMode.standard"); + run(&mut app, "view.screenMode.fullScreen"); + assert!(!app.ui.panels.full_screen_chrome); + // The flag is not saved. + let v = serde_json::to_value(&app.ui.panels).unwrap(); + assert!(v.get("full_screen_chrome").is_none()); +} + /// #2726: the Navigator sizes its own preview to the group (no outer scroll area, so no scroll /// fade over the thumbnail), keeping the document's aspect, with the zoom controls below it. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6685b9b77..be23de94d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2032,19 +2032,24 @@ impl eframe::App for PhotocraftApp { // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); let chrome = !self.ui.view.hides_chrome(); - if chrome && self.ui.panels.menu_bar { + if chrome { + self.ui.panels.full_screen_chrome = false; + } + // In Full Screen Mode, Tab shows the menu bar, options bar, toolbar and dock (#6760). + let tabbed_in = !chrome && self.ui.panels.full_screen_chrome; + if (chrome || tabbed_in) && self.ui.panels.menu_bar { panels::title_bar(self, ui); } - if chrome && self.ui.panels.options_bar { + if (chrome && self.ui.panels.options_bar) || tabbed_in { panels::options_bar(self, ui); } if chrome && self.ui.panels.status_bar { panels::status_bar(self, ui); } - if chrome && self.ui.panels.toolbar { + if (chrome && self.ui.panels.toolbar) || tabbed_in { panels::toolbar(self, ui); } - if chrome && self.ui.panels.dock { + if (chrome && self.ui.panels.dock) || tabbed_in { panels::right_dock(self, ui); } let t = theme::Tokens::get(&ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bbaf6fa6a4..f6fefae468 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -580,6 +580,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "window.togglePanels" => { // Tab hides the Tools panel, the options bar and the dock; Tab again shows what it hid. + if app.ui.view.hides_chrome() { + // Full Screen Mode: Tab only shows or hides the chrome for now (#6760); the panels' + // own state, `hidden_by_tab` included, stays as it was before entering. + app.ui.panels.full_screen_chrome = !app.ui.panels.full_screen_chrome; + return Ok(Value::Null); + } let p = &mut app.ui.panels; match p.hidden_by_tab.take() { Some([toolbar, options, dock]) if !(p.toolbar || p.options_bar || p.dock) => { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..3dbfb13aa7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -404,6 +404,10 @@ pub struct Panels { /// What Tab hid (toolbar, options bar, dock), so a second Tab brings back just those. #[serde(default, skip_serializing_if = "Option::is_none")] pub hidden_by_tab: Option<[bool; 3]>, + /// Tab in Full Screen Mode shows the menu bar, toolbar, options bar and dock over the canvas + /// (#6760). Only a view of the moment: not saved, and the panels' own flags stay untouched. + #[serde(skip)] + pub full_screen_chrome: bool, /// The right dock's icon rail. An app that embeds PhotoCraft's UI can hide it for a simpler view. #[serde(default = "yes")] pub rail: bool, @@ -435,6 +439,7 @@ impl Default for Panels { toolbar_double: false, dock: true, hidden_by_tab: None, + full_screen_chrome: false, rail: true, menu_bar: true, gradient_editor: false, diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..ffee2ef962 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -704,6 +704,8 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res k }; o.screen_mode = next.to_string(); + // Tab's view of the chrome (#6760) lasts only while in Full Screen Mode. + app.ui.panels.full_screen_chrome = false; // Both full-screen modes take the whole display; the menu bar stays in the first. let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false))); let was_maximized = ctx.data(|d| d.get_temp::(was_maximized_id())).unwrap_or(false); From 57519aee7deebe6df7404739ec882c3d6217e12e Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 15:52:33 +0530 Subject: [PATCH 0595/1452] =?UTF-8?q?Hand=20tool:=20a=20middle-button=20pa?= =?UTF-8?q?n=20keeps=20panning=20when=20=E2=8C=98=20or=20=E2=8C=A5=20goes?= =?UTF-8?q?=20down=20(#6434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the Hand selected, ⌘ or ⌥ pressed during a middle-button drag turned it into the temporary Zoom tool, which outranks the middle button's Hand, so the pan stopped. A held middle button now keeps the Hand's modifiers from zooming. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/hold_keys.rs | 5 +++-- crates/ui-egui/src/hold_keys/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index e492515e62..8b8bef092c 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -124,10 +124,11 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool { /// without ⌘) Zoom Out, as in Photoshop; the Modifier Keys panel's latched keys count. Not while /// Free Transform is open (its box keeps ⌘ for the distort handles), and a modifier pressed during /// a Hand press doesn't turn it into a zoom, release included: only a press made with it held -/// zooms (that one is latched by [`for_frame`] until the button comes up). +/// zooms (that one is latched by [`for_frame`] until the button comes up). A middle-button pan +/// stays a pan whatever is held. fn hand_zoom(app: &PhotocraftApp, i: &InputState) -> Option { let p = &i.pointer; - let hand_press = (p.primary_down() || p.primary_released()) && !p.primary_pressed(); + let hand_press = ((p.primary_down() || p.primary_released()) && !p.primary_pressed()) || p.middle_down(); if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || hand_press { return None; } diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index e88ec917a3..c66bdc920a 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -857,6 +857,31 @@ fn modifiers_pressed_during_a_hand_pan_keep_panning() { } } +/// A middle-button pan with the Hand selected keeps panning when ⌘ or ⌥ goes down during it. +#[test] +fn modifiers_pressed_during_a_middle_button_pan_keep_panning() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + h.event(Event::PointerButton { pos: p, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + hold_mods(&mut h, m); + assert_eq!(held_tool(h.state(), &h.ctx), None, "{m:?}"); + let q = p + vec2(60.0, 40.0); + h.event(Event::PointerMoved(q)); + h.run_steps(2); + h.event(Event::PointerButton { pos: q, button: PointerButton::Middle, pressed: false, modifiers: platform(m) }); + h.run_steps(1); + hold_mods(&mut h, Modifiers::NONE); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, z0, "{m:?}: no zoom"); + assert!(v.center[0] < c0[0] - 30.0 && v.center[1] < c0[1] - 20.0, "{m:?}: panned {c0:?} -> {:?}", v.center); + } +} + /// Free Transform keeps ⌘ (its distort handles) while the Hand is selected. #[test] fn cmd_with_the_hand_tool_is_not_a_zoom_during_free_transform() { From 87c53e06abefaa4df46815da425753409528866f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 15:52:34 +0530 Subject: [PATCH 0596/1452] Value fields: leave the view its whole wheel, and sideways swipes (#5473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a field had focus, every frame without a wheel event zeroed egui's smoothed scroll, cutting the tail off the ⌘/Ctrl + wheel the field leaves to the view (a 40 pt pan moved 36). Only the tail of a gesture the field took is dropped now. A plain sideways scroll (a trackpad swipe) is the view's too; Shift + a sideways wheel still steps by ten. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 50 ++++++++++++++++----- crates/ui-egui/src/field_wheel_app_tests.rs | 32 +++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index d8924b0d8c..c2492e04f8 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -40,9 +40,11 @@ fn taken_id() -> Id { Id::new("pc-field-wheel-taken") } -/// A wheel event the focused field takes: plain or Shift, not the view's Alt or Ctrl/Cmd zoom. +/// A wheel event a field takes: plain or Shift, not the view's Alt or Ctrl/Cmd zoom, nor a plain +/// sideways scroll (a trackpad swipe pans the view; Shift+wheel can arrive sideways). fn for_field(e: &Event) -> bool { - matches!(e, Event::MouseWheel { modifiers, .. } if !modifiers.alt && !modifiers.ctrl && !modifiers.command && !modifiers.mac_cmd) + matches!(e, Event::MouseWheel { delta, modifiers, .. } + if (delta.y != 0.0 || modifiers.shift) && !modifiers.alt && !modifiers.ctrl && !modifiers.command && !modifiers.mac_cmd) } /// Before this frame's UI is laid out: hand the wheel to the focused value field, if any, and @@ -53,20 +55,23 @@ pub fn begin_frame(ctx: &Context) { let focused = ctx.data(|d| d.get_temp::<(Id, Id)>(focused_id())).filter(|(id, _)| ctx.memory(|m| m.has_focus(*id))); let (now, wheel) = ctx.input(|i| (i.time, i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })))); if let Some((_, field)) = focused { - let taken: Vec = ctx.input_mut(|i| { + let (taken, view_wheel): (Vec, bool) = ctx.input_mut(|i| { let (taken, rest) = std::mem::take(&mut i.events).into_iter().partition(for_field); i.events = rest; - // egui spreads each notch over a few frames: none of it scrolls anything meanwhile. - if !i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })) { - i.smooth_scroll_delta = Vec2::ZERO; - } - taken + (taken, i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. }))) }); + if wheel { + // The latest wheel is the view's when any of it was left to the view (Alt, Ctrl/Cmd). + let owner = if view_wheel { Owner::Elsewhere } else { Owner::Field(field) }; + ctx.data_mut(|d| d.insert_temp(gesture_id(), Gesture { last: now, owner })); + } if !taken.is_empty() { - ctx.data_mut(|d| { - d.insert_temp(taken_id(), taken); - d.insert_temp(gesture_id(), Gesture { last: now, owner: Owner::Field(field) }); - }); + ctx.data_mut(|d| d.insert_temp(taken_id(), taken)); + } + // egui spreads each notch over a few frames: the tail of the field's own notches scrolls + // nothing, while the view's keeps all of its own. + if ctx.data(|d| d.get_temp::(gesture_id())).is_some_and(|g| g.owner == Owner::Field(field)) { + ctx.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); } return; } @@ -209,6 +214,27 @@ mod tests { assert!(h.state().1 > 0.0, "the panel scrolled"); } + /// A plain sideways scroll (a trackpad swipe) is the view's, focused or hovered; Shift+wheel + /// that the system turned sideways still steps by ten. + #[test] + fn a_sideways_scroll_steps_a_field_only_with_shift() { + for focus in [false, true] { + for (m, want) in [(Modifiers::NONE, 100.0), (Modifiers::SHIFT, 110.0)] { + let mut h = harness(100.0); + if focus { + h.get_by_role(Role::SpinButton).click(); + h.run(); + } + let at = field(&h).center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(1.0, 0.0), phase: egui::TouchPhase::Move, modifiers: m }); + h.run_steps(4); + assert_eq!(h.state().0, want, "focus {focus}, {m:?}"); + } + } + } + #[test] fn a_disabled_field_ignores_the_wheel() { let mut h = harness(100.0); diff --git a/crates/ui-egui/src/field_wheel_app_tests.rs b/crates/ui-egui/src/field_wheel_app_tests.rs index ba902da508..6d4f56db3e 100644 --- a/crates/ui-egui/src/field_wheel_app_tests.rs +++ b/crates/ui-egui/src/field_wheel_app_tests.rs @@ -71,3 +71,35 @@ fn the_wheel_over_a_field_steps_it_without_a_click() { assert!(!field(&h, "21").is_focused(), "hovering doesn't put the cursor in the field"); assert_eq!(view(&h), before, "the canvas didn't move"); } + +/// How far a wheel of `delta` lines with `modifiers` over the canvas moves the view, a Smoothing +/// field focused or not, once egui's smoothed scroll has run out. The field isn't stepped. +fn canvas_pan(focus: bool, delta: egui::Vec2, modifiers: egui::Modifiers) -> (f32, [f32; 2]) { + let mut h = harness(); + if focus { + field(&h, "10").click(); + h.run(); + } + let canvas = egui::pos2(640.0, 500.0); + h.hover_at(canvas); + h.run_steps(1); + let before = view(&h); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, phase: egui::TouchPhase::Move, modifiers }); + h.run_steps(40); + assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("10")), "focus {focus}: the field wasn't stepped"); + let after = view(&h); + (after.0 / before.0, [after.1[0] - before.1[0], after.1[1] - before.1[1]]) +} + +/// The wheel a focused field leaves to the view moves it as far as with no field focused: its +/// smoothed tail isn't cut off (⌘/Ctrl + wheel, and a plain sideways trackpad swipe). +#[test] +fn the_view_keeps_its_whole_wheel_while_a_field_is_focused() { + for (delta, m) in [(vec2(0.0, -1.0), egui::Modifiers::COMMAND), (vec2(0.0, -1.0), egui::Modifiers::CTRL), (vec2(-1.0, 0.0), egui::Modifiers::NONE)] { + let free = canvas_pan(false, delta, m); + assert!(free != (1.0, [0.0, 0.0]), "{delta:?} {m:?}: the wheel moves the view"); + let focused = canvas_pan(true, delta, m); + let close = (focused.0 - free.0).abs() < 1e-3 && (0..2).all(|k| (focused.1[k] - free.1[k]).abs() < 0.5); + assert!(close, "{delta:?} {m:?}: {focused:?} with a field focused, {free:?} without"); + } +} From 20385b70439b1fe2d5986218483e3c04ee32de0b Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 15:52:34 +0530 Subject: [PATCH 0597/1452] Test: a refused restore_layer_selection changes no selection or revision Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 934471164e..ee9f2c9b5a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1595,8 +1595,13 @@ fn restore_layer_selection_puts_back_what_still_exists() { let st = s.active().unwrap(); assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (Some(b), vec![a, b], Some(a))); assert_eq!(st.saved_revision, st.revision, "selection is not an edit"); + let snap = |s: &Session| { + let st = s.active().unwrap(); + (st.active_layer, st.selected_layers(), st.layer_anchor, st.revision, st.saved_revision) + }; + let before = snap(&s); assert!(matches!(s.restore_layer_selection(Some(gone), &[gone], None), Err(EngineError::NoLayer(_)))); - assert_eq!(s.active().unwrap().active_layer, Some(b), "an error changes nothing"); + assert_eq!(snap(&s), before, "an error changes nothing"); s.restore_layer_selection(None, &[a], Some(a)).unwrap(); let st = s.active().unwrap(); assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected"); From 9a834aefe8191281bf4022258a7522f737c9d658 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 16:10:12 +0530 Subject: [PATCH 0598/1452] Value fields: a trackpad gesture that starts with no delta still steps a hovered field (#5473) macOS opens a trackpad gesture with a zero-delta wheel event. Since plain sideways scrolls were left to the view, that event was too, so a field under the pointer never claimed the gesture and the swipe didn't step it. An event with no delta at all is the field's again. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index c2492e04f8..c78a737d02 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -41,10 +41,12 @@ fn taken_id() -> Id { } /// A wheel event a field takes: plain or Shift, not the view's Alt or Ctrl/Cmd zoom, nor a plain -/// sideways scroll (a trackpad swipe pans the view; Shift+wheel can arrive sideways). +/// sideways scroll (a trackpad swipe pans the view; Shift+wheel can arrive sideways). An event +/// with no delta at all counts: macOS starts a trackpad gesture with one, and it is what lets a +/// field under the pointer claim the gesture. fn for_field(e: &Event) -> bool { matches!(e, Event::MouseWheel { delta, modifiers, .. } - if (delta.y != 0.0 || modifiers.shift) && !modifiers.alt && !modifiers.ctrl && !modifiers.command && !modifiers.mac_cmd) + if (delta.y != 0.0 || delta.x == 0.0 || modifiers.shift) && !modifiers.alt && !modifiers.ctrl && !modifiers.command && !modifiers.mac_cmd) } /// Before this frame's UI is laid out: hand the wheel to the focused value field, if any, and @@ -235,6 +237,22 @@ mod tests { } } + /// A trackpad gesture that macOS starts with a zero-delta event, then moves a frame later, + /// steps the field under the pointer. + #[test] + fn a_trackpad_gesture_that_starts_with_no_delta_steps_a_hovered_field() { + let mut h = harness(100.0); + h.hover_at(field(&h).center()); + h.run_steps(1); + let ev = |phase, y| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase, modifiers: Modifiers::NONE }; + h.event(ev(egui::TouchPhase::Start, 0.0)); + h.run_steps(1); + h.event(ev(egui::TouchPhase::Move, 2.0)); + h.run_steps(4); + assert_eq!(h.state().0, 102.0); + assert_eq!(h.state().1, 0.0, "the panel didn't scroll"); + } + #[test] fn a_disabled_field_ignores_the_wheel() { let mut h = harness(100.0); From a5aa05f54bdf7e3ba4eb66c6292a8ce92070fc1a Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 17:11:55 +0530 Subject: [PATCH 0599/1452] Value fields: a sideways swipe after a zero-delta start pans the view (#5473) A trackpad gesture that starts with a zero-delta event over a hovered field let the field claim it; a plain sideways Move that followed was not the field's, yet the field kept the gesture and zeroed the smoothed scroll, so the view could not pan. Wheel the field doesn't take now hands the rest of the gesture to the view. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index c78a737d02..b72a37d1f8 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -108,12 +108,20 @@ pub fn notches(ui: &Ui, field: Id, focused: bool, rect: Rect) -> f32 { return 0.0; } let Some(mut g) = ui.data(|d| d.get_temp::(gesture_id())) else { return 0.0 }; - let events: Vec = ui.input(|i| i.events.iter().filter(|e| for_field(e)).cloned().collect()); + let (events, wheel): (Vec, bool) = + ui.input(|i| (i.events.iter().filter(|e| for_field(e)).cloned().collect(), i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })))); match g.owner { Owner::New if !events.is_empty() => { g.owner = Owner::Field(field); ui.data_mut(|d| d.insert_temp(gesture_id(), g)); } + // Wheel the field doesn't take (a sideways swipe after a zero-delta start) hands the + // rest of the gesture to the view, which pans with it. + Owner::Field(id) if id == field && wheel && events.is_empty() => { + g.owner = Owner::Elsewhere; + ui.data_mut(|d| d.insert_temp(gesture_id(), g)); + return 0.0; + } Owner::Field(id) if id == field => {} _ => return 0.0, } @@ -138,21 +146,24 @@ mod tests { use egui_kittest::Harness; use egui_kittest::kittest::Queryable; - /// The value, the scroll area's offset, and whether the field is enabled. - type State = (f32, f32, bool); + /// The value, the scroll area's vertical offset, whether the field is enabled, and the + /// horizontal offset. + type State = (f32, f32, bool, f32); - /// A value field (0–1000) at the top of a scroll area 100 pt tall, above a tall filler. + /// A value field (0–1000) at the top left of a scroll area 100 pt tall, beside and above a + /// large filler. fn harness(value: f32) -> Harness<'static, State> { let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state( |ui, s: &mut State| { super::begin_frame(ui.ctx()); - let out = egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| { + let out = egui::ScrollArea::both().max_height(100.0).max_width(200.0).show(ui, |ui| { ui.add_enabled_ui(s.2, |ui| crate::widgets::value_field(ui, &mut s.0, 0.0..=1000.0, "px", 90.0)); - ui.allocate_space(vec2(200.0, 1000.0)); + ui.allocate_space(vec2(1000.0, 1000.0)); }); s.1 = out.state.offset.y; + s.3 = out.state.offset.x; }, - (value, 0.0, true), + (value, 0.0, true, 0.0), ); h.run(); h @@ -253,6 +264,22 @@ mod tests { assert_eq!(h.state().1, 0.0, "the panel didn't scroll"); } + /// A trackpad gesture that starts with a zero-delta event over a field, then swipes sideways, + /// pans the view and leaves the field alone. + #[test] + fn a_sideways_swipe_after_a_zero_delta_start_pans_the_view() { + let mut h = harness(100.0); + h.hover_at(field(&h).center()); + h.run_steps(1); + let ev = |phase, x| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(x, 0.0), phase, modifiers: Modifiers::NONE }; + h.event(ev(egui::TouchPhase::Start, 0.0)); + h.run_steps(1); + h.event(ev(egui::TouchPhase::Move, -2.0)); + h.run_steps(4); + assert_eq!(h.state().0, 100.0, "the field was not stepped"); + assert!(h.state().3 > 0.0, "the panel panned sideways"); + } + #[test] fn a_disabled_field_ignores_the_wheel() { let mut h = harness(100.0); From 221adb3d67b48395b5fe955a48b1d241cb03817b Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 17:30:23 +0530 Subject: [PATCH 0600/1452] Value fields: a sideways swipe off the field also hands the gesture to the view (#5473) The hand-off from a5aa05f5 sat behind the hover check, so a sideways swipe with the pointer off the field kept the gesture the field's; coming back over it in the same gesture stepped it. The hand-off now runs before the hover check. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 49 +++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index b72a37d1f8..82e93a90d7 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -104,24 +104,28 @@ pub fn notches(ui: &Ui, field: Id, focused: bool, rect: Rect) -> f32 { let events: Vec = if focused { ui.data_mut(|d| d.remove_temp::>(taken_id())).unwrap_or_default() } else { - if !ui.is_enabled() || !ui.rect_contains_pointer(rect) { + if !ui.is_enabled() { return 0.0; } let Some(mut g) = ui.data(|d| d.get_temp::(gesture_id())) else { return 0.0 }; let (events, wheel): (Vec, bool) = ui.input(|i| (i.events.iter().filter(|e| for_field(e)).cloned().collect(), i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })))); + // Wheel the field doesn't take (a sideways swipe after a zero-delta start) hands the rest + // of the gesture to the view, which pans with it: also with the pointer off the field, so + // the gesture doesn't step it on coming back. + if g.owner == Owner::Field(field) && wheel && events.is_empty() { + g.owner = Owner::Elsewhere; + ui.data_mut(|d| d.insert_temp(gesture_id(), g)); + return 0.0; + } + if !ui.rect_contains_pointer(rect) { + return 0.0; + } match g.owner { Owner::New if !events.is_empty() => { g.owner = Owner::Field(field); ui.data_mut(|d| d.insert_temp(gesture_id(), g)); } - // Wheel the field doesn't take (a sideways swipe after a zero-delta start) hands the - // rest of the gesture to the view, which pans with it. - Owner::Field(id) if id == field && wheel && events.is_empty() => { - g.owner = Owner::Elsewhere; - ui.data_mut(|d| d.insert_temp(gesture_id(), g)); - return 0.0; - } Owner::Field(id) if id == field => {} _ => return 0.0, } @@ -153,7 +157,12 @@ mod tests { /// A value field (0–1000) at the top left of a scroll area 100 pt tall, beside and above a /// large filler. fn harness(value: f32) -> Harness<'static, State> { - let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state( + harness_stepping(value, 0.25) + } + + /// [`harness`] with each step `step_dt` seconds long. + fn harness_stepping(value: f32, step_dt: f32) -> Harness<'static, State> { + let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(step_dt).build_ui_state( |ui, s: &mut State| { super::begin_frame(ui.ctx()); let out = egui::ScrollArea::both().max_height(100.0).max_width(200.0).show(ui, |ui| { @@ -280,6 +289,28 @@ mod tests { assert!(h.state().3 > 0.0, "the panel panned sideways"); } + /// The same swipe with the pointer off the field hands the gesture to the view too: coming + /// back over the field in the same gesture doesn't step it. + #[test] + fn a_sideways_swipe_off_the_field_keeps_the_rest_of_the_gesture_the_views() { + // Short frames: moving the pointer takes a frame of its own, and the gesture must not end. + let mut h = harness_stepping(100.0, 1.0 / 30.0); + h.hover_at(field(&h).center()); + h.run_steps(1); + let ev = |phase, unit, d| egui::Event::MouseWheel { unit, delta: d, phase, modifiers: Modifiers::NONE }; + h.event(ev(egui::TouchPhase::Start, egui::MouseWheelUnit::Point, vec2(0.0, 0.0))); + h.run_steps(1); + // A short swipe, so the field stays in view. + h.hover_at(field(&h).center() + vec2(0.0, 40.0)); + h.event(ev(egui::TouchPhase::Move, egui::MouseWheelUnit::Point, vec2(-10.0, 0.0))); + h.run_steps(1); + h.hover_at(field(&h).center()); + h.event(ev(egui::TouchPhase::Move, egui::MouseWheelUnit::Line, vec2(0.0, -1.0))); + h.run_steps(4); + assert_eq!(h.state().0, 100.0, "the field was not stepped"); + assert!(h.state().1 > 0.0, "the panel scrolled"); + } + #[test] fn a_disabled_field_ignores_the_wheel() { let mut h = harness(100.0); From 3192457c022acd88f0a505d7e89746e5a8da4c8b Mon Sep 17 00:00:00 2001 From: Matthias Schlich Date: Sun, 18 Oct 2026 14:02:54 +0200 Subject: [PATCH 0601/1452] perf(smart-filters): find the object's bounds once per render Render filters on a smart object asked for the placed stage each time. When the stage cache had evicted it, every one of them placed (and decoded) the source again: four Lens Flares, four decodes. The bounds are now found once and reused (CodeRabbit review on #6160). --- crates/engine/src/smart_cmds.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 275f713422..127fcb8bcb 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -439,10 +439,17 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result (0, placed()?), }; let n = filters.len() as f32; + // The object's bounds, found once: the placed stage may have been evicted from the cache, and + // placing it again for every render filter would decode the source each time. + let mut object_bounds: Option = None; for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) { ctx.check()?; ctx.progress(i as f32 / n, ""); - let bounds = if renders_in_object(f) { filter_bounds(f, &placed()?.surface, canvas) } else { canvas }; + let bounds = match object_bounds { + _ if !renders_in_object(f) => canvas, + Some(b) => b, + None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)), + }; cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent)?, extent: cur.extent }; stack_cache::put(*key, &cur); } From c6e4d8a94528b437f60ae76c804c01aafd522737 Mon Sep 17 00:00:00 2001 From: wwewtech Date: Sun, 18 Oct 2026 16:08:40 +0400 Subject: [PATCH 0602/1452] test(engine): make the define-brush mask allocation fail on its own, fail-after-one (#212) --- crates/engine/src/allocation.rs | 41 +++++++++++++++++++++++++-- crates/engine/src/brush_cmds/tests.rs | 23 +++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 7c29ecc8ee..fc59a13730 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -5,6 +5,7 @@ use crate::{EngineError, Result}; #[cfg(test)] thread_local! { static FAIL_NEXT: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FAIL_AFTER: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path. @@ -18,7 +19,7 @@ pub(crate) fn filled(len: usize, value: T, purpose: &str) -> Result(len: usize, purpose: &str) -> Result> { let bytes = len.checked_mul(std::mem::size_of::()).ok_or_else(|| error(purpose, "requested size overflowed"))?; #[cfg(test)] - if FAIL_NEXT.with(|flag| flag.replace(false)) { + if failpoint_hits() { return Err(error(purpose, "not enough memory")); } let mut out = Vec::new(); @@ -30,7 +31,7 @@ pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { /// The test-only failpoint lets command tests prove their transaction boundary. pub(crate) fn checkpoint(purpose: &str) -> Result<()> { #[cfg(test)] - if FAIL_NEXT.with(|flag| flag.replace(false)) { + if failpoint_hits() { return Err(error(purpose, "not enough memory")); } let _ = purpose; @@ -57,6 +58,32 @@ pub(crate) fn fail_next_for_test() { FAIL_NEXT.with(|flag| flag.set(true)); } +/// Fail the fallible allocation or checkpoint after `skip` clean attempts, then stop. +/// `fail_after_for_test(0)` is equivalent to [`fail_next_for_test`]. +#[cfg(test)] +pub(crate) fn fail_after_for_test(skip: usize) { + FAIL_AFTER.with(|flag| flag.set(Some(skip))); +} + +/// Whether a test failpoint trips here: the single-shot flag, or the counted flag +/// once its `skip` attempts have passed cleanly earlier. +#[cfg(test)] +fn failpoint_hits() -> bool { + let one_shot = FAIL_NEXT.with(|flag| flag.replace(false)); + let after = FAIL_AFTER.with(|flag| match flag.get() { + Some(0) => { + flag.set(None); + true + } + Some(rest) => { + flag.set(Some(rest - 1)); + false + } + None => false, + }); + one_shot || after +} + #[cfg(test)] mod tests { use super::*; @@ -67,4 +94,14 @@ mod tests { let result = filled::(16, 0, "test image"); assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image"))); } + + #[test] + fn fail_after_trips_only_on_the_counted_attempt() { + fail_after_for_test(2); + assert!(filled::(4, 0, "one").is_ok(), "the counted failpoint lets the first attempts pass cleanly"); + assert!(filled::(4, 0, "two").is_ok()); + assert!(filled::(4, 0, "three").is_err_and(|e| e.to_string().contains("not enough memory for three"))); + // The flag is consumed by the trip: later attempts are clean again. + assert!(filled::(4, 0, "four").is_ok()); + } } diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 5977603f94..90977f871d 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -462,6 +462,29 @@ fn define_brush_from_selection_survives_allocation_failure() { assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); } +#[test] +fn define_brush_from_selection_survives_failure_of_the_second_allocation() { + // Review of #5469: fail_next_for_test trips on the first buffer, so the second fallible + // call (the selection-mask read) did not have its own regression. Count the tip buffer as + // a clean attempt and fail the mask read instead; both report the same command purpose, + // and the command must still bow out before touching anything. + let mut s = session(40, 40); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + let pixels_before = surface(&s); + let brush_before = s.tools.brush.clone(); + let presets_before = s.tools.presets.clone(); + let history_before = s.active().unwrap().history.past_len(); + crate::allocation::fail_after_for_test(1); + let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err(), "an allocation failure must be reported, not panic"); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for defining a brush from the selection"))); + assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged"); + assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); + assert!(same_pixels(&pixels_before, &surface(&s), s.active().unwrap().doc.bounds()), "the document pixels are unchanged"); +} + #[test] fn works_on_cmyk_and_16_bit() { for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] { From 17c26cb08948243669b87bbcc130260e65133bb3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 17:58:04 +0530 Subject: [PATCH 0603/1452] Value fields: a gesture that wheels off the field is the view's from then on (#5473) A hovered field that owned a gesture kept it while the pointer wheeled vertically off it: the view scrolled, and coming back over the field in the same gesture stepped it again. Any wheel with the pointer off the field now hands the rest of the gesture to the view, as a panel gesture already keeps scrolling the panel over a field. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/field_wheel.rs | 34 ++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index 82e93a90d7..92b15965dc 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -5,7 +5,8 @@ //! takes the wheel out of the frame's input before anything is laid out, so neither the canvas //! nor a panel scrolls or zooms with it. ⌥/Alt and ⌘/Ctrl + wheel stay the view's. //! - Over a field without focus, the wheel steps it too, but only a gesture that starts over it: -//! a panel being scrolled keeps scrolling when a field passes under the pointer. +//! a panel being scrolled keeps scrolling when a field passes under the pointer. A gesture that +//! leaves the field is the view's from then on. use egui::{Context, Event, Id, MouseWheelUnit, Rect, Ui, Vec2}; @@ -110,10 +111,10 @@ pub fn notches(ui: &Ui, field: Id, focused: bool, rect: Rect) -> f32 { let Some(mut g) = ui.data(|d| d.get_temp::(gesture_id())) else { return 0.0 }; let (events, wheel): (Vec, bool) = ui.input(|i| (i.events.iter().filter(|e| for_field(e)).cloned().collect(), i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })))); - // Wheel the field doesn't take (a sideways swipe after a zero-delta start) hands the rest - // of the gesture to the view, which pans with it: also with the pointer off the field, so - // the gesture doesn't step it on coming back. - if g.owner == Owner::Field(field) && wheel && events.is_empty() { + // Wheel the field doesn't take (a sideways swipe after a zero-delta start), or any wheel + // with the pointer off the field, hands the rest of the gesture to the view: coming back + // over the field in the same gesture doesn't step it. + if g.owner == Owner::Field(field) && wheel && (events.is_empty() || !ui.rect_contains_pointer(rect)) { g.owner = Owner::Elsewhere; ui.data_mut(|d| d.insert_temp(gesture_id(), g)); return 0.0; @@ -311,6 +312,29 @@ mod tests { assert!(h.state().1 > 0.0, "the panel scrolled"); } + /// A gesture that steps a field, then wheels on with the pointer off it, is the view's from + /// then on: coming back over the field in the same gesture doesn't step it again. + #[test] + fn a_gesture_that_wheels_off_the_field_does_not_step_it_on_coming_back() { + // Short frames: moving the pointer takes a frame of its own, and the gesture must not end. + let mut h = harness_stepping(100.0, 1.0 / 30.0); + h.hover_at(field(&h).center()); + h.run_steps(1); + // Up, so the panel (already at the top) doesn't move the field. + let up = + || egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }; + h.event(up()); + h.run_steps(1); + assert_eq!(h.state().0, 101.0); + h.hover_at(field(&h).center() + vec2(0.0, 40.0)); + h.event(up()); + h.run_steps(1); + h.hover_at(field(&h).center()); + h.event(up()); + h.run_steps(4); + assert_eq!(h.state().0, 101.0, "the field was stepped once, by the start of the gesture"); + } + #[test] fn a_disabled_field_ignores_the_wheel() { let mut h = harness(100.0); From 98f4c9e1b860c8c0a3c51cf2aacf6f768023f1c7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 08:54:20 -0400 Subject: [PATCH 0604/1452] test(psd): write the NUL in psd16_merged_transparency.rs as an escape The test file held a literal NUL byte, so git treated it as binary. Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/psd16_merged_transparency.rs | Bin 3511 -> 3512 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs index 3f04970626e7f21e776f84241be0ee3a46d66a99..02d6c0e6b3f05a4843ace4dc829d3d9fa9f311b2 100644 GIT binary patch delta 15 Wcmdlky+eA#98RVfgUxd}rC0zi(FFhi delta 14 VcmdlXy Date: Sun, 18 Oct 2026 20:54:35 +0800 Subject: [PATCH 0605/1452] ui: trim the recent fonts at once when the preference lowers the cap (review) --- crates/ui-egui/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4bffc10e81..7dbee5541e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1312,6 +1312,12 @@ impl PhotocraftApp { r.truncate(cap); } + /// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) applies to the existing list at + /// once, not only after the next pick. + pub fn sync_recent_fonts(&mut self) { + self.ui.recent_fonts.truncate(self.session.prefs().type_.recent_fonts.min(50) as usize); + } + /// File › Open Recent › Clear Recent File List. pub fn clear_recent(&mut self) { self.ui.recent_files.clear(); @@ -2019,6 +2025,9 @@ impl eframe::App for PhotocraftApp { text_field_menu::show(&ctx); // Each painting tool keeps its own brush (#218), so switch the active tool's brush in // before anything this frame reads it (the cursor, the options bar, a stroke). + // Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) trims the existing list at + // once, not only after the next pick. + self.sync_recent_fonts(); paint_mouse::sync_tool_brush(self); // A unit typed into a value field (`1in` in a px field) converts at the active document's // resolution, as in Photoshop. From 2fb3e47c1e347a4752df5bd469658261591bbebd Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:35 +0800 Subject: [PATCH 0606/1452] ui: the closed font picker's wheel keeps a stable order (review) --- crates/ui-egui/src/type_tool.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f9933f0d24..75c3c846ab 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1584,7 +1584,10 @@ pub(crate) fn family_picker_in( ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); } let notches = crate::widgets::wheel_notches_of(ui, response.response.id.with("family-wheel"), hovered, 1.0); - let rows = font_menu_rows(families, labels, "", recents); + // The wheel steps in a stable order: hoisting the family it just picked as recent + // would change the order between notches and can jump back to the previous family. + // The open menu still shows the recent families first. + let rows = font_menu_rows(families, labels, "", &[]); if let Some(i) = rows.iter().position(|(f, _)| *f == current) && let Some(n) = wheel_family_index(i, rows.len(), notches) && let Some((f, _)) = rows.get(n) @@ -2496,6 +2499,22 @@ mod tests { assert!(app.ui.recent_fonts.is_empty(), "0 keeps no list"); } + /// A smaller Recent Fonts cap (or 0) applies to the existing list at once, without waiting + /// for the next pick (CodeRabbit review of #3559). + #[test] + fn lowering_the_recent_fonts_cap_applies_at_once() { + let mut app = app(); + for f in ["A", "B", "C"] { + app.note_recent_font(f); + } + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap(); + app.sync_recent_fonts(); + assert_eq!(app.ui.recent_fonts, ["C", "B"], "the smaller cap trims the tail without a pick"); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap(); + app.sync_recent_fonts(); + assert!(app.ui.recent_fonts.is_empty(), "0 clears the list"); + } + fn layer_text(app: &PhotocraftApp) -> String { let ed = app.ui.text_edit.as_ref().unwrap(); current_text(app, LayerId(ed.layer)).unwrap() From 60636200ad0482dcd01be07c65a1eea8f0e9bab3 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:35 +0800 Subject: [PATCH 0607/1452] ui: test that the closed picker's wheel keeps advancing while fonts become recent --- crates/ui-egui/src/type_font_picker_tests.rs | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 3ccdcd59bb..ff15a548b5 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -587,6 +587,30 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() { assert_eq!(h.state().current, "Family 00", "stops at the first family"); } +#[test] +fn wheel_over_the_closed_button_keeps_a_stable_order_with_recents() { + let mut h = harness_with_recents(FontPreview::Medium, vec![]); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + h.hover_at(button); + h.run(); + let wheel = |h: &mut Harness<'static, State>, y: f32| { + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }); + h.run(); + }; + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 01"); + // The caller (the options bar) records the picked family as the most recent one. + h.state_mut().recents.insert(0, "Family 01".into()); + h.run(); + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 02", "the next notch keeps going, not back to the recent family"); + h.state_mut().recents.insert(0, "Family 02".into()); + h.run(); + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "the list stays closed"); +} + #[test] fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(0, 3, -5.0), Some(2)); From 62ca145979c5ff32e01451e1eebdfd653344f9a0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:18:37 -0400 Subject: [PATCH 0608/1452] Auto-Blend: keep this PR to the seam fix for #5745; split the rest out Per review, the canvas-wide area, Seamless gain compensation and Background conversion move to their own PRs. Adds an engine test for the crop case. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 95 +++++++++++---------------------- 1 file changed, 32 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index 05d367e14b..80f8f0a7b7 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -178,21 +178,13 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); - let mut union = Rect::EMPTY; - let mut content_px = 0u64; - let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height()); + let mut area = Rect::EMPTY; for id in &ids { if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) { - union = union.union(&b); - content_px = content_px.saturating_add(px_of(&b)); + area = area.union(&b); } } - // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers - // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All - // shows the whole panorama and the seams know where each layer really extends the picture. - // Layers so far apart that their bounds are mostly empty keep to the canvas. - let canvas = doc.bounds(); - let area = if px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2) { union } else { union.intersect(&canvas) }; + let area = area.intersect(&doc.bounds()); if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -233,9 +225,7 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); - // Seamless Tones and Colors also evens out exposure and colour between the layers (gain - // compensation over their overlaps), as in Photomerge. - let b = seam_blend(&surfs, area, &order, seamless, seamless, None); + let b = seam_blend(&surfs, area, &order, seamless, false, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) @@ -268,8 +258,6 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let label = "Auto-Blend Layers"; let (new_layer, depth_layer) = s.edit(label, |doc, active| { for (id, wt) in ids.iter().zip(&weights) { - // The Background can't have a mask: it becomes a normal layer first, as in Photoshop. - crate::extra_cmds::background_to_layer_for_mask(doc, *id); let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; let mut mask = LayerMask::hide_all(); mask.surface.write_region(area, wt); @@ -430,54 +418,35 @@ mod tests { } } - /// #5745: two photos in one document, the second moved by Auto-Align so it hangs off the - /// canvas's right edge, 10% brighter. The bottom (the Background) covers the whole canvas, - /// so cropped to the canvas the top layer was all overlap: it got an all-black mask and the - /// picture didn't change. Now the top layer shows on its side of the seam, off the canvas - /// too, and Seamless Tones and Colors evens out the exposure. + /// #5745: the top layer hangs off the canvas's right edge, so cropped to the canvas it lies + /// wholly over the bottom one. It used to get an all-black mask; now it shows on its side. #[test] - fn auto_blend_panorama_of_a_layer_hanging_off_the_canvas() { - for depth in [8, 16, 32] { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 200, "height": 60, "depth": depth, "background": "white"})).unwrap(); - let bg = s.active().unwrap().doc.background().unwrap().id; - // The same scene in both layers, so they agree where they overlap (x 120..200). - let scene = |x: i32, gain: f32| [(0.2 + 0.6 * x as f32 / 320.0) * gain, 0.4 * gain, 0.5 * gain, 1.0]; - s.edit("left", |doc, _| { - let surf = doc.layer_mut(bg).unwrap().surface_mut().unwrap(); - for x in 0..200 { - surf.fill_rect(Rect::new(x, 0, x + 1, 60), &scene(x, 1.0)); - } - Ok(()) - }) - .unwrap(); - s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); - s.edit("right", |doc, active| { - let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); - for x in 120..320 { - surf.fill_rect(Rect::new(x, 0, x + 1, 60), &scene(x, 1.1)); - } - Ok(()) - }) - .unwrap(); - let right = s.active().unwrap().active_layer.unwrap(); - select(&mut s, &[bg, right]); - let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); - let doc = &s.active().unwrap().doc; - assert!(!doc.is_background(bg), "depth {depth}: the Background became a layer to take its mask"); - let ml = doc.layer(bg).unwrap().mask.as_ref().unwrap(); - let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); - assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0), "depth {depth}"); - assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "depth {depth}: off the canvas"); - for x in 0..320 { - assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "depth {depth}: x {x} shows one layer"); - } - // The merged layer covers the off-canvas part too, with the exposures matched: the - // flat blue channel reads the same on both sides (0.5 vs 0.55 before). - let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); - let (a, b) = (blended.pixel(40, 30), blended.pixel(300, 30)); - assert_eq!(b[3], 1.0, "depth {depth}"); - assert!((a[2] - b[2]).abs() < 0.015, "depth {depth}: {a:?} {b:?}"); + fn auto_blend_panorama_of_a_layer_the_canvas_crops_to_all_overlap() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + s.execute("edit.autoBlendLayers", json!({"method": "panorama", "seamlessTones": false})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(195, 30), mr.value(195, 30)), (0.0, 1.0)); + for x in 0..200 { + assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "x {x} shows one layer"); } } From 17c9cda345a5f40a6c816d94bc89842eea258fa6 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:19:39 -0400 Subject: [PATCH 0609/1452] Artboards: the Background and full-canvas layers keep the pasteboard (#5712) Review on #6410: any visible layer covering the whole canvas (the default white Background, or a fill layer whose bounds are unbounded) removed the pasteboard entirely. Those are backdrop, not loose work: pasteboard_rects now skips the document's Background and any layer whose composite bounds are unbounded or contain the canvas. The test helper is back to the default white document, and full_canvas_layers_keep_the_pasteboard covers it. Merging main also drops the rustfmt-only change to smart_filter_authorization.rs from the PR diff. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/artboard_ui.rs | 46 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index ef4e080f6a..da97fc7d4c 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -13,18 +13,19 @@ use crate::theme::Tokens; /// Screen rects covering the document image minus every artboard (vertical slabs between the /// boards' x edges, split around the boards they cross), and minus where visible layers outside /// the boards draw: Photoshop shows those over the pasteboard, so a board added next to earlier -/// work must not paint over it (#5712). +/// work must not paint over it (#5712). The Background and any layer that covers the whole +/// canvas (or may draw anywhere, such as a fill layer) are backdrop, not loose work: they keep +/// the pasteboard. pub fn pasteboard_rects(xf: &ViewXform, doc: &Document) -> Vec { let canvas = doc.bounds(); - let mut loose = Vec::new(); - for l in doc.layers.iter().filter(|l| l.visible && !is_artboard(l)) { - match photocraft_compose::composite_bounds(l, canvas) { - // It may draw anywhere: no pasteboard shows. - None => return Vec::new(), - Some(b) if !b.is_empty() => loose.push(b), - Some(_) => {} - } - } + let background = doc.background().map(|b| b.id); + let loose: Vec = doc + .layers + .iter() + .filter(|l| l.visible && !is_artboard(l) && Some(l.id) != background) + .filter_map(|l| photocraft_compose::composite_bounds(l, canvas)) + .filter(|b| !b.is_empty() && !b.contains_rect(&canvas)) + .collect(); let boards: Vec = doc.artboards().iter().map(|b| b.2.rect.intersect(&canvas)).filter(|r| !r.is_empty()).collect(); let mut xs: Vec = vec![canvas.x0, canvas.x1]; for b in &boards { @@ -208,7 +209,7 @@ mod tests { fn app() -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 100, "height": 50, "background": "transparent"})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); app.sync_views(); (app, egui::Context::default()) } @@ -258,6 +259,29 @@ mod tests { assert!(rects.iter().all(|r| !r.intersects(Rect::from_min_max(Pos2::new(60.5, 10.5), Pos2::new(79.5, 29.5))))); } + #[test] + fn full_canvas_layers_keep_the_pasteboard() { + // The white Background (from `app()`) and a loose layer filling the whole canvas are + // backdrop: the pasteboard still shows outside the board. + let (mut app, _) = app(); + app.run("layer.new.artboard", json!({"rect": [0, 0, 40, 50]})).unwrap(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + assert!(doc.background().is_some()); + let mut px = photocraft_doc::Layer::raster("Wash", doc.pixel_format()); + px.surface_mut().unwrap().fill_rect(doc.bounds(), &[0.0, 0.0, 1.0, 1.0]); + doc.layers.push(px); + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(100.0, 50.0)), + zoom: 1.0, + center: [50.0, 25.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let area: f32 = pasteboard_rects(&xf, &doc).iter().map(|r| r.area()).sum(); + assert!((area - (5000.0 - 2000.0)).abs() < 0.5); + } + #[test] fn subtract_splits_around_holes() { let r = photocraft_geom::Rect::new(0, 0, 10, 10); From 2fc4360d67e78dfa10294b57f336f9e069e6f25b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:24:35 -0400 Subject: [PATCH 0610/1452] Auto-Blend Layers: mask and blend the whole layers, past the canvas too Refs storytold/PhotoCraft#5745, split out of #5816. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 45 ++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..9b6790355e 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -178,13 +178,21 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); - let mut area = Rect::EMPTY; + let mut union = Rect::EMPTY; + let mut content_px = 0u64; + let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height()); for id in &ids { if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) { - area = area.union(&b); + union = union.union(&b); + content_px = content_px.saturating_add(px_of(&b)); } } - let area = area.intersect(&doc.bounds()); + // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers + // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All + // shows the whole panorama. Layers so far apart that their bounds are mostly empty keep to + // the canvas. + let canvas = doc.bounds(); + let area = if px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2) { union } else { union.intersect(&canvas) }; if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -418,6 +426,37 @@ mod tests { } } + /// #5745: a layer Auto-Align left partly off the canvas is masked and blended over all of + /// its bounds, not cut off at the canvas edge. + #[test] + fn auto_blend_panorama_masks_past_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 150, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "off the canvas"); + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); From dfaf48e801daa3048e69218b40828db30312cb48 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:28:16 -0400 Subject: [PATCH 0611/1452] Auto-Blend Layers: Seamless Tones and Colors turns on gain compensation Refs storytold/PhotoCraft#5745, split out of #5816. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..2e89ddc565 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -225,7 +225,9 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); - let b = seam_blend(&surfs, area, &order, seamless, false, None); + // Seamless Tones and Colors also evens out exposure and colour between the layers (gain + // compensation over their overlaps), as in Photomerge. + let b = seam_blend(&surfs, area, &order, seamless, seamless, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) @@ -418,6 +420,30 @@ mod tests { } } + /// Seamless Tones and Colors evens out a 10% exposure step between two panorama layers. + #[test] + fn auto_blend_panorama_seamless_matches_exposure() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + let mut ids = Vec::new(); + for (name, x0, x1, gain) in [("left", 0, 130, 1.0f32), ("right", 70, 200, 1.1)] { + s.execute("layer.new.layer", json!({ "name": name })).unwrap(); + s.edit(name, |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x0, 0, x1, 60), &[0.2 * gain, 0.4 * gain, 0.5 * gain, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + select(&mut s, &ids); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + let (a, b) = (blended.pixel(10, 30), blended.pixel(190, 30)); + // 0.5 vs 0.55 without gain compensation. + assert!((a[2] - b[2]).abs() < 0.015, "{a:?} {b:?}"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); From e8848e8ba2987fe25a11b1f06bf8e989a8564397 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:32:05 -0400 Subject: [PATCH 0612/1452] Auto-Blend Layers: turn the Background into a layer before masking it Refs storytold/PhotoCraft#5745, split out of #5816. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..15546b2110 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -258,6 +258,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let label = "Auto-Blend Layers"; let (new_layer, depth_layer) = s.edit(label, |doc, active| { for (id, wt) in ids.iter().zip(&weights) { + // The Background can't have a mask: it becomes a normal layer first, as in Photoshop. + crate::extra_cmds::background_to_layer_for_mask(doc, *id); let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; let mut mask = LayerMask::hide_all(); mask.surface.write_region(area, wt); @@ -418,6 +420,28 @@ mod tests { } } + /// #5745: the Background can't take a mask, so Auto-Blend turns it into a normal layer first. + #[test] + fn auto_blend_turns_the_background_into_a_layer() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.background().unwrap().id; + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[bg, right]); + s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.is_background(bg), "the Background became a layer to take its mask"); + assert!(doc.layer(bg).unwrap().mask.is_some()); + s.undo(); + assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); From c8f7cfaae6ff883a7c3882edc495d6993b011573 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 09:45:11 -0400 Subject: [PATCH 0613/1452] Artboards: paint pasteboard pieces as quads so a rotated view keeps the holes (#5712) pasteboard_rects returned each piece's screen bounding box, which in a rotated view covers the loose layers (and boards) it was cut around. pasteboard_quads returns each piece's four screen corners and the canvas paints them as meshes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/artboard_ui.rs | 77 +++++++++++++++++++++++++------ crates/ui-egui/src/canvas.rs | 28 +++++++---- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index da97fc7d4c..63939fa9f3 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -10,13 +10,15 @@ use crate::PhotocraftApp; use crate::canvas::ViewXform; use crate::theme::Tokens; -/// Screen rects covering the document image minus every artboard (vertical slabs between the +/// Screen quads covering the document image minus every artboard (vertical slabs between the /// boards' x edges, split around the boards they cross), and minus where visible layers outside /// the boards draw: Photoshop shows those over the pasteboard, so a board added next to earlier /// work must not paint over it (#5712). The Background and any layer that covers the whole /// canvas (or may draw anywhere, such as a fill layer) are backdrop, not loose work: they keep -/// the pasteboard. -pub fn pasteboard_rects(xf: &ViewXform, doc: &Document) -> Vec { +/// the pasteboard. Each piece is returned as its four screen corners rather than a screen +/// bounding box, so in a rotated view the pieces keep their shape and do not cover the boards +/// or the loose layers they were cut around. +pub fn pasteboard_quads(xf: &ViewXform, doc: &Document) -> Vec<[Pos2; 4]> { let canvas = doc.bounds(); let background = doc.background().map(|b| b.id); let loose: Vec = doc @@ -41,17 +43,23 @@ pub fn pasteboard_rects(xf: &ViewXform, doc: &Document) -> Vec { let mut y = canvas.y0; for (a, b) in spans { if a > y { - out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, a), &loose).into_iter().map(|r| xf.doc_rect(r))); + out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, a), &loose).into_iter().map(|r| quad(xf, r))); } y = y.max(b); } if y < canvas.y1 { - out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, canvas.y1), &loose).into_iter().map(|r| xf.doc_rect(r))); + out.extend(subtract(photocraft_geom::Rect::new(x0, y, x1, canvas.y1), &loose).into_iter().map(|r| quad(xf, r))); } } out } +/// The corners of a document rect on screen, in order (a parallelogram when the view is rotated). +fn quad(xf: &ViewXform, r: photocraft_geom::Rect) -> [Pos2; 4] { + let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + [xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)] +} + fn is_artboard(l: &Layer) -> bool { matches!(&l.content, photocraft_doc::LayerContent::Group(g) if g.artboard.is_some()) } @@ -228,11 +236,11 @@ mod tests { rotation: 0.0, aspect: 1.0, }; - let rects = pasteboard_rects(&xf, &doc); - let area: f32 = rects.iter().map(|r| r.area()).sum(); + let quads = pasteboard_quads(&xf, &doc); + let area: f32 = quads.iter().map(quad_area).sum(); // 100×50 canvas − 40×50 − 40×30 boards. - assert!((area - (5000.0 - 2000.0 - 1200.0)).abs() < 0.5, "{rects:?}"); - assert!(rects.iter().all(|r| !r.intersects(Rect::from_min_max(Pos2::new(0.5, 0.5), Pos2::new(39.5, 49.5))))); + assert!((area - (5000.0 - 2000.0 - 1200.0)).abs() < 0.5, "{quads:?}"); + assert!(quads.iter().all(|q| !Rect::from_points(q).intersects(Rect::from_min_max(Pos2::new(0.5, 0.5), Pos2::new(39.5, 49.5))))); } #[test] @@ -252,11 +260,11 @@ mod tests { rotation: 0.0, aspect: 1.0, }; - let rects = pasteboard_rects(&xf, &doc); - let area: f32 = rects.iter().map(|r| r.area()).sum(); + let quads = pasteboard_quads(&xf, &doc); + let area: f32 = quads.iter().map(quad_area).sum(); // 100×50 canvas − the 40×50 board − the 20×20 loose pixels. - assert!((area - (5000.0 - 2000.0 - 400.0)).abs() < 0.5, "{rects:?}"); - assert!(rects.iter().all(|r| !r.intersects(Rect::from_min_max(Pos2::new(60.5, 10.5), Pos2::new(79.5, 29.5))))); + assert!((area - (5000.0 - 2000.0 - 400.0)).abs() < 0.5, "{quads:?}"); + assert!(quads.iter().all(|q| !Rect::from_points(q).intersects(Rect::from_min_max(Pos2::new(60.5, 10.5), Pos2::new(79.5, 29.5))))); } #[test] @@ -278,10 +286,51 @@ mod tests { rotation: 0.0, aspect: 1.0, }; - let area: f32 = pasteboard_rects(&xf, &doc).iter().map(|r| r.area()).sum(); + let area: f32 = pasteboard_quads(&xf, &doc).iter().map(quad_area).sum(); assert!((area - (5000.0 - 2000.0)).abs() < 0.5); } + fn quad_area(q: &[Pos2; 4]) -> f32 { + (0..4).map(|i| q[i].x * q[(i + 1) % 4].y - q[(i + 1) % 4].x * q[i].y).sum::().abs() / 2.0 + } + + /// Whether `p` lies strictly inside the convex quad `q` (either winding). + fn inside(q: &[Pos2; 4], p: Pos2) -> bool { + let side = |i: usize| (q[(i + 1) % 4] - q[i]).x * (p - q[i]).y - (q[(i + 1) % 4] - q[i]).y * (p - q[i]).x; + let s: Vec = (0..4).map(side).collect(); + s.iter().all(|&v| v > 0.0) || s.iter().all(|&v| v < 0.0) + } + + #[test] + fn rotated_view_keeps_the_holes_around_loose_layers() { + // A loose layer in the middle of the pasteboard, seen at 45°: the strips around it must + // not cover it (their screen bounding boxes would). + let (mut app, _) = app(); + app.run("layer.new.artboard", json!({"rect": [0, 0, 20, 50]})).unwrap(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + let mut px = photocraft_doc::Layer::raster("Work", doc.pixel_format()); + px.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(50, 15, 70, 35), &[1.0, 0.0, 0.0, 1.0]); + doc.layers.insert(0, px); + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(200.0, 200.0)), + zoom: 1.0, + center: [50.0, 25.0], + flip: true, + rotation: 45.0, + aspect: 1.0, + }; + let quads = pasteboard_quads(&xf, &doc); + let area: f32 = quads.iter().map(quad_area).sum(); + // Rotation keeps areas: 100×50 canvas − the 20×50 board − the 20×20 loose pixels. + assert!((area - (5000.0 - 1000.0 - 400.0)).abs() < 0.5, "{quads:?}"); + for (x, y) in [(60.0, 25.0), (51.0, 16.0), (69.0, 34.0), (10.0, 25.0)] { + let p = xf.to_screen(x, y); + assert!(quads.iter().all(|q| !inside(q, p)), "({x}, {y}) is covered"); + } + // Pasteboard still shows next to the hole. + assert!(quads.iter().any(|q| inside(q, xf.to_screen(80.0, 25.0)))); + } + #[test] fn subtract_splits_around_holes() { let r = photocraft_geom::Rect::new(0, 0, 10, 10); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91644b5f73..538fb08ac3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3174,21 +3174,29 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let t = crate::theme::Tokens::get(&ctx); let pasteboard = crate::prefs_ui::pasteboard_color(app); let dot_tex = dots(&ctx, &t); - for r in crate::artboard_ui::pasteboard_rects(&xf, &doc) { - let r = r.intersect(rect); - if !r.is_positive() { + // Each piece is painted as its own quad (the painter clips to the canvas), so a + // rotated view does not paint a piece's bounding box over the boards or loose work. + let fill = |q: &[Pos2; 4], tex: Option, color: Color32| { + let mut mesh = tex.map_or_else(egui::Mesh::default, egui::Mesh::with_texture); + for &p in q { + // Same phase as the dots around the document. + let uv = if tex.is_some() { ((p - rect.min) / 22.0).to_pos2() } else { egui::epaint::WHITE_UV }; + mesh.vertices.push(egui::epaint::Vertex { pos: p, uv, color }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(mesh); + }; + for q in crate::artboard_ui::pasteboard_quads(&xf, &doc) { + if !Rect::from_points(&q).intersects(rect) { continue; } match pasteboard { - Some(c) => { - painter.rect_filled(r, 0.0, c); - } + Some(c) => fill(&q, None, c), None => { - painter.rect_filled(r, 0.0, t.canvas); + fill(&q, None, t.canvas); if let Some(id) = dot_tex { - // Same phase as the dots around the document. - let uv = Rect::from_min_max(((r.min - rect.min) / 22.0).to_pos2(), ((r.max - rect.min) / 22.0).to_pos2()); - painter.image(id, r, uv, Color32::WHITE); + fill(&q, Some(id), Color32::WHITE); } } } From 266273d50c03eae84d07879ef02258bfc801c8f5 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 10:00:14 -0400 Subject: [PATCH 0614/1452] Auto-Blend: a contained image on the left or top keeps its side of the seam On equal cost the seam search took the lowest column/row, so an image wholly inside the composite on its left (or top) got a seam on its own outer edge and no pixels. Break ties away from such an image's side; test the left and top cases too. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/panorama.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index 2d00143c98..8e0808f86a 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -901,7 +901,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o continue; } let (cx, cy) = (nx / nn as f64, ny / nn as f64); - let (ox, oy) = if nn == no { + let contained = nn == no; + let (ox, oy) = if contained { // Wholly inside the composite (#5745: two photos in one document, the second moved // by Auto-Align so the canvas crops it to the part over the first). Its side of the // seam is the one it lies towards from the composite's centre; an image centred on @@ -925,6 +926,10 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o let vertical = (cx - ox).abs() >= (cy - oy).abs(); // Image on the increasing side along the seam's cross axis? let positive = if vertical { cx > ox } else { cy > oy }; + // A contained image has no pixels of its own, so a seam on its outer edge would leave it + // nothing: break cost ties away from its side (towards larger k when it lies on the + // decreasing side) so it keeps its side of the overlap. + let prefer_high = contained && !positive; let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() }; let (lines, len) = if vertical { (h, w) } else { (w, h) }; let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line }; @@ -945,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o let mut best = (0.0f32, usize::MAX); if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) { best = (f32::INFINITY, usize::MAX); - for dk in [-1i64, 0, 1] { + let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] }; + for dk in dks { let kk = k as i64 + dk; if kk < 0 || kk >= len as i64 { continue; @@ -972,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if seam[line] != usize::MAX { continue; } - let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue }; + let Some(k) = (0..len) + .filter(|&k| overlap[idx(line, k)]) + .min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) })) + else { + continue; + }; let (mut l, mut k) = (line, k); loop { seam[l] = k; @@ -1578,6 +1589,25 @@ mod tests { let first = row.iter().position(|l| *l == 1).unwrap(); assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}"); } + // The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap + // instead of on the image's outer edge, which used to leave it nothing. + let left: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()]; + let labels = seam_labels(w, h, &luma, &left, &[0, 1]); + for y in 0..h { + let row = &labels[y * w..(y + 1) * w]; + assert_eq!(row[0], 1, "row {y}: {row:?}"); + assert_eq!(row[w - 1], 0, "row {y}"); + let last = row.iter().rposition(|l| *l == 1).unwrap(); + assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + let (tw, th) = (40usize, 120usize); + let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]]; + let top: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()]; + let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]); + for x in 0..tw { + assert_eq!(labels[x], 1, "column {x}"); + assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}"); + } // An image centred on the composite has no side: the first image keeps the overlap. let centred: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()]; assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0)); From 0c36fbc49d094a7ea017becc30f1eb564a8d8376 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:04:13 +0000 Subject: [PATCH 0615/1452] Integration: #6035 move the new tests above existing tests' doc comments --- crates/engine/src/image_cmds.rs | 11 ++++++----- crates/ui-egui/src/sizing.rs | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 5f37a2eaa8..69fb1b17c1 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -944,11 +944,7 @@ mod tests { } } - /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a - /// Background or a 200 % export got a translucent frame), and a full selection stays full. - /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands - /// without a budget. It is refused before any allocation and the document is untouched; a - /// change of resolution only, and an ordinary resize, still work. + /// #6019: Image Size on an Indexed Color document makes no colours the colour table doesn't have. #[test] fn image_size_resamples_indexed_color_with_nearest_neighbor() { let mut s = Session::new(); @@ -970,6 +966,11 @@ mod tests { } } + /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a + /// Background or a 200 % export got a translucent frame), and a full selection stays full. + /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands + /// without a budget. It is refused before any allocation and the document is untouched; a + /// change of resolution only, and an ordinary resize, still work. #[test] fn image_size_refuses_a_raster_past_the_budget() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index c3ec7a199b..d0e0cfdec4 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -525,8 +525,7 @@ mod tests { assert!(app.ui.dialogs.iter().any(|x| x.id == id), "reset leaves the dialog open"); } - /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens - /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). + /// #6019: on an Indexed Color document the dialog opens on, and offers only, Automatic and Nearest Neighbor. #[test] fn indexed_color_resamples_with_nearest_neighbor_only() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -555,6 +554,8 @@ mod tests { } } + /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens + /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). #[test] fn resample_follows_the_interpolation_preference() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From fac74f83e8dd35b084e47d39c55d2dfb8db388d9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:05:02 +0000 Subject: [PATCH 0616/1452] Integration: #6414 an unconfirmed save releases the unsaved-changes prompt --- crates/ui-egui/src/discard_ui.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 3dbf33c905..fe75cadc3e 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -214,7 +214,8 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { } match saved { // A Save Document script may have edited the document after it was written: still dirty. - Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()), + // A write the platform can't confirm (a web download) leaves it dirty too, and counts. + Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -227,12 +228,12 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { /// A completed save may release the close prompt. Choosing a path only starts a layered TIFF /// save; TIFF Options calls this after the write, and `jobs_ui` when a background save ends. -/// Copies and failed writes leave the doc dirty. +/// Copies and failed writes leave the doc dirty; so does any write when it is unconfirmed. pub(crate) fn saved_document(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) { // Still the prompt that asked (quitting may have replaced it meanwhile). if !app.save_options_open() && app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) - && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()) + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) { advance(app, ctx); } @@ -924,6 +925,30 @@ mod tests { assert!(h.state().session.documents().is_empty(), "closed once saved"); } + /// #6414: on the web a save stays dirty (the download isn't confirmed), and answering Save + /// still closes the tab after one write. + #[test] + fn saving_from_the_prompt_closes_after_an_unconfirmed_write() { + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let w = written.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.write_unconfirmed = true; + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + h.key_press(Key::Y); + h.run_steps(3); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty(), "closed after one write"); + } + #[path = "tiff_tests.rs"] mod tiff_tests; From 0b167ca2599957b794baadec9b947146a6a114e4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 12:16:37 +0000 Subject: [PATCH 0617/1452] Integration: #6476's uppercase token test writes its file owner-only (#5459 on main) --- crates/automation/src/security.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 14aadbca53..89a760b32a 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -404,7 +404,7 @@ mod tests { fn an_uppercase_token_file_authenticates_its_exact_contents() { let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); let contents = generate_token().unwrap().to_ascii_uppercase(); - std::fs::write(&path, format!("{contents}\n")).unwrap(); + create_token_file(&path, &contents).unwrap(); let expected = server_token(None, Some(&path)); std::fs::remove_file(&path).unwrap(); let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); From 5a01650652c2d24ee49810272a03d9c2a05db786 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 18 Oct 2026 13:21:01 +0000 Subject: [PATCH 0618/1452] Integration: cargo fmt --- crates/io/src/svg.rs | 13 ++----------- crates/ui-egui/src/discard_ui.rs | 6 +++++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 3d66f45e94..8afefb733d 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -230,17 +230,8 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH)); - let mut conv = Conv { - fmt, - canvas: doc.bounds(), - global, - max_group_depth, - warnings, - noted: Vec::new(), - counts: [0; 4], - pixel_budget, - retained_pixel_bytes: 0, - }; + let mut conv = + Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 }; if count_nodes(tree.root()) > MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index fe75cadc3e..82e4497d92 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -215,7 +215,11 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { match saved { // A Save Document script may have edited the document after it was written: still dirty. // A write the platform can't confirm (a web download) leaves it dirty too, and counts. - Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()), + Ok(_) => { + !app.save_options_open() + && !app.saving() + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) + } // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { From e4e0a789cc998e2b46660349b92bd941ab679e04 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:24:46 +0200 Subject: [PATCH 0619/1452] Camera Raw: choose the demosaic method when a raw opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A camera raw always opened with AHD demosaicing; `photocraft-raw` has three methods but neither the open-time Camera Raw dialog nor Preferences offered them. - Preferences › Camera Raw › Demosaic (`rawDefaults.demosaic`: ahd, mhc, bilinear; default ahd): raws open with it. - The open-time dialog's Detail panel gets a Demosaic row that starts from the preference; Open re-develops when the choice differs from the method the file was opened with (`RawOpen::developed_with` / `demosaic`, `RawTuning::demosaic`), as it does for the raw-stage sliders. The preview keeps the method the file was opened with. Control channel: `ui.demosaic`, `openingRaw.demosaic`. - Imports take `photocraft_io::ImportOptions` (SVG group depth and demosaic) instead of the depth alone: `import_with_options`, `import_with_options_and_interrupt`, `jobs::import_options(prefs)`, `ImportFn` takes `&ImportOptions`; desktop, web, background opens and Place pass the user's settings. - The eight new strings in all 20 catalogs; the scorecard counts the new preference. Test: `camera_raw_ui::tests::the_demosaic_method_is_chosen_at_open` (another method re-develops with different pixels; the preference develops the open itself and Open then changes nothing; unknown methods and the choice outside a raw open are refused); `prefs_ui` shows the setting. Split out of #3816 at the reviewer's request. Co-Authored-By: Claude Fable 5.1 --- apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 4 +- crates/engine/src/file_cmds.rs | 4 +- crates/engine/src/jobs.rs | 18 +++- crates/engine/src/prefs.rs | 21 ++++ crates/io/src/lib.rs | 33 +++++- crates/io/src/raw.rs | 15 ++- crates/ui-egui/examples/snapshot.rs | 4 +- crates/ui-egui/src/actions.rs | 4 +- crates/ui-egui/src/camera_raw_ui.rs | 119 +++++++++++++++++++-- crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/file_open/tests.rs | 4 +- crates/ui-egui/src/i18n/cs.tsv | 7 ++ crates/ui-egui/src/i18n/de.tsv | 7 ++ crates/ui-egui/src/i18n/el.tsv | 7 ++ crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/fr.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/id.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/ko.tsv | 7 ++ crates/ui-egui/src/i18n/nl.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sr.tsv | 7 ++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/vi.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 5 +- crates/ui-egui/src/profile_open_tests.rs | 10 +- crates/ui-egui/src/profile_prompt_tests.rs | 4 +- crates/ui-egui/src/save_identity_tests.rs | 4 +- docs/scorecard.md | 4 +- 39 files changed, 360 insertions(+), 57 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6fd3620483..6eea63b499 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -384,8 +384,8 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..b15dede39b 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -494,9 +494,9 @@ pub fn native(automation: Option) -> }) as photocraft_ui_egui::AutomationCommandFn }); Services { - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { crate::crash_guard::guard("Open", || { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) }) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f99aefcff0..1be23c13f8 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -557,8 +557,8 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option photocraft_io::ImportOptions { + photocraft_io::ImportOptions { + max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, + demosaic: prefs.raw_defaults.demosaic.method(), + } +} + impl Session { /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation @@ -485,7 +493,7 @@ impl Session { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); - let max_svg_group_depth = self.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; + let opts = import_options(self.prefs()); self.start_job( OPEN_JOB, json!({"name": name}), @@ -499,12 +507,12 @@ impl Session { }; ctx.check()?; ctx.progress(0.02, "Decoding"); - let imported = ctx - .stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) - .map_err(|e| match e { + let imported = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&name_w, &bytes, &opts, ctl)).map_err( + |e| match e { photocraft_io::IoError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), - })?; + }, + )?; ctx.progress(0.95, "Preparing layers"); warm_layer_bounds(&imported.document, || ctx.check())?; Ok(imported) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2c2507226d..55ff4c3b97 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -108,6 +108,12 @@ choice!(FontPreview { Off = "off", Small = "small", Medium = "medium", Large = " choice!(RawColorSpace { Srgb = "srgb", AdobeRgb = "adobeRgb", ProPhoto = "proPhoto", DisplayP3 = "displayP3" } default AdobeRgb); choice!(RawDepth { Eight = "8", Sixteen = "16" } default Sixteen); choice!(RawSharpen { None = "none", Screen = "screen", Glossy = "glossy", Matte = "matte" } default None); +choice!( + /// How a raw file's colour filter array is filled in when it is developed: AHD + /// (Hirakawa–Parks) keeps edges cleanest, Malvar–He–Cutler is sharp and fast, bilinear is the + /// fastest and softest. Photoshop's Camera Raw has no such choice. + RawDemosaic { Ahd = "ahd", Mhc = "mhc", Bilinear = "bilinear" } default Ahd +); impl Unit { /// Document pixels → this unit. `dpi` is the document resolution, `extent` the 100% length @@ -858,6 +864,19 @@ pub struct RawDefaults { /// Photoshop does; off develops it with the defaults straight away. Automation opens never /// show the dialog. pub open_in_camera_raw: bool, + /// The demosaic method raws are developed with; the open-time Camera Raw dialog starts from it. + pub demosaic: RawDemosaic, +} + +impl RawDemosaic { + /// The developer's method. + pub fn method(self) -> photocraft_io::raw::Demosaic { + match self { + RawDemosaic::Ahd => photocraft_io::raw::Demosaic::Ahd, + RawDemosaic::Mhc => photocraft_io::raw::Demosaic::Mhc, + RawDemosaic::Bilinear => photocraft_io::raw::Demosaic::Bilinear, + } + } } impl Default for RawDefaults { @@ -870,6 +889,7 @@ impl Default for RawDefaults { open_as_smart_object: false, apply_auto_tone: false, open_in_camera_raw: true, + demosaic: RawDemosaic::Ahd, } } } @@ -1095,6 +1115,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "rawDefaults.colorSpace" => RawColorSpace::NAMES, "rawDefaults.bitDepth" => RawDepth::NAMES, "rawDefaults.sharpenFor" => RawSharpen::NAMES, + "rawDefaults.demosaic" => RawDemosaic::NAMES, "performance.gpuBackend" => GpuBackend::NAMES, "performance.linuxDisplayServer" => LinuxDisplayServer::NAMES, "performance.renderingMode" => RenderingMode::NAMES, diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3543cd359c..f31af45414 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -266,14 +266,39 @@ pub fn import_with_svg_group_depth_and_interrupt( max_group_depth: usize, ctl: &photocraft_raster::Interrupt, ) -> Result { + import_with_options_and_interrupt(name, bytes, &ImportOptions { max_group_depth, ..Default::default() }, ctl) +} + +/// The user's settings that shape an import. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImportOptions { + /// SVG groups nested deeper than this are rasterised. + pub max_group_depth: usize, + /// The demosaic method camera raws are developed with. + pub demosaic: raw::Demosaic, +} + +impl Default for ImportOptions { + fn default() -> Self { + ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default() } + } +} + +/// [`import`] with the user's import settings. +pub fn import_with_options(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result { + import_with_options_and_interrupt(name, bytes, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`import_with`] with the user's import settings. +pub fn import_with_options_and_interrupt(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; - let r = import_stages(name, bytes, ctl, max_group_depth)?; + let r = import_stages(name, bytes, ctl, opts)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) } -fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { +fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); @@ -310,10 +335,10 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m return pdn::import(name, bytes, ctl); } if raw::is_raw(bytes) { - return raw::import_raw(name, bytes); + return raw::import_raw_demosaic(name, bytes, opts.demosaic); } if is_svg_input(name, bytes) { - return svg::import_svg_with_group_depth(name, bytes, max_group_depth); + return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); } flat::import_flat(name, bytes) } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 008173839b..f03c503c90 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; +pub use photocraft_raw::Demosaic; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; pub use photocraft_raw::{Tone, retone}; @@ -44,7 +45,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels. +/// developed RGB pixels, and the demosaic method. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -53,6 +54,7 @@ pub struct RawTuning { pub tint: f32, /// EV, as `CameraRaw::exposure`. pub exposure: f32, + pub demosaic: Demosaic, /// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw /// does for raw files ([`photocraft_raw::Tone`]). pub shadows: f32, @@ -90,7 +92,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< // The colour rendering the default open used (a look fitted to the camera's JPEG for files // without colour calibration), so re-developing changes only what the sliders change. let camera_look = default_look(bytes, &sensor); - let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() }; + let mut opts = DevelopOptions { limits: limits(), exposure, demosaic: tuning.demosaic, tone, camera_look, ..Default::default() }; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); @@ -110,7 +112,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, format, dev, &summary)?, wb_applied)) } @@ -180,7 +182,12 @@ fn developed_document(name: &str, format: &str, dev: photocraft_raw::Developed, /// Develops a raw file with the default settings. pub fn import_raw(name: &str, bytes: &[u8]) -> Result { - import_raw_with(name, bytes, &DevelopOptions { limits: limits(), ..Default::default() }) + import_raw_demosaic(name, bytes, Demosaic::default()) +} + +/// Develops a raw file with the default settings and the `demosaic` method. +pub fn import_raw_demosaic(name: &str, bytes: &[u8], demosaic: Demosaic) -> Result { + import_raw_with(name, bytes, &DevelopOptions { limits: limits(), demosaic, ..Default::default() }) } /// Smallest preview long edge preferred for the colour fit (a smaller preview decodes faster; diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 0d227255db..787e97c4d1 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -41,8 +41,8 @@ fn main() { arg(&args, "--script").map(|s| serde_json::from_str::>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default(); let services = Services { - import: Some(Box::new(|name: &str, bytes: &[u8], depth: usize| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &photocraft_doc::Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { let mut opts = photocraft_io::ExportOptions::default(); diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index a5c2f15e9b..74e3960c28 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -1390,8 +1390,8 @@ mod tests { /// An app with the real importer/exporter and writer whose Save dialogs answer `answers`. fn disk_app(answers: Vec>) -> PhotocraftApp { let services = crate::Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc, path, _| { photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index d2f78a35c2..869f4ba2ca 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -88,7 +88,7 @@ impl CameraRawDialog { Target::SmartFilter(index) => Some(index), Target::NewFilter | Target::OpenRaw => None, }); - result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path}))); + result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path, "demosaic": r.demosaic.id()}))); result["view"] = json!(navigation); result["sourceSize"] = json!([self.full_w, self.full_h]); result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h)); @@ -194,10 +194,10 @@ enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open -/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or -/// Dehaze changed (those act on the sensor data, see [`photocraft_io::raw::import_raw_tuned`]) -/// and applies the other settings as one Camera Raw step, then marks the document unmodified. -/// Cancel closes it. +/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, +/// Dehaze or the demosaic method changed (those act on the sensor data, see +/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw +/// step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -205,6 +205,10 @@ pub struct RawOpen { pub path: Option, /// The file, when it was opened from bytes (no path to read it again from). pub bytes: Option>>, + /// The demosaic method the open developed it with. + pub developed_with: photocraft_io::raw::Demosaic, + /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. + pub demosaic: photocraft_io::raw::Demosaic, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -222,11 +226,15 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { /// Queues the open-time dialog for the active document (shown on the next frame). pub fn queue_open_dialog(app: &mut PhotocraftApp, name: &str, path: Option<&str>, bytes: Option<&[u8]>) { if let Some(st) = app.session.active() { + // The open developed it with the preference's method (`jobs::import_options`). + let method = app.session.prefs().raw_defaults.demosaic.method(); app.pending_raw_open = Some(RawOpen { document: st.doc.id, name: name.to_string(), path: path.map(str::to_string), bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), + developed_with: method, + demosaic: method, }); } } @@ -301,12 +309,15 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - temperature: params.temperature, tint: params.tint, exposure: params.exposure, + demosaic: raw.demosaic, shadows: params.shadows, highlights: params.highlights, clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, }; + // The open itself: the default settings, developed with the method it was opened with. + let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; // Without the file the raw-stage settings stay RGB adjustments, like the filter's. let source = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_engine::jobs::OpenSource::Bytes(b.clone())), @@ -314,7 +325,7 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - (None, Some(p)) => Some(photocraft_engine::jobs::OpenSource::Path(p.clone())), _ => None, }; - let Some(source) = source.filter(|_| tuning != photocraft_io::raw::RawTuning::default()) else { + let Some(source) = source.filter(|_| tuning != as_opened) else { return finish_open_raw(app, index, params.clone(), None, Some(raw)); }; app.session.set_active(index); @@ -683,7 +694,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val }; for (key, value) in fields { match key.as_str() { - "set" | "scope" | "view" => {} + "set" | "scope" | "view" | "demosaic" => {} "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" if value.is_boolean() => {} "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" => { return Some(Err(format!("Camera Raw {key} must be a boolean"))); @@ -757,7 +768,18 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti }, None => None, }; + let demosaic = match ui.get("demosaic") { + None => None, + Some(_) if d.raw_open.is_none() => return Some(Err("demosaic applies only while a raw file opens".into())), + Some(v) => match v.as_str().and_then(photocraft_io::raw::Demosaic::from_id) { + Some(m) => Some(m), + None => return Some(Err("demosaic must be ahd, mhc or bilinear".into())), + }, + }; let d = app.camera_raw.as_mut()?; + if let (Some(m), Some(raw)) = (demosaic, d.raw_open.as_mut()) { + raw.demosaic = m; + } if let Some(view) = navigation { app.ui.camera_raw_preview = view; } @@ -920,6 +942,25 @@ fn commit(app: &mut PhotocraftApp) -> Result { Ok(result) } +/// The open-time demosaic choice (at the top of Detail). Photoshop's Camera Raw picks one method +/// for every file; here it is the user's. Open re-develops with it; the preview keeps the method +/// the file was opened with (the differences are finer than the preview's pixels). +fn demosaic_row(ui: &mut egui::Ui, m: &mut photocraft_io::raw::Demosaic) { + use photocraft_io::raw::Demosaic; + ui.horizontal(|ui| { + ui.label(crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", "Demosaic")).on_hover_text(tl!( + "How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with." + )); + let options = [ + (Demosaic::Ahd, "AHD (adaptive homogeneity)", "Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest."), + (Demosaic::Mhc, "Malvar–He–Cutler", "Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail."), + (Demosaic::Bilinear, "Bilinear", "The fastest and the softest, with colour fringing on fine detail."), + ]; + widgets::dropdown_with_tooltips(ui, "camera-raw-demosaic", m, &options, 180.0); + }); + ui.add_space(4.0); +} + fn temp_stops() -> Vec { vec![Color32::from_rgb(70, 120, 230), Color32::from_rgb(200, 200, 200), Color32::from_rgb(235, 200, 60)] } @@ -1229,6 +1270,9 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { row(ui, &mut dirty, "Balance", &mut p.grade_balance, -100.0..=100.0, None); }); section(ui, "Detail", false, |ui| { + if let Some(raw) = d.raw_open.as_mut() { + demosaic_row(ui, &mut raw.demosaic); + } widgets::section_label(ui, tl!("Sharpening")); row(ui, &mut dirty, "Amount", &mut p.sharpen_amount, 0.0..=150.0, None); row(ui, &mut dirty, "Radius", &mut p.sharpen_radius, 0.5..=3.0, None); @@ -1490,8 +1534,8 @@ mod tests { fn raw_app() -> (PhotocraftApp, Vec) { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; let services = crate::Services { - import: Some(Box::new(|name: &str, b: &[u8], depth: usize| { - photocraft_io::import_with_svg_group_depth(name, b, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, b: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, b, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }; @@ -1534,6 +1578,47 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + fn pixels(app: &PhotocraftApp) -> Vec { + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..32).flat_map(|y| (0..48).map(move |x| (x, y))).flat_map(|(x, y)| s.rgba(x, y)).collect() + } + + #[test] + fn the_demosaic_method_is_chosen_at_open() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0002.dng", &dng).unwrap(); + let ahd = pixels(&app); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "bilinear"}})).unwrap().unwrap(); + assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "amaze"}})).unwrap().is_err()); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "another method re-develops: {r}"); + assert!(r.get("filter").is_none(), "nothing else to apply: {r}"); + let bilinear = pixels(&app); + assert!(ahd.iter().zip(&bilinear).any(|(a, b)| (a - b).abs() > 1e-3), "bilinear differs from AHD"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision); + + // The preference sets the method of the open itself; Open with it changes nothing. + app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "bilinear"})).unwrap(); + app.open_bytes("IMG_0003.dng", &dng).unwrap(); + assert_eq!(pixels(&app), bilinear, "developed with the preference's method"); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = app.camera_raw.as_ref().unwrap().describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview); + assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], false, "{r}"); + + // Only the open-time dialog has the choice. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "ahd"}})).unwrap().is_err()); + } + #[test] fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { let (mut app, dng) = raw_app(); @@ -1634,7 +1719,21 @@ mod tests { app.open_bytes("c.dng", &dng).unwrap(); assert!(app.pending_raw_open.is_none()); // A non-raw never queues the dialog; a closed document is an error, not a panic. - assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); + assert!( + open_raw( + &mut app, + &ctx, + RawOpen { + document: photocraft_doc::DocId(u64::MAX), + name: "x".into(), + path: None, + bytes: None, + developed_with: Default::default(), + demosaic: Default::default() + } + ) + .is_err() + ); } #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..3c325d24c0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1431,8 +1431,8 @@ mod tests { let dng = spec.build(); for finish in ["commit", "cancel"] { let services = crate::Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }; @@ -2456,7 +2456,7 @@ mod tests { let written: Rc>> = Rc::default(); let w = written.clone(); let services = crate::Services { - import: Some(Box::new(|name: &str, _b: &[u8], _depth: usize| { + import: Some(Box::new(|name: &str, _b: &[u8], _opts: &photocraft_io::ImportOptions| { Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), vec!["Adjustment layer flattened".to_string()])) })), export: Some(Box::new(|_d: &Document, _p: &str, _s: &crate::ExportSettings| Ok((b"out".to_vec(), vec!["Layers were flattened".to_string()])))), @@ -2520,7 +2520,7 @@ mod tests { Err(format!("corrupt bundle: {path}")) } })), - import: Some(Box::new(|name: &str, _b: &[u8], _depth: usize| { + import: Some(Box::new(|name: &str, _b: &[u8], _opts: &photocraft_io::ImportOptions| { Ok((Document::new(name, photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), Vec::new())) })), automation_read: Some(Box::new(|path: &str| { diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 00db3ef96e..c139bed9cf 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -18,7 +18,7 @@ fn app_with(answers: Vec>) -> (PhotocraftApp, Written) let written: Written = Rc::default(); let w = written.clone(); let services = Services { - import: Some(Box::new(|name: &str, bytes: &[u8], _depth: usize| { + import: Some(Box::new(|name: &str, bytes: &[u8], _opts: &photocraft_io::ImportOptions| { if bytes == b"bad" { return Err("not an image".into()); } @@ -935,7 +935,7 @@ fn opening_type_in_a_missing_font_warns() { let (with_missing, installed) = (doc_with_type(Some(MISSING)), doc_with_type(None)); let already = warning.clone(); let services = Services { - import: Some(Box::new(move |name: &str, _bytes: &[u8], _depth: usize| { + import: Some(Box::new(move |name: &str, _bytes: &[u8], _opts: &photocraft_io::ImportOptions| { Ok(match name { "installed.psd" => (installed.clone(), Vec::new()), "warned.psd" => (with_missing.clone(), vec![already.clone()]), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..8499cb6c49 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3838,3 +3838,10 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Demosaic Demozaikování + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Jak se doplňuje mozaika barevného filtru fotoaparátu. Použije se při otevření souboru; náhled ukazuje metodu, se kterou byl otevřen. + AHD (adaptive homogeneity) AHD (adaptivní homogenita) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: nejčistší hrany a nejméně barevných lemů, nejpomalejší. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolace s korekcí gradientu: ostrá a rychlá, s mírnými barevnými lemy v jemných detailech. + The fastest and the softest, with colour fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..db9c58b519 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3230,3 +3230,10 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Demosaic Demosaicing + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Wie das Farbfiltermosaik der Kamera aufgefüllt wird. Es wird beim Öffnen der Datei angewendet; die Vorschau zeigt die Methode, mit der sie geöffnet wurde. + AHD (adaptive homogeneity) AHD (adaptive Homogenität) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: die saubersten Kanten und die wenigsten Farbsäume, am langsamsten. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientenkorrigierte Interpolation: scharf und schnell, mit etwas Farbsäumen an feinen Details. + The fastest and the softest, with colour fringing on fine detail. Am schnellsten und weichsten, mit Farbsäumen an feinen Details. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..f6ec29ec55 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3930,3 +3930,10 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Demosaic Αποψηφιδοποίηση + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Πώς συμπληρώνεται το ψηφιδωτό του χρωματικού φίλτρου της κάμερας. Εφαρμόζεται όταν ανοίγει το αρχείο· η προεπισκόπηση δείχνει τη μέθοδο με την οποία άνοιξε. + AHD (adaptive homogeneity) AHD (προσαρμοστική ομοιογένεια) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: οι πιο καθαρές ακμές και τα λιγότερα χρωματικά περιθώρια, η πιο αργή. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Παρεμβολή με διόρθωση κλίσης: ευκρινής και γρήγορη, με λίγα χρωματικά περιθώρια στις λεπτές λεπτομέρειες. + The fastest and the softest, with colour fringing on fine detail. Η πιο γρήγορη και η πιο μαλακή, με χρωματικά περιθώρια στις λεπτές λεπτομέρειες. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..6070bba363 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4192,3 +4192,10 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Demosaic Demosaico + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cómo se completa el mosaico del filtro de color de la cámara. Se aplica al abrir el archivo; la vista previa muestra el método con el que se abrió. + AHD (adaptive homogeneity) AHD (homogeneidad adaptativa) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: los bordes más limpios y el menor halo de color; el más lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolación con corrección de gradiente: nítida y rápida, con algo de halo de color en el detalle fino. + The fastest and the softest, with colour fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..4cecef5432 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3238,3 +3238,10 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Demosaic Dématriçage + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Comment la mosaïque du filtre couleur de l'appareil est complétée. Appliqué à l'ouverture du fichier ; l'aperçu montre la méthode avec laquelle il a été ouvert. + AHD (adaptive homogeneity) AHD (homogénéité adaptative) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks : les bords les plus nets et le moins de franges colorées ; le plus lent. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolation corrigée par gradient : nette et rapide, avec quelques franges colorées sur les détails fins. + The fastest and the softest, with colour fringing on fine detail. Le plus rapide et le plus doux, avec des franges colorées sur les détails fins. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..1bad45e36a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4149,3 +4149,10 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Demosaic Demozaikolás + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hogyan tölti ki a program a kamera színszűrő-mozaikját. A fájl megnyitásakor érvényesül; az előnézet a megnyitáskor használt módszert mutatja. + AHD (adaptive homogeneity) AHD (adaptív homogenitás) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: a legtisztább élek és a legkevesebb színes szegély, a leglassabb. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradienskorrigált interpoláció: éles és gyors, apró részleteken némi színes szegéllyel. + The fastest and the softest, with colour fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..9f1d7c61df 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3225,3 +3225,10 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Demosaic Demosaik + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cara mosaik filter warna kamera diisi. Diterapkan saat berkas dibuka; pratinjau menampilkan metode yang dipakai saat dibuka. + AHD (adaptive homogeneity) AHD (homogenitas adaptif) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: tepi paling bersih dan pinggiran warna paling sedikit, paling lambat. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolasi terkoreksi gradien: tajam dan cepat, dengan sedikit pinggiran warna pada detail halus. + The fastest and the softest, with colour fringing on fine detail. Paling cepat dan paling lembut, dengan pinggiran warna pada detail halus. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..02e1fd9fb1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4347,3 +4347,10 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Demosaic Demosaicizzazione + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Come viene completato il mosaico del filtro colore della fotocamera. Si applica all'apertura del file; l'anteprima mostra il metodo con cui è stato aperto. + AHD (adaptive homogeneity) AHD (omogeneità adattiva) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: i bordi più puliti e le minori frange di colore, il più lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolazione con correzione del gradiente: nitida e veloce, con qualche frangia di colore nei dettagli fini. + The fastest and the softest, with colour fringing on fine detail. Il più veloce e il più morbido, con frange di colore nei dettagli fini. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..96df27a9f1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3907,3 +3907,10 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Demosaic デモザイク + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. カメラのカラーフィルター配列をどのように補間するか。ファイルを開くときに適用されます。プレビューは開いたときの方式で表示されます。 + AHD (adaptive homogeneity) AHD(適応的均質性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:エッジが最もきれいで色にじみが最も少ないが、最も遅い。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 勾配補正補間:シャープで高速だが、細部に多少の色にじみが出る。 + The fastest and the softest, with colour fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ce06b65cdc..6ec3e9ace4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3409,3 +3409,10 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Demosaic 디모자이크 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 카메라 컬러 필터 모자이크를 채우는 방법입니다. 파일을 열 때 적용되며, 미리 보기는 파일을 연 방식으로 표시됩니다. + AHD (adaptive homogeneity) AHD (적응형 균질성) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: 가장 깨끗한 가장자리와 가장 적은 색 번짐, 가장 느림. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 기울기 보정 보간: 선명하고 빠르지만 미세한 디테일에 약간의 색 번짐이 있음. + The fastest and the softest, with colour fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..d260403a44 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3990,3 +3990,10 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Demosaic Demosaicing + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hoe het kleurfiltermozaïek van de camera wordt ingevuld. Wordt toegepast bij het openen van het bestand; de voorvertoning toont de methode waarmee het is geopend. + AHD (adaptive homogeneity) AHD (adaptieve homogeniteit) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de schoonste randen en de minste kleurranden, het traagst. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradiëntgecorrigeerde interpolatie: scherp en snel, met wat kleurranden in fijne details. + The fastest and the softest, with colour fringing on fine detail. Het snelst en het zachtst, met kleurranden in fijne details. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..2d529fa11a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3829,3 +3829,10 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Demosaic Demozaikowanie + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Sposób uzupełniania mozaiki filtra barwnego aparatu. Stosowany przy otwieraniu pliku; podgląd pokazuje metodę, z którą plik został otwarty. + AHD (adaptive homogeneity) AHD (adaptacyjna jednorodność) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najczystsze krawędzie i najmniej barwnych obwódek, najwolniejsza. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacja z korekcją gradientu: ostra i szybka, z niewielkimi barwnymi obwódkami na drobnych szczegółach. + The fastest and the softest, with colour fringing on fine detail. Najszybsza i najmiększa, z barwnymi obwódkami na drobnych szczegółach. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..a27c3c1e21 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4020,3 +4020,10 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Demosaic Demosaico + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Como o mosaico do filtro de cor da câmera é preenchido. É aplicado ao abrir o arquivo; a visualização mostra o método com que ele foi aberto. + AHD (adaptive homogeneity) AHD (homogeneidade adaptativa) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: as bordas mais limpas e o menor franjamento de cor; o mais lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolação com correção de gradiente: nítida e rápida, com algum franjamento de cor em detalhes finos. + The fastest and the softest, with colour fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..ac4906050d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3203,3 +3203,10 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Demosaic Демозаик + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Как восстанавливается мозаика цветного фильтра камеры. Применяется при открытии файла; превью показывает метод, с которым файл был открыт. + AHD (adaptive homogeneity) AHD (адаптивная однородность) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хиракава–Паркс: самые чистые края и меньше всего цветной каймы, самый медленный. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Интерполяция с коррекцией градиента: резкая и быстрая, с небольшой цветной каймой на мелких деталях. + The fastest and the softest, with colour fringing on fine detail. Самый быстрый и самый мягкий, с цветной каймой на мелких деталях. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..d3435d231a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4303,3 +4303,10 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Demosaic Demozaikovanje + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kako se popunjava mozaik filtera boja kamere. Primenjuje se pri otvaranju datoteke; pregled prikazuje metod kojim je datoteka otvorena. + AHD (adaptive homogeneity) AHD (adaptivna homogenost) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najčistije ivice i najmanje obojenih rubova, najsporiji. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacija sa korekcijom gradijenta: oštra i brza, sa malo obojenih rubova na finim detaljima. + The fastest and the softest, with colour fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..0d3833d85d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3960,3 +3960,10 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Demosaic Demozaikleme + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kameranın renk filtresi mozaiğinin nasıl doldurulduğu. Dosya açılırken uygulanır; önizleme dosyanın açıldığı yöntemi gösterir. + AHD (adaptive homogeneity) AHD (uyarlamalı homojenlik) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: en temiz kenarlar ve en az renk saçağı, en yavaş. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradyan düzeltmeli ara değerleme: keskin ve hızlı, ince ayrıntılarda biraz renk saçağı. + The fastest and the softest, with colour fringing on fine detail. En hızlı ve en yumuşak, ince ayrıntılarda renk saçağı. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..5c44e3b0bb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3938,3 +3938,10 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Demosaic Демозаїк + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. + AHD (adaptive homogeneity) AHD (адаптивна однорідність) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хіракава–Паркс: найчистіші краї й найменше кольорової облямівки, найповільніший. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Інтерполяція з корекцією градієнта: різка й швидка, з невеликою кольоровою облямівкою на дрібних деталях. + The fastest and the softest, with colour fringing on fine detail. Найшвидший і найм'якший, з кольоровою облямівкою на дрібних деталях. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..571dd1e65c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3717,3 +3717,10 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Demosaic Giải khảm + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cách lấp đầy khảm lọc màu của máy ảnh. Áp dụng khi mở tệp; bản xem trước hiển thị phương pháp đã dùng khi mở. + AHD (adaptive homogeneity) AHD (đồng nhất thích ứng) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: cạnh sạch nhất và ít viền màu nhất, chậm nhất. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Nội suy hiệu chỉnh gradient: sắc nét và nhanh, có chút viền màu ở chi tiết nhỏ. + The fastest and the softest, with colour fringing on fine detail. Nhanh nhất và mềm nhất, có viền màu ở chi tiết nhỏ. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 01e0d57051..7167cc344e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2851,3 +2851,10 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Demosaic 去马赛克 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填补相机彩色滤镜阵列的马赛克。在打开文件时应用;预览显示打开时所用的方法。 + AHD (adaptive homogeneity) AHD(自适应同质性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:边缘最干净、色边最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:锐利且快速,细节处有些许色边。 + The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,细节处有色边。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2d9ad2905a..8b5ec2f624 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2836,3 +2836,10 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Demosaic 去馬賽克 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填補相機彩色濾鏡陣列的馬賽克。在開啟檔案時套用;預覽顯示開啟時所用的方法。 + AHD (adaptive homogeneity) AHD(自適應同質性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:邊緣最乾淨、色邊最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:銳利且快速,細節處有些許色邊。 + The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,細節處有色邊。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6685b9b77..5df29f60c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -253,7 +253,7 @@ pub use file_open::OsEvent; pub use state::{Tool, UiState}; /// Decode a file: (document, warnings about anything approximated or dropped). -pub type ImportFn = Box Result<(Document, Vec), String>>; +pub type ImportFn = Box Result<(Document, Vec), String>>; /// Encoder settings chosen in Export As (the file format comes from the name's extension). #[derive(Clone, Debug, PartialEq)] pub struct ExportSettings { @@ -1349,8 +1349,8 @@ impl PhotocraftApp { return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; - let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let (doc, warnings) = import(name, bytes, &opts)?; // Edit › Color Settings policies apply on open; Ask When Opening holds the file back // until its prompt is answered, and later files wait behind it (`open_prompt`). // Missing fonts are read from the document itself: it may not be open yet (#4559). @@ -1399,8 +1399,8 @@ impl PhotocraftApp { /// events and no Color Settings policy (which may read user-configured profile paths). fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result, String> { let import = self.services.import.as_ref().ok_or("no importer configured")?; - let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let (doc, warnings) = import(name, bytes, &opts)?; // The caller records the path it read from. self.session.add_document(doc, None); if let Some(st) = self.session.active_mut() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bbaf6fa6a4..d4820fa250 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2095,7 +2095,7 @@ mod open_recent_tests { use photocraft_color::{ColorMode, SampleType}; use photocraft_geom::Size; let services = crate::Services { - import: Some(Box::new(|_n: &str, _b: &[u8], _depth: usize| { + import: Some(Box::new(|_n: &str, _b: &[u8], _opts: &photocraft_io::ImportOptions| { Ok((photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())) })), ..Default::default() diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6c910865bd..8f799c53f7 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1143,6 +1143,8 @@ fn choice_label(v: &str) -> String { "dx12" => "DirectX 12".into(), "metal" => "Metal".into(), "gl" => "OpenGL".into(), + "ahd" => "AHD (adaptive homogeneity)".into(), + "mhc" => "Malvar–He–Cutler".into(), "cpu" => "CPU (no GPU acceleration)".into(), v => humanize(v), } @@ -3481,9 +3483,10 @@ mod tests { assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); - // Camera Raw Defaults shows only "Open in Camera Raw" so far. + // Camera Raw Defaults shows "Open in Camera Raw" and the demosaic method so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); + assert!(!prefs::is_hidden("rawDefaults.demosaic")); assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); assert!(!prefs::is_hidden("general.autoShowHomeScreen")); assert!(!prefs::is_hidden("interface.uiScale")); diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 3eeb06b003..6adab105f7 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -30,8 +30,8 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }, @@ -168,8 +168,8 @@ fn the_prompt_is_translated_and_still_names_its_file() { ), ] { let services = Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }; @@ -204,7 +204,7 @@ fn a_held_file_still_reports_its_missing_fonts() { s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap(); let mut doc = (*s.active().unwrap().doc).clone(); doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); - let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: usize| Ok((doc.clone(), Vec::new())))), ..Default::default() }; + let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: &photocraft_io::ImportOptions| Ok((doc.clone(), Vec::new())))), ..Default::default() }; let mut app = PhotocraftApp::new(Session::new(), services); app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]); diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index e055cdd546..1aa56eaac7 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -16,8 +16,8 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }, diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c947afc9b6..988d529d82 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -9,8 +9,8 @@ fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, max_svg_group_depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc, path, _| { photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) diff --git a/docs/scorecard.md b/docs/scorecard.md index 3b36e7c611..04b04a04f1 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 169 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 169** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 357dfd11432953bb6ec4ac34fddf30aca5ebe582 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:29:01 +0200 Subject: [PATCH 0620/1452] fmt: the test import closure Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/profile_open_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 6adab105f7..c92f97aa7f 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -204,7 +204,8 @@ fn a_held_file_still_reports_its_missing_fonts() { s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap(); let mut doc = (*s.active().unwrap().doc).clone(); doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); - let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: &photocraft_io::ImportOptions| Ok((doc.clone(), Vec::new())))), ..Default::default() }; + let services = + Services { import: Some(Box::new(move |_: &str, _: &[u8], _: &photocraft_io::ImportOptions| Ok((doc.clone(), Vec::new())))), ..Default::default() }; let mut app = PhotocraftApp::new(Session::new(), services); app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]); From 5b087699be62c70b71556fa49056a27d69216410 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 18 Oct 2026 10:29:07 -0400 Subject: [PATCH 0621/1452] Tests: fix the flaky file-lock tests and the occasional portal_save hang (#7418) The rotation and instance-log tests in logging.rs, and two app_dirs migration-lock tests, drop a flock and check straight away that it is free. A child that another test spawns at that moment holds a copy of every open descriptor until its exec, the lock included, so the check sometimes saw it still held (about 6 failures in 15 runs of the app's test binary). Tests that spawn children (the portal mock's dbus-daemon, the display-profile helper test) now hold CHILD_SPAWNS for reading while they spawn, and the lock tests hold it for writing. The portal mock made its message stream inside its thread; a stream only gets messages that arrive after it exists, so a SaveFile sent before the thread got going was lost and the test waited forever (about 1 run in 25). The stream is now made before the thread starts. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/app_dirs.rs | 2 ++ apps/photocraft/src/logging.rs | 3 +++ apps/photocraft/src/main.rs | 13 +++++++++++++ apps/photocraft/src/monitor_profile.rs | 1 + apps/photocraft/src/portal_save/tests.rs | 7 ++++++- 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 5775e254df..39d90f07bb 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -784,6 +784,7 @@ mod tests { /// now-existing folder and the original is put back, so both autosaves stay (#6412 review). #[test] fn a_launch_that_times_out_never_writes_where_a_move_removes() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-timeout"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "old"); @@ -866,6 +867,7 @@ mod tests { /// once the lock is released, the move happens. #[test] fn a_move_waits_for_another_process_holding_the_lock() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-lock"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "a"); diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 33b7df2462..5c8f0697dd 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -420,6 +420,7 @@ mod tests { #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("rotate"); for run in 1..=4 { let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate"); @@ -438,6 +439,7 @@ mod tests { /// `.2.log`, and a fourth deleted it. #[test] fn a_second_start_leaves_the_running_instance_log_alone() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("second"); let mut first = rotate(&dir).expect("first start"); assert_eq!(first.path, dir.join(LOG_FILE)); @@ -461,6 +463,7 @@ mod tests { #[test] fn instance_logs_are_pruned_only_when_no_one_holds_them() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("prune"); let first = rotate(&dir).expect("first start"); // Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 05355e55bf..826aba03ab 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -767,6 +767,19 @@ fn main() -> eframe::Result { result } +/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock +/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file, +/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held +/// for a moment. +#[cfg(test)] +pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile. +#[cfg(test)] +pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { #[test] diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs index 8e33869696..c210e096ec 100644 --- a/apps/photocraft/src/monitor_profile.rs +++ b/apps/photocraft/src/monitor_profile.rs @@ -495,6 +495,7 @@ mod tests { #[cfg(unix)] #[test] fn a_hung_helper_is_stopped_and_big_replies_dont_block() { + let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let t0 = std::time::Instant::now(); let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err(); assert!(e.contains("took longer than"), "{e}"); diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..5a839b6025 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std: /// once the user is done with it (here after 300 ms). fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { use std::io::BufRead; + let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let mut daemon = std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + drop(spawns); let mut address = String::new(); std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; let address = address.trim().to_string(); let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); let seen: Seen = Arc::default(); let log = seen.clone(); + // A stream only gets the messages that arrive after it exists: made in the thread, it could + // miss a `SaveFile` sent before the thread got going, and the call would wait forever. + let messages = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in messages { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { From 109c34b2733776b29e4061fe4c89da2a5428371f Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 10:30:07 -0400 Subject: [PATCH 0622/1452] Auto-Blend: cap the work area past the canvas independently of layer bounds A sparse layer (a dot at each far corner) has huge bounds that also raise the content estimate, so the whole union was chosen and its buffers could exhaust memory. Past the canvas, work on at most MAX_BLEND_PIXELS; beyond that keep to the canvas as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 40 ++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index 9b6790355e..bd8c4e526b 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -154,6 +154,10 @@ fn pixels(l: &Layer, area: Rect) -> Option<(Vec, photocraft_color::PixelFor Some((s.read_region(area), s.format())) } +/// The most pixels Auto-Blend works on past the canvas: a bound independent of the layers' own +/// bounding boxes, which a sparse layer (a dot at each far corner) can make arbitrarily large. +const MAX_BLEND_PIXELS: u64 = 1 << 27; + fn auto_blend(s: &mut Session, p: &Value) -> Result { let cmd = "edit.autoBlendLayers"; let ids = selected_pixel_layers(s); @@ -189,10 +193,11 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All - // shows the whole panorama. Layers so far apart that their bounds are mostly empty keep to - // the canvas. + // shows the whole panorama. Layers so far apart that their bounds are mostly empty, or + // whose union exceeds `MAX_BLEND_PIXELS`, keep to the canvas. let canvas = doc.bounds(); - let area = if px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2) { union } else { union.intersect(&canvas) }; + let fits = px_of(&union) <= MAX_BLEND_PIXELS && px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2); + let area = if fits { union } else { union.intersect(&canvas) }; if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -457,6 +462,35 @@ mod tests { assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part"); } + /// A sparse layer whose bounds are huge (a dot at each far corner) raises its own content + /// estimate, so the extent past the canvas is capped independently: it keeps to the canvas. + #[test] + fn auto_blend_sparse_huge_bounds_keep_to_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "a"})).unwrap(); + s.edit("a", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 150, 200), &[0.2, 0.4, 0.5, 1.0]); + surf.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "b"})).unwrap(); + s.edit("b", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 0, 200, 200), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let b = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let bb = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap().content_bounds(); + assert!(bb.x1 <= 200 && bb.y1 <= 200, "{bb:?}"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); From 6e460aab8a3cedd6c80211e29ebcfd412edbcbee Mon Sep 17 00:00:00 2001 From: driftee <48539371+driftee@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:38:54 +0800 Subject: [PATCH 0623/1452] fix(transform): isolate nested preview siblings (#7280) --- crates/ui-egui/src/transform_tool.rs | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d46ef189bc..d14046db9d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -792,13 +792,11 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - for l in solo.layers.iter_mut() { - if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { - l.visible = false; - } else { - // Its clipping base is hidden here, which would clip it away entirely (#5239). - l.clipped = false; - } + show_only(&mut solo.layers, &[id]); + // Its clipping base is hidden here, which would clip it away entirely (#5239). + // Clear the target itself, not only the top-level group that contains it. + if let Some(l) = solo.layer_mut(id) { + l.clipped = false; } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { l.effects.enabled = false; @@ -4747,6 +4745,29 @@ mod tests { assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]); } + #[test] + fn nested_group_preview_excludes_its_siblings() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let mut sibling = Layer::raster("sibling", PixelFormat::RGBA8); + sibling.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(12, 12, 20, 20), &[0.0, 0.0, 1.0, 1.0]); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + doc.layers.push(Layer::group("parent", vec![target, sibling])); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!( + image.pixels[8 * image.size[0] + 8], + Color32::from_rgb(255, 0, 0), + "a sibling in the parent group must not be baked into the moving texture" + ); + } + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); From 8909e1c9483ab97da023f35ecbf3d2b26f3e2bb6 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:38:58 +0200 Subject: [PATCH 0624/1452] Camera Raw: the full-resolution preview of a raw open shows the raw-stage sliders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a raw opens, Shadows, Highlights, Clarity, Texture and Dehaze act on the sensor data (`retone`, in linear light before the camera's tone curve) and Open develops the file that way, but the dialog's full-resolution preview ran the filter's RGB versions of those sliders: at 100 % the preview showed one thing and Open made another. `DetailPreview` gets the raw's tone curve (`raw_curve`, set when the open-time dialog opens); with it, `develop` retones the whole area from the whole frame's maps exactly as Open does, then runs the rest of the pipeline with those five sliders at zero. Without a curve (the filter on a layer) nothing changes. `retone` runs its rows in parallel (`crate::par`) and recomputes the linear colours per band instead of keeping a second copy of the image, since the detail preview develops 16–45 MP frames. Test: `a_raw_open_refines_raw_stage_sliders_from_the_whole_frame`. Split out of #3816 at the reviewer's request. Co-Authored-By: Claude Fable 5.1 --- crates/raw/src/tone.rs | 44 ++++++---- crates/ui-egui/src/camera_raw_detail_ui.rs | 94 ++++++++++++++++++++-- crates/ui-egui/src/camera_raw_ui.rs | 2 + 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 80a80bfdd6..2dc594b68d 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -555,23 +555,39 @@ pub fn retone(px: &mut [[f32; 4]], w: usize, h: usize, curve: &[[f32; 2]], tone: let i = table.partition_point(|t| *t < e).clamp(1, N); let (a, b) = (table[i - 1], table[i]); let f = if b > a { ((e - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; - ((i - 1) as f32 + f) / N as f32 + (((i - 1) as f32 + f) / N as f32).powi(2) }; - let mut lin: Vec<[f32; 3]> = px.iter().map(|p| [0, 1, 2].map(|k| inverse(p[k]).powi(2))).collect(); - if let Some(haze) = Haze::new(w, h, |i| lin[i], tone.dehaze) { - for (i, l) in lin.iter_mut().enumerate() { - *l = haze.apply(i % w, i / w, *l); - } - } + let linear = |p: &[f32; 4]| [inverse(p[0]), inverse(p[1]), inverse(p[2])]; + // Full-size images (the detail preview develops 16–45 MP): rows in parallel, and the linear + // colours recomputed where needed instead of kept as a second copy of the image. + let haze = Haze::new(w, h, |i| linear(&px[i]), tone.dehaze); let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); - let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; - let map = ToneMap::new(w, h, |i| lum(&lin[i]), tone, 0.0); - for (i, (p, l)) in px.iter_mut().zip(&lin).enumerate() { - let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum(l))); - for k in 0..3 { - p[k] = forward(l[k] * g); + let lum_of = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let dehazed = |i: usize| { + let l = linear(&px[i]); + haze.as_ref().map_or(l, |hz| hz.apply(i % w, i / w, l)) + }; + let rows = crate::par::band_rows(w); + let lum: Vec = crate::par::map(h.div_ceil(rows), |band| { + let (y0, y1) = (band * rows, ((band + 1) * rows).min(h)); + (y0 * w..y1 * w).map(|i| lum_of(&dehazed(i))).collect::>() + }) + .concat(); + let map = ToneMap::new(w, h, |i| lum[i], tone, 0.0); + let haze = haze.as_ref(); + crate::par::chunks_mut(&mut px[..w * h], rows * w, |band, chunk| { + for (j, p) in chunk.iter_mut().enumerate() { + let i = band * rows * w + j; + let mut l = [inverse(p[0]), inverse(p[1]), inverse(p[2])]; + if let Some(hz) = haze { + l = hz.apply(i % w, i / w, l); + } + let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum[i])); + for k in 0..3 { + p[k] = forward(l[k] * g); + } } - } + }); } #[cfg(test)] diff --git a/crates/ui-egui/src/camera_raw_detail_ui.rs b/crates/ui-egui/src/camera_raw_detail_ui.rs index 7b445f1775..48b09c43f2 100644 --- a/crates/ui-egui/src/camera_raw_detail_ui.rs +++ b/crates/ui-egui/src/camera_raw_detail_ui.rs @@ -22,6 +22,9 @@ type OverlayKey = (u64, bool, PixelRect, crate::camera_raw_scope_ui::ClippingMod pub(crate) struct DetailPreview { source: Surface, + /// The open-time dialog's camera tone curve: Shadows, Highlights, Clarity, Texture and Dehaze + /// then act on the raw data, in linear light before it (see [`develop`]). + pub(crate) raw_curve: Option>>, area: PixelRect, coverage: Coverage, result: Option<(u64, Surface)>, @@ -46,6 +49,7 @@ impl DetailPreview { pub(crate) fn new(source: Surface, area: PixelRect, coverage: Coverage) -> Self { Self { source, + raw_curve: None, area, coverage, result: None, @@ -105,6 +109,7 @@ impl DetailPreview { return; } let source = self.source.clone(); + let raw_curve = self.raw_curve.clone(); let area = self.area; let coverage = self.coverage.clone(); let mut params = params.clone(); @@ -116,9 +121,10 @@ impl DetailPreview { if cancel.load(Ordering::Relaxed) { return; } - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, area, ¶ms, &coverage))) - .map(|surface| (revision, surface)) - .map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into()); + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, raw_curve.as_deref().map(Vec::as_slice), area, ¶ms, &coverage))) + .map(|surface| (revision, surface)) + .map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into()); if !cancel.load(Ordering::Relaxed) { let _ = tx.send(result); ctx.request_repaint(); @@ -291,8 +297,43 @@ impl DetailPreview { } } -fn develop(source: &Surface, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface { - let mut result = photocraft_engine::lens_cmds::camera_raw_surface(source, area, params); +/// The full-size result of `params` over `area`. With a raw file's tone `curve`, the raw-stage +/// sliders are applied first to the whole area, from the whole frame's maps as Open computes them +/// ([`photocraft_io::raw::retone`]), then the rest of the pipeline: at 1:1 the preview shows what +/// Open will make. +fn develop(source: &Surface, curve: Option<&[[f32; 2]]>, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface { + let tone = photocraft_io::raw::Tone { + shadows: params.shadows, + highlights: params.highlights, + clarity: params.clarity, + texture: params.texture, + dehaze: params.dehaze, + }; + let (retoned, params) = match curve { + Some(curve) if !tone.is_neutral() => { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut px = vec![[0.0f32; 4]; w * h]; + source.read_rgba_into(area, &mut px); + photocraft_io::raw::retone(&mut px, w, h, curve, tone); + let fmt = source.format(); + let n = fmt.channels(); + let mut data = source.read_region(area); + for (out, q) in data.chunks_exact_mut(n).zip(&px) { + let alpha = if fmt.alpha { out.get(n - 1).copied() } else { None }; + photocraft_raster::from_rgba_into(&fmt, *q, out); + if let (Some(a), Some(o)) = (alpha, out.get_mut(n - 1)) { + *o = a; + } + } + let mut out = source.clone(); + out.write_region(area, &data); + let rest = CameraRaw { shadows: 0.0, highlights: 0.0, clarity: 0.0, texture: 0.0, dehaze: 0.0, ..params.clone() }; + (Some(out), rest) + } + _ => (None, params.clone()), + }; + let base = retoned.as_ref().unwrap_or(source); + let mut result = photocraft_engine::lens_cmds::camera_raw_surface(base, area, ¶ms); if matches!(coverage, Coverage::None) { return result; } @@ -403,10 +444,47 @@ mod tests { source.write_region(area, &values); let params = CameraRaw { exposure: 0.5, texture: 15.0, grain_amount: 30.0, ..Default::default() }; let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms); - assert_eq!(develop(&source, area, ¶ms, &Coverage::None), expected); + assert_eq!(develop(&source, None, area, ¶ms, &Coverage::None), expected); let mask = Surface::with_default(PixelFormat::GRAY8, &[0.0]); - assert_eq!(develop(&source, area, ¶ms, &Coverage::Selection(mask)), source); + assert_eq!(develop(&source, None, area, ¶ms, &Coverage::Selection(mask)), source); } + #[test] + fn a_raw_open_refines_raw_stage_sliders_from_the_whole_frame() { + // Shadows on a raw being opened: the full-size refinement retones the whole frame (as + // Open does) and runs the rest of the pipeline after, instead of the filter's Shadows. + let area = PixelRect::new(0, 0, 96, 64); + let mut source = Surface::new(PixelFormat::RGBA16); + let mut values = vec![0.0; 96 * 64 * 4]; + for (i, p) in values.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let v = if (i % 96) < 48 { 0.12 } else { 0.7 }; + p.copy_from_slice(&[v, v * 0.9, v * 0.8, 1.0]); + } + source.write_region(area, &values); + let curve = [[0.01, 0.01], [0.05, 0.12], [0.3, 0.6], [1.0, 1.0]]; + let params = CameraRaw { shadows: 80.0, exposure: 0.25, ..Default::default() }; + let refined = develop(&source, Some(&curve), area, ¶ms, &Coverage::None); + let mut px = vec![[0.0f32; 4]; 96 * 64]; + source.read_rgba_into(area, &mut px); + let tone = photocraft_io::raw::Tone { shadows: 80.0, ..Default::default() }; + photocraft_io::raw::retone(&mut px, 96, 64, &curve, tone); + let mut retoned = source.clone(); + let flat: Vec = px.iter().flatten().copied().collect(); + retoned.write_region(area, &flat); + let expected = photocraft_engine::lens_cmds::camera_raw_surface(&retoned, area, &CameraRaw { exposure: 0.25, ..Default::default() }); + let (a, b) = (refined.rgba(10, 30), expected.rgba(10, 30)); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 2e-4), "{a:?} vs {b:?}"); + assert!( + refined.rgba(10, 30)[0] > source.rgba(10, 30)[0] + 0.02, + "the shadow side is lifted: {:?} from {:?}, retoned {:?}", + refined.rgba(10, 30), + source.rgba(10, 30), + retoned.rgba(10, 30) + ); + // Without a raw curve Shadows is the filter's. + let filter = develop(&source, None, area, ¶ms, &Coverage::None); + assert_eq!(filter, photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms)); + } + #[test] fn stale_refinements_and_failed_old_revisions_do_not_replace_new_settings() { let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.6, 1.0]); @@ -446,7 +524,7 @@ mod tests { } source.write_region(area, &pixels); let params = CameraRaw { exposure: 0.5, sharpen_amount: 20.0, grain_amount: 30.0, ..Default::default() }; - let full = develop(&source, area, ¶ms, &Coverage::None); + let full = develop(&source, None, area, ¶ms, &Coverage::None); let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms); assert_eq!(full, expected); let mut preview = DetailPreview::new(source, area, Coverage::None); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index d2f78a35c2..f49bb5bc37 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -245,6 +245,8 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul }; if let Some(d) = app.camera_raw.as_mut() { d.layer_name = raw.name.clone(); + // The full-size preview retones the whole frame with it, as Open will. + d.detail.raw_curve = curve.clone().map(std::sync::Arc::new); d.raw_curve = curve; d.raw_open = Some(raw); } From 2c1c6f6693ca91eaa43b9fd1b04f9d2f319bb48a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:38:59 +0200 Subject: [PATCH 0625/1452] Open Photoshop guides stored in a JPEG's APP13 resources (#7300) (#7348) --- crates/codecs/src/codecs/jpeg.rs | 5 ++++- crates/io/src/flat.rs | 5 +++++ crates/io/src/psd_import.rs | 2 +- crates/io/tests/flat.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 9c37628872..a300de4bd1 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -33,6 +33,8 @@ pub(crate) struct JpegMeta { pub exif: Option>, pub xmp: Option, pub dpi: Option<(f32, f32)>, + /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header. + pub photoshop: Option>, pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, @@ -108,6 +110,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { .or_else(|| m.xmp.as_deref().and_then(xmp_resolution)) .or_else(|| m.exif.as_deref().and_then(exif_resolution)) .or(jfif_dpi); + m.photoshop = Some(photoshop).filter(|p| !p.is_empty()); if !icc_parts.is_empty() { icc_parts.sort_by_key(|p| p.0); let total = icc_parts[0].1 as usize; @@ -226,7 +229,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; img.icc = meta.icc; - img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() }; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; if end == DataEnd::Truncated { img.warnings.push(DecodeWarning::Truncated { format: F }); } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 674c605610..773e692729 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -241,6 +241,11 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result Vec { let mut b = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec(); From e04f52266731181e5b4504aef93e099e229ed142 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:39:04 +0200 Subject: [PATCH 0626/1452] Centre stacked ruler digits and the Properties W/H/X/Y labels (#7314) (#7349) --- crates/ui-egui/src/doc_props_ui.rs | 43 +++++++++++++++++++--- crates/ui-egui/src/layer_props_ui.rs | 53 ++++++++++++++++++++++------ crates/ui-egui/src/props_layout.rs | 11 ++++++ crates/ui-egui/src/rulers.rs | 27 ++++++++++++-- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index fbdbc9a955..6a959c38d7 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -6,6 +6,7 @@ use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType}; use serde_json::{Value, json}; +use crate::props_layout::stacked_label; use crate::theme::Tokens; use crate::{PhotocraftApp, icons, widgets}; @@ -151,19 +152,19 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); ui.vertical(|ui| { ui.horizontal(|ui| { - field_label(ui, "W", 18.0); + stacked_label(ui, "W", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "w", w as f32, lu) { run.extend(canvas_resize_params((w, h), Some(v), None, linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "X", 22.0); + stacked_label(ui, "X", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); ui.horizontal(|ui| { - field_label(ui, "H", 18.0); + stacked_label(ui, "H", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "h", h as f32, lu) { run.extend(canvas_resize_params((w, h), None, Some(v), linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "Y", 22.0); + stacked_label(ui, "Y", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); }); @@ -342,6 +343,40 @@ mod tests { assert_eq!(st.history.entries().len(), undo, "no history step"); } + /// Horizontal centre of every single-letter label the panel drew, by letter. + fn letter_centres(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(String, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect() + } + + /// #7314: the Canvas section's W over H (and X over Y) share a centre; a narrow "H" used to sit + /// flush right under the wider "W". + #[test] + fn canvas_size_labels_are_centred_on_each_other() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let centres = letter_centres(&h); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("H")).abs() < 0.01, "W and H: {centres:?}"); + assert!((at("X") - at("Y")).abs() < 0.01, "X and Y: {centres:?}"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index c2ea848c5e..57335a897b 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -6,7 +6,7 @@ use egui::{Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent}; use serde_json::{Value, json}; -use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section}; +use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section, stacked_label}; use crate::theme::{ROW_GAP, Tokens}; use crate::{PhotocraftApp, widgets}; @@ -33,11 +33,10 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option, h: Option, Some(json!({"layer": layer, "rect": b, "quad": quad})) } -/// A number field that reports a value once committed (drag released, Enter, focus lost). -fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32, u: LenUnit, extent: f64) -> Option { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover()); - ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, tl!(label), egui::FontId::proportional(12.0), t.text_dim); +/// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own +/// label and the one stacked with it in the same column ("W" over "X"), which share a centre. +fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, u: LenUnit, extent: f64) -> Option { + stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width); @@ -137,24 +136,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", "W", b.width() as f32, w, u, dw) { + if let Some(v) = field(ui, "w", ("W", "X"), b.width() as f32, w, u, dw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", "H", b.height() as f32, w, u, dh) { + if let Some(v) = field(ui, "h", ("H", "Y"), b.height() as f32, w, u, dh) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", "X", b.x0 as f32, w, u, dw) { + if let Some(v) = field(ui, "x", ("X", "W"), b.x0 as f32, w, u, dw) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w, u, dh) { + if let Some(v) = field(ui, "y", ("Y", "H"), b.y0 as f32, w, u, dh) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32}))); } }); @@ -281,6 +280,40 @@ mod tests { } } + /// #7314: in the Transform section W sits over X and H over Y, and each pair shares a centre. + #[test] + fn transform_labels_are_centred_on_each_other() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let centres: Vec<(String, f32)> = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect(); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("X")).abs() < 0.01, "W and X: {centres:?}"); + assert!((at("H") - at("Y")).abs() < 0.01, "H and Y: {centres:?}"); + } + #[test] fn transform_fields_show_and_take_the_ruler_unit() { use photocraft_engine::prefs::Unit; diff --git a/crates/ui-egui/src/props_layout.rs b/crates/ui-egui/src/props_layout.rs index 674ff4dba2..ea67b3714d 100644 --- a/crates/ui-egui/src/props_layout.rs +++ b/crates/ui-egui/src/props_layout.rs @@ -17,6 +17,17 @@ pub const LABEL_GAP: f32 = 4.0; /// Horizontal gap between two label+field columns. pub const COL_GAP: f32 = 8.0; +/// A one-letter field label ("W", "H", "X", "Y") in a `w`-wide column, ending `pad` before its field. The letters +/// stacked in one column share a centre (#7314): the widest of `column` stays flush right, and the narrower ones +/// are centred on it rather than pushed against its right edge. +pub fn stacked_label(ui: &mut egui::Ui, s: &str, column: &[&str], w: f32, pad: f32) { + let t = Tokens::get(ui.ctx()); + let font = egui::FontId::proportional(12.0); + let widest = column.iter().map(|c| ui.painter().layout_no_wrap((*c).to_owned(), font.clone(), t.text_dim).size().x).fold(0.0, f32::max); + let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + ui.painter().text(pos2(r.right() - pad - widest / 2.0, r.center().y), egui::Align2::CENTER_CENTER, s, font, t.text_dim); +} + /// Icon for a layer kind (the same glyphs as the Layers panel's kind filter). pub fn kind_icon(content: &LayerContent) -> &'static str { match content { diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 61ea25d96f..995f04a5bb 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -393,9 +393,10 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let c = at(v); if vertical { p.line_segment([pos2(left.right() - RULER, c), pos2(left.right(), c)], tick); - // Labels stacked, as in Photoshop. + // Labels stacked, as in Photoshop, each character centred in the ruler's width + // (a narrow "1" used to hug the left edge while a "0" filled the strip). for (i, ch) in ruler_label(v, step, whole).chars().enumerate() { - p.text(pos2(left.left() + 3.0, c + 2.0 + i as f32 * 8.5), Align2::LEFT_TOP, ch, font.clone(), t.text_dim); + p.text(pos2(left.left() + RULER * 0.5, c + 2.0 + i as f32 * 8.5), Align2::CENTER_TOP, ch, font.clone(), t.text_dim); } } else { p.line_segment([pos2(c, top.bottom() - RULER), pos2(c, top.bottom())], tick); @@ -534,6 +535,28 @@ mod tests { assert!(failures.is_empty(), "ruler drawing overlaps the origin corner: {failures:?}"); } + /// #7314: the stacked characters of the left ruler's labels are centred on the strip, so a + /// narrow "1" lines up with a wide "0". + #[test] + fn left_ruler_label_characters_are_centred() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let (mut h, full, _) = ruler_harness(app); + h.run_steps(2); + let centres: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.pos.x < full.min.x + RULER && t.pos.y > full.min.y + RULER) + .map(|t| t.pos.x + t.galley.size().x * 0.5) + .collect(); + assert!(centres.len() > 2, "the left ruler draws labels: {centres:?}"); + for c in ¢res { + assert!((c - (full.min.x + RULER * 0.5)).abs() < 0.01, "character centre {c} in {centres:?}"); + } + } + #[test] fn shift_snaps_the_zero_point_to_ruler_ticks_from_the_current_origin() { assert_eq!(snap_px_to_tick(47.0, 0.0, 10.0), 50.0); From c8c651c413cda3b3ed55557b840d711024980d16 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:39:09 +0200 Subject: [PATCH 0627/1452] Esc leaves full screen with the Crop tool showing its default frame (#7315) (#7350) --- crates/ui-egui/src/shortcuts.rs | 5 ++++- crates/ui-egui/src/view_cmds/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 99613d5a26..bfa1904c4f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -627,7 +627,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } - if pressed(Key::Escape) { + // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only + // makes the next one, so Esc goes on to leave full screen (#7315). + let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); crate::crop_ui::cancel(app); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 1abe863558..9361e51254 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -500,6 +500,29 @@ fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); } +#[test] +fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{mode}: the default frame stays"); + } + // With Straighten armed, Esc drops it first and the mode stays. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + app.crop.straighten.armed = true; + press_esc(&mut app, &ctx); + assert!(!app.crop.straighten.armed); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + crate::crop_ui::ensure_frame(&mut app); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); From 5f648126c7f84ee432d874abd61cba42ff15b537 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:15 +0200 Subject: [PATCH 0628/1452] Crop and Straighten Photos keeps Duotone inks and is disabled for Indexed Color (#7354) (#7355) The copies were made with Document::new and kept the mode but not the duotone inks, so a Duotone scan came out as plain gray. Copy them, and grey the command out for Indexed Color documents, as Photoshop does. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/photo_cmds.rs | 60 +++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 09fd474aad..72851b3679 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -859,6 +859,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { let mut nd = Document::new(format!("{} copy {}", doc.name, k + 1), Size::new(pw, ph), doc.mode, doc.depth); nd.icc_profile = doc.icc_profile.clone(); nd.resolution_dpi = doc.resolution_dpi; + nd.duotone = doc.duotone.clone(); let mut bg = Layer::new("Background", LayerContent::Raster(px)); bg.locks.transparency = true; bg.locks.position = true; @@ -871,8 +872,13 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { // ---------- specs ---------- -fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents. +fn not_indexed(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.mode == ColorMode::Indexed { + return Err("not available for Indexed Color documents".into()); + } + Ok(()) } fn always(_: &Session) -> std::result::Result<(), String> { @@ -907,7 +913,7 @@ pub fn specs() -> Vec { menu: &["File", "Automate"], shortcut: None, params: "{} → one new document per photo found on the scan", - enabled: has_doc, + enabled: not_indexed, run: crop_and_straighten, journal: true, }, @@ -1106,6 +1112,54 @@ mod tests { if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } } + /// A light scan with one dark tilted print on it. + fn scan_with_one_photo() -> Document { + let (w, h) = (300usize, 220usize); + let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h]; + let (s, co) = 5.0f64.to_radians().sin_cos(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f64 + 0.5 - 150.0, y as f64 + 0.5 - 110.0); + let (u, v) = (co * dx + s * dy, -s * dx + co * dy); + if u.abs() < 80.0 && v.abs() < 55.0 { + px[y * w + x] = [0.2 + (u / 160.0) as f32 * 0.3, 0.25, 0.3, 1.0]; + } + } + } + doc_from("scan", &px, w, h, SampleType::U8) + } + + #[test] + fn crop_and_straighten_keeps_duotone_inks() { + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute( + "image.mode.duotone", + json!({"type": "duotone", "inks": [{"name": "Black", "color": [0, 0, 0]}, {"name": "Orange", "color": [255, 128, 0]}]}), + ) + .unwrap(); + let src = s.documents()[0].doc.duotone.clone(); + assert!(src.is_some()); + let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap(); + assert_eq!(r["photos"].as_array().unwrap().len(), 1, "{r}"); + let d = &s.documents()[1].doc; + assert_eq!(d.mode, ColorMode::Duotone); + assert_eq!(d.duotone, src); + } + + #[test] + fn crop_and_straighten_disabled_for_indexed() { + // Photoshop greys the command out for Indexed Color documents. + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_eq!(s.documents()[0].doc.mode, ColorMode::Indexed); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos")); + assert!(s.execute("file.automate.cropAndStraightenPhotos", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); + } + #[test] fn crop_and_straighten_makes_documents() { let (w, h) = (500usize, 360usize); From 06d4d31dd4e77886279577daaefc5659fecb8302 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:19 +0200 Subject: [PATCH 0629/1452] Tool Presets: + saves only the current tool's options (#7356) (#7357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button in Window › Tool Presets stored the whole ToolOptions struct, so choosing the preset later wrote every other tool's options back (e.g. Crop's Delete Cropped Pixels). Reset Tool's per-tool field lists become `option_keys`, which the + button now uses to store only the tool's own options. Brush/Pencil Clear, the Eraser's mode, the Remove tool's options and Content-Aware Move's Transform On Drop join their tools' lists, and a test checks that every option belongs to a tool. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/preset_panels.rs | 36 +++++++++++++++++-- crates/ui-egui/src/tool_reset.rs | 54 ++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 8411c5af63..1cd542dbf3 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1025,6 +1025,15 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { } } +/// The `tool.presets.new` params for the + button: `tool`'s own options only, so choosing the +/// preset later leaves the other tools' settings alone. +fn new_tool_preset_params(app: &PhotocraftApp, tool: Tool) -> Value { + let all = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); + let own: serde_json::Map = + crate::tool_reset::option_keys(tool).iter().filter_map(|k| Some(((*k).to_string(), all.get(*k)?.clone()))).collect(); + json!({"name": tool.label(), "tool": tool_id(tool), "options": {"toolOptions": own}}) +} + pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let only = app.ui.presets_ui.current_tool_only; @@ -1093,9 +1102,7 @@ pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { action = Some(("tool.presets.edit".into(), json!({"action": "delete", "preset": n}))); } if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new tool preset")).clicked() { - let label = cur.label().to_string(); - let opts = json!({"toolOptions": serde_json::to_value(&app.ui.tool_options).unwrap_or_default()}); - action = Some(("tool.presets.new".into(), json!({"name": label, "tool": tool_id(cur), "options": opts}))); + action = Some(("tool.presets.new".into(), new_tool_preset_params(app, cur))); } }); }); @@ -1262,6 +1269,7 @@ pub fn clone_sample_point(app: &PhotocraftApp, at: Option<[f64; 2]>) -> Option<[ mod tests { use super::*; use crate::menus::invoke; + use crate::state::ToolOptions; fn app() -> (PhotocraftApp, egui::Context) { let ctx = egui::Context::default(); @@ -1311,6 +1319,28 @@ mod tests { assert_eq!(Tool::from_name(&tool_id(Tool::CustomShape)), Some(Tool::CustomShape)); } + /// #7356: a preset made with + keeps only its own tool's options, so choosing it doesn't + /// reset the Crop tool's Delete Cropped Pixels or the Move tool's Auto-Select. + #[test] + fn new_tool_preset_stores_only_its_tools_options() { + let (mut app, _) = app(); + app.ui.tool = Tool::RectMarquee; + app.ui.tool_options.feather = 5.0; + let p = new_tool_preset_params(&app, Tool::RectMarquee); + let r = app.run("tool.presets.new", p).unwrap(); + let name = r["name"].as_str().unwrap().to_string(); + let stored = &app.session.presets.tool_presets.iter().find(|t| t.name == name).unwrap().options["toolOptions"]; + assert_eq!(stored["feather"], json!(5.0)); + assert!(stored.as_object().unwrap().keys().all(|k| !k.starts_with("crop_") && !k.starts_with("move_")), "{stored}"); + app.ui.tool_options.feather = 0.0; + app.ui.tool_options.crop_delete = !ToolOptions::default().crop_delete; + app.ui.tool_options.move_auto_select = !ToolOptions::default().move_auto_select; + select_tool_preset(&mut app, &name); + assert_eq!(app.ui.tool_options.feather, 5.0); + assert_eq!(app.ui.tool_options.crop_delete, !ToolOptions::default().crop_delete); + assert_eq!(app.ui.tool_options.move_auto_select, !ToolOptions::default().move_auto_select); + } + #[test] fn custom_shape_tool_places_the_picked_shape() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 27e9622ab3..9fa9169f87 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -113,9 +113,32 @@ fn is_selection_tool(tool: Tool) -> bool { /// Put `tool`'s fields of `o` back to their defaults. pub fn reset_options(o: &mut ToolOptions, tool: Tool) { - let d = ToolOptions::default(); + let keys = option_keys(tool); + if keys.is_empty() { + return; + } + let (Ok(Value::Object(mut cur)), Ok(Value::Object(d))) = (serde_json::to_value(&*o), serde_json::to_value(ToolOptions::default())) else { + return; + }; + for k in keys { + if let Some(v) = d.get(*k) { + cur.insert((*k).to_string(), v.clone()); + } + } + if let Ok(n) = serde_json::from_value(Value::Object(cur)) { + *o = n; + } +} + +/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and +/// what a tool preset made with + stores (#7356). +pub fn option_keys(tool: Tool) -> &'static [&'static str] { + // `reset!` names each field once; one that is misspelt or gone fails to compile. macro_rules! reset { - ($($f:ident),* $(,)?) => {{ $( o.$f = d.$f.clone(); )* }}; + ($($f:ident),* $(,)?) => {{ + let _ = |o: &ToolOptions| { $(let _ = &o.$f;)* }; + &[$(stringify!($f)),*] + }}; } match tool { Tool::Move => reset!(move_auto_select, move_target, move_show_transform), @@ -144,7 +167,10 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { ), Tool::PerspectiveCrop => reset!(crop_width, crop_height, crop_resolution, crop_resolution_unit, perspective_crop_grid), Tool::Eyedropper => reset!(eyedropper_size, eyedropper_sample, eyedropper_ring), - Tool::Pencil => reset!(pencil_auto_erase), + Tool::Brush => reset!(brush_clear), + Tool::Pencil => reset!(pencil_auto_erase, pencil_clear), + Tool::Eraser => reset!(eraser_mode), + Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), @@ -155,7 +181,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::Healing => reset!(clone_aligned, clone_sample), Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color, sample_all_layers), - Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, sample_all_layers), + Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop, sample_all_layers), Tool::RedEye => reset!(red_eye_pupil_size, red_eye_darken), Tool::CloneStamp => reset!(clone_aligned, clone_sample), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), @@ -173,12 +199,9 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::Ruler | Tool::Note | Tool::Count - | Tool::Brush | Tool::MixerBrush - | Tool::Eraser | Tool::Hand | Tool::RotateView - | Tool::Remove | Tool::HistoryBrush | Tool::PathSelection | Tool::DirectSelection @@ -188,7 +211,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::DeleteAnchorPoint | Tool::ConvertPoint | Tool::Artboard - | Tool::Frame => {} + | Tool::Frame => &[], } } @@ -202,6 +225,21 @@ mod tests { PhotocraftApp::new(s, crate::Services::default()) } + /// Every option belongs to a tool, so Reset Tool and tool presets (#7356) miss none, and every + /// listed name is a real serialized field. + #[test] + fn every_option_belongs_to_a_tool() { + let all = serde_json::to_value(ToolOptions::default()).unwrap(); + let all = all.as_object().unwrap(); + let listed: std::collections::BTreeSet<&str> = Tool::ALL.iter().flat_map(|t| option_keys(*t).iter().copied()).collect(); + for k in all.keys() { + assert!(listed.contains(k.as_str()), "{k} is no tool's option"); + } + for k in listed { + assert!(all.contains_key(k), "{k} is not a ToolOptions field"); + } + } + /// Reset Tool resets only the current tool's options; Reset All Tools asks, then resets all. #[test] fn reset_tool_and_reset_all_tools() { From fb6f8b4fa6cab1c441f945ad03bea53d89d626d6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:24 +0200 Subject: [PATCH 0630/1452] Canvas Size: growing the canvas re-renders PSD fill layers (#7362) (#7363) A fill layer from a PSD keeps its stored pixels as a fill cache that covers only the old canvas. Canvas Size, an extending Crop and Reveal All translated that cache but kept it, so the added area of the fill layer stayed transparent. Drop the fill caches whenever the canvas grows, so the fill re-renders from its model over the whole canvas, as Photoshop does (Free Transform and Perspective Crop already drop it). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 18 ++++++++++++++++++ crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..4559ca49a5 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -54,6 +54,18 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn } } +/// Drops every fill cache of a layer tree. A fill layer covers the whole canvas, but its cache +/// (the pixels a PSD stored) only the old one: after the canvas grows the fill must re-render +/// from its model, or the added area stays transparent (#7362). +fn drop_fill_caches(layers: &mut [Layer]) { + for l in layers { + l.fill_cache = None; + if let LayerContent::Group(g) = &mut l.content { + drop_fill_caches(&mut g.children); + } + } +} + /// Converts every pixel surface of a layer tree (masks included) to `depth`. pub(crate) fn convert_layers_depth(layers: &mut [Layer], depth: SampleType) { for_each_surface(layers, true, &mut |surf, _| { @@ -337,6 +349,9 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi surf.write_interleaved(keep, &saved); } } + if extends { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); Ok(()) } @@ -397,6 +412,9 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { s.edit("Canvas Size", |doc, _| { translate_doc(doc, "image.canvasSize", dx, dy)?; doc.size = Size::new(nw, nh); + if nw > ow as u32 || nh > oh as u32 { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..0849cd941a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -702,6 +702,38 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { assert_eq!(after, fresh, "the effect maps track the cache surface, not the Fill spec alone"); } +#[test] +fn growing_the_canvas_re_renders_a_cached_fill_layer() { + // A PSD fill layer keeps its stored pixels as a fill cache covering only the old canvas. + // Canvas Size, an extending Crop and Reveal All must re-render the fill over the new area, + // as Photoshop does, instead of leaving the added bands transparent (#7362). + let grow: [(&str, Value); 2] = [ + ("image.canvasSize", json!({"width": 100, "anchor": "left"})), + ("image.crop", json!({"x": 0, "y": 0, "width": 100, "height": 48, "deleteCroppedPixels": false})), + ]; + for (cmd, params) in grow { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap(); + s.edit("cache", |doc, active| { + let fmt = doc.pixel_format(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Fill(f) = &l.content else { return Err(EngineError::Other("not a fill layer".into())) }; + let fill = f.clone(); + let mut surface = photocraft_raster::Surface::new(fmt); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[1.0, 0.0, 0.0, 1.0]); + l.fill_cache = Some(photocraft_doc::FillCache { fill, surface }); + Ok(()) + }) + .unwrap(); + s.execute(cmd, params).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.size.width, 100, "{cmd} grows the canvas"); + let px = photocraft_compose::render(d, d.bounds()).get(90, 20); + assert!(px[3] > 0.99 && px[0] > 0.99 && px[1] < 0.01, "{cmd}: the fill covers the added area, got {px:?}"); + } +} + #[test] fn group_effect_maps_follow_child_visibility_and_opacity() { // A styled group's maps come from its children's composite: hiding a child or changing its From a4c7b5ef89654b2cc3c1beccaff2af947ea0b228 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:29 +0200 Subject: [PATCH 0631/1452] Rotate Canvas Arbitrary: keep the angle at full precision, so rotated crops land on the pixel (#7369) (#7370) rotate_arbitrary read `angle` as f32. rotated_crop sizes its canvas with rotated_size at f64 and passes the angle through JSON; near a whole-pixel ceil boundary the f32 angle grew the canvas 1 px more (1000x1000 at 0.11470651449337071: 1003 instead of 1002), shifting the crop by 1 px. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 3 ++- crates/engine/src/mode_cmds/tests.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 840f1c6096..4625368ff6 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -63,7 +63,8 @@ pub fn rotated_size(size: Size, deg: f64) -> Size { pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.rotation.arbitrary"; - let angle = f64::from(num(p, "angle", 0.0)); + // At full precision: an f32 angle can grow the canvas 1 px past what `rotated_size` gives callers. + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(0.0); if !angle.is_finite() || angle.abs() > 3600.0 { return Err(bad(CMD, "angle out of range")); } diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index a78a11db7d..d506128d78 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,18 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_full_precision_angle() { + // Rounded to f32 this angle crosses a whole pixel: 1003 px instead of 1002, so a rotated crop + // that sized its canvas with `rotated_size` at f64 would land 1 px off. + let angle = 0.114_706_514_493_370_71; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 1000})).unwrap(); + let r = s.execute("image.rotation.arbitrary", json!({"angle": angle})).unwrap(); + assert_eq!(rotated_size(Size::new(1000, 1000), angle), Size::new(1002, 1002)); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1002), Some(1002))); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); From d459ca7939bdde87ace76537902f8286d86137a7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:34 +0200 Subject: [PATCH 0632/1452] Image Rotation: Arbitrary keeps Color Sampler points on their pixel (#7372) (#7373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sampler points are pixel indices, but rotate_arbitrary mapped them as the pixel's top-left corner while the warp moves pixels by their centres, so after a turn a point sat one pixel off its pixel, or fell off the canvas (a corner point at 90° or 180°). Map the pixel centre, as #6720 does for flips and right-angle turns; the turned Crop shares this map. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 4 +++- crates/engine/src/sampler_cmds.rs | 40 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 4625368ff6..fbe58af6a3 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -78,13 +78,15 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { } let bg = s.tools.background; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). + // They are pixel indices, so map the pixel's centre, as the warp samples the pixels. { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; let new = rotated_size(old, deg); let (sn, cs) = deg.to_radians().sin_cos(); let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); - s.set_sampler_map(Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }); + let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; + s.set_sampler_map(Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 0894ab72e1..57787b2e83 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -365,7 +365,7 @@ mod tests { #[test] fn a_right_angle_rotation_moves_points() { - // 90° clockwise about the centre: the doc centre stays put. + // 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x). let mut s = session(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); @@ -373,8 +373,7 @@ mod tests { assert_eq!(p.len(), 1); let pos = p[0]["position"].as_array().unwrap(); let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap()); - // The new canvas is 80×100, so the centre is (40, 50). - assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + assert!((x - 39.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); } #[test] @@ -406,15 +405,46 @@ mod tests { } } + #[test] + fn an_arbitrary_rotation_keeps_points_on_their_pixel() { + // The pixel at (3, 3) of the 100×80 document, and where each turn moves it. + for (angle, direction, at) in [(90, "cw", [76.0, 3.0]), (90, "ccw", [3.0, 96.0]), (180, "cw", [96.0, 76.0])] { + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 0, "y": 0})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": angle, "direction": direction})).unwrap(); + assert_eq!(s.take_samplers_removed(), 0, "{angle} {direction}: the corner point stays on the canvas"); + let p = points(&mut s); + assert_eq!(p.len(), 2, "{angle} {direction}: the corner point stays on the canvas"); + assert_eq!(p[0]["position"], json!(at), "{angle} {direction}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{angle} {direction}"); + } + } + #[test] fn a_rotated_crop_moves_points() { - // The crop frame is centred on the point, so the point lands at the new canvas centre. + // The crop frame is centred on the point's pixel; turned 90° that pixel lands at (10, 9). let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(50, 40, 51, 41), &black); + Ok(()) + }) + .unwrap(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap(); let p = points(&mut s); assert_eq!(p.len(), 1); - assert_eq!(p[0]["position"], json!([10.0, 10.0])); + assert_eq!(p[0]["position"], json!([10.0, 9.0])); + assert_eq!(p[0]["rgb"], json!([0, 0, 0])); } #[test] From 4b2bee675eb2741c7d4505e6baf941930f8c409a Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 20:09:39 +0530 Subject: [PATCH 0633/1452] Spot Healing Brush trail is Photoshop's dark half-opaque tint (#7342) (#7374) The stroke preview was white at a quarter opacity, invisible on white and faint elsewhere. Photoshop shows the area to heal in half-opaque dark grey; the trail now does too. Other retouch trails are unchanged. Test: drag_preview_canvas::the_spot_healing_trail_is_a_dark_half_opaque_tint drags the real canvas offscreen on white and mid-grey and checks the trail darkens the pixels to 40-60 %; it fails with the old colour. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/tests/drag_preview_canvas.rs | 24 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91644b5f73..c2494a08e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4968,6 +4968,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View Tool::QuickSelection => Color32::from_white_alpha(40), // The Remove Tool tints what it will remove, like Photoshop's. Tool::Remove => crate::theme::Tokens::get(painter.ctx()).danger.gamma_multiply(0.45), + // Photoshop paints the area to heal in half-opaque dark grey; a faint white trail + // barely showed on light pixels (#7342). + Tool::SpotHealing => Color32::from_black_alpha(128), _ => Color32::from_white_alpha(60), }; let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index fc516ed307..9b7d682abb 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -295,3 +295,27 @@ fn a_held_marquee_drag_keeps_requesting_frames() { assert!(d.is_some_and(|d| d <= Duration::from_millis(100)), "mid-drag repaint request: {d:?}"); button(&mut h, 300.0, 220.0, false); } + +#[test] +fn the_spot_healing_trail_is_a_dark_half_opaque_tint() { + // #7342: the trail was white at a quarter opacity, invisible on white and faint elsewhere. + // Photoshop shows the area to heal in half-opaque dark grey. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = harness(1.0) else { return }; + h.run_steps(4); + for bg in ["white", "#808080"] { + setup(&mut h, bg, "spotHealing"); + let app = h.state_mut(); + app.session.tools.brush.size = 40.0; + app.session.tools.brush.hardness = 1.0; + let before = render(&mut h); + drag_to(&mut h, [80.0, 150.0], [320.0, 150.0]); + let img = render(&mut h); + let p = screen(&h, 200.0, 150.0); + let (was, now) = (before.luma(p.x as i64, p.y as i64).unwrap_or(0), img.luma(p.x as i64, p.y as i64).unwrap_or(0)); + assert!(f32::from(now) < f32::from(was) * 0.6, "spot healing trail on {bg}: luma {was} -> {now}, not dark enough"); + assert!(f32::from(now) > f32::from(was) * 0.4, "spot healing trail on {bg}: luma {was} -> {now}, should stay see-through"); + button(&mut h, 320.0, 150.0, false); + h.run_steps(2); + } +} From 3f5c65e166f0b3c59f959c50f18a83d497f50122 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:48 +0300 Subject: [PATCH 0634/1452] Workspace layouts no longer save or restore the Layers panel options The options persist on their own in the preferences (persist_options and restore_options, as Photoshop's Panel Options do), and engine commands read them from there. Restoring a workspace layout that carried the key put the UI out of step with the preferences, so the panel and the commands disagreed. Drop layersPanelOptions from the layout snapshot and from apply; layouts saved by earlier builds keep the key, which is now ignored. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 5 +---- crates/ui-egui/src/dock_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7bcf31063a..3945ef728a 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -809,7 +809,7 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) /// What `prefs.panelLayout` holds: the live layout and open panels. fn snapshot(app: &PhotocraftApp) -> Value { - json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "layersPanelOptions": app.ui.layers_panel_options, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed}) + json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed}) } /// Remember the layout in the preferences once the user lets go of the mouse (Workspace › @@ -861,9 +861,6 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) { app.ui.dock = d; } - if let Some(opts) = v.get("layersPanelOptions").and_then(|o| serde_json::from_value(o.clone()).ok()) { - app.ui.layers_panel_options = opts; - } crate::panel_docking::sync_visibility(app); app.ui.timeline.open = v.get("timelineOpen").and_then(Value::as_bool).unwrap_or(false); if !app.ui.timeline.open { diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index f0a64562c4..f8b325ed92 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -1148,3 +1148,24 @@ fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() { apply(&mut workspace, &saved); assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout"); } + +/// Layers panel options live in the preferences on their own (like Photoshop's Panel Options), +/// so a workspace layout neither saves nor restores them. +#[test] +fn workspace_layouts_leave_the_layers_panel_options_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.layers_panel_options.show_filters = false; + crate::layers_panel_ui::persist_options(&mut app); + let stored = app.session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + assert!(stored.is_some(), "the options are saved in the preferences"); + + let mut saved = snapshot(&app); + assert!(saved.get("layersPanelOptions").is_none(), "a layout does not carry the options"); + + // A layout saved by an earlier build still has the key; it must not win over the preferences. + saved["layersPanelOptions"] = json!({"showFilters": true, "thumbnailSize": "large"}); + apply(&mut app, &saved); + assert!(!app.ui.layers_panel_options.show_filters); + assert_ne!(app.ui.layers_panel_options.thumbnail_size, crate::state::LayerThumbnailSize::Large); + assert_eq!(app.session.prefs().dialogs.get("ui.layersPanelOptions").cloned(), stored); +} From 62dbd6ced770d4bfd217c0e2ed85a1d2c794005a Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:48 +0300 Subject: [PATCH 0635/1452] Layers panel: thumbnails off no longer leave mask thumbnails that catch drags starts_mask_drag reads the mask thumbnail rects recorded in egui memory on an earlier frame. With the thumbnail size set to None no mask thumbnail is drawn and nothing is recorded, so rects from when thumbnails were on stayed behind and a drag of the row that started there was classified as a mask drag. Forget the layer's recorded rects before the drag is classified when thumbnails are off. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/mask_thumbs_ui.rs | 6 +++++ crates/ui-egui/src/mask_thumbs_ui/tests.rs | 30 ++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 4 +++ 3 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 1a8ff55d75..225cd47e8f 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -181,6 +181,12 @@ pub fn recorded(ctx: &egui::Context, id: u64) -> Option { ctx.data(|d| d.get_temp::>(rects_id())).and_then(|m| m.get(&id).cloned()) } +/// Drop what was recorded for layer `id`, so a row drawn without mask thumbnails (thumbnail size +/// None) can't be hit through rects from an earlier frame. +pub fn forget(ctx: &egui::Context, id: u64) { + ctx.data_mut(|d| d.get_temp_mut_or_default::>(rects_id()).remove(&id)); +} + /// A small vertical chain: two interlocking links. fn paint_chain(painter: &Painter, c: Pos2, color: Color32) { let st = Stroke::new(1.2, color); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 163c7cbae8..369e7468f5 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -214,6 +214,36 @@ fn dragging_a_layer_row_to_trash_still_deletes_the_layer_with_a_mask_targeted() assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none()); } +/// With the thumbnail size set to None no mask thumbnail is drawn, so a drag from where one used +/// to be moves the layer (the row) rather than a mask that is no longer on screen. +#[test] +fn thumbnails_off_forget_the_mask_rects_and_drag_the_layer() { + let (s, masked, _) = session(); + let mut h = harness(s, 0, 1.0, 290.0); + let stale = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1; + click_with(&mut h, stale.center(), Modifiers::NONE); + h.state_mut().ui.layers_panel_options.thumbnail_size = crate::state::LayerThumbnailSize::None; + h.run_steps(3); + assert!(recorded(&h.ctx, masked).is_none(), "no mask thumbnails are recorded while they are off"); + + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == masked).unwrap().row; + let from = pos2(stale.center().x, row.center().y); + assert!(row.contains(from), "the old thumbnail position is still on the row"); + let to = h.get_by_label("Delete Layer Mask").rect().center(); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(!h.state().ui.dialogs.iter().any(|d| crate::mask_trash_prompt::owns(&d.fields)), "no mask prompt: the mask was not dragged"); + assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none(), "the layer went to the trash"); +} + /// #3113: dragging the layer mask's thumbnail onto the Trash asks "Apply mask to layer before /// removing?" and keeps the layer; Apply applies the mask, Delete deletes it. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d4e495ae14..875014273b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2954,6 +2954,10 @@ fn layer_row( // The Layers Panel Options thumbnail size controls rows and both layer/mask thumbnails. let (base, row_h) = crate::layers_panel_ui::thumbnail_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); + // With thumbnails off no mask thumbnail is drawn, so none may keep catching drags from older frames. + if base == 0.0 { + crate::mask_thumbs_ui::forget(ctx, l.id.0); + } // A drag from a mask's thumbnail drags that mask, not the layer (#3113, #3119). let mask_drag = starts_mask_drag(ctx, l, &resp); if let Some(kind) = mask_drag { From 792413b5281ca157199ca182e86290fc29de83b9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:49 +0300 Subject: [PATCH 0636/1452] Expand New Effects follows the layer a Layer Style command changes The disclosure target only read a numeric "layer" param, so a layer named by its name (which every command accepts through explicit_layer) fell back to the active layer and the wrong layer's effects were expanded or collapsed. Resolve the target with commands::layer_param, which understands every explicit form and still falls back to the active layer. layer.layerStyle.clear needs no case: it leaves the layer without effects, which the existing post-run check already excludes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 5 ++++- crates/engine/src/layer_multi_cmds.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 00b83682b7..5566d39c3f 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -725,9 +725,12 @@ impl Session { let run_params = self.admit(id, spec, ¶ms)?; // This UI-only disclosure preference applies to both `execute` and `start`: // desktop UI normally calls `start`, even for synchronous Layer Style commands. + // The layer the command will change, named as the command accepts it (id or name), else the + // active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands. let new_fx_target = if id.starts_with("layer.layerStyle.") { + let target = crate::commands::layer_param(self, ¶ms).ok(); self.active().and_then(|st| { - let target = params.get("layer").and_then(Value::as_u64).map(LayerId).or(st.active_layer)?; + let target = target?; (st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target)) }) } else { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1ab67610d3..a87c4badda 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2987,4 +2987,18 @@ mod layers_panel_parity_tests { s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap(); assert!(s.active().unwrap().fx_collapsed.contains(&target)); } + + /// The disclosure follows the layer the command changes, however the command names it: a + /// layer given by name is not the active one. + #[test] + fn expand_new_effects_follows_a_layer_named_by_its_name() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let (named, active) = (ids[0], ids[2]); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + s.execute("layer.layerStyle.dropShadow", json!({"layer": "Test 0", "size": 2})).unwrap(); + assert!(s.active().unwrap().doc.layer(named).is_some_and(|l| !l.effects.items.is_empty())); + assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed"); + assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed"); + } } From 7a671f1486bc24bc8938207b4ba7cb1ae4a61fd5 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:41:58 +0300 Subject: [PATCH 0637/1452] Layers panel options: document the functions and tests the PR adds CodeRabbit measured the docstring coverage of the PR at 79.41%, under its 80% threshold. Add short doc comments saying what each helper does and which Photoshop behaviour each test pins. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/artboard_cmds/tests.rs | 1 + crates/engine/src/layer_multi_cmds.rs | 11 +++++++++++ crates/ui-egui/src/layers_panel_ui.rs | 10 ++++++++++ crates/ui-egui/src/state.rs | 2 ++ 4 files changed, 24 insertions(+) diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index f225f1244c..28e8d0587c 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -586,6 +586,7 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() { assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err()); } +/// Adding a duplicated artboard beside another appends "copy" to its name only while the Layers panel option is on. #[test] fn add_beside_duplicate_honors_panel_copy_name_preference() { for add_copy in [false, true] { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index a87c4badda..2081db277c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2787,6 +2787,7 @@ mod tests { mod layers_panel_parity_tests { use super::*; + /// A 24×16 document with three layers, "Test 0" to "Test 2", above the Background; the last one is active. fn new_session() -> (Session, Vec) { let mut s = Session::new(); s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); @@ -2794,6 +2795,7 @@ mod layers_panel_parity_tests { (s, ids) } + /// Set one Layers panel option in the preferences, as the Panel Options dialog does. fn option(s: &mut Session, key: &str, enabled: bool) { s.prefs.edit(|p| { let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({})); @@ -2803,6 +2805,7 @@ mod layers_panel_parity_tests { }); } + /// The drag preview equals the committed `layer.moveTo` result and never touches the document or its history. #[test] fn preview_matches_real_move_and_never_edits_document_or_history() { let (mut s, ids) = new_session(); @@ -2816,6 +2819,7 @@ mod layers_panel_parity_tests { assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order"); } + /// A previewed ⌥-drag copy names its layers like the committed copy, with and without the "copy" suffix option. #[test] fn preview_copy_and_no_copy_suffix_option_match_committed_layers() { let (mut s, ids) = new_session(); @@ -2836,6 +2840,7 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy")); } + /// Drops the command refuses give no preview and leave the document alone; a multi-layer preview follows the committed stack order. #[test] fn preview_rejects_invalid_drop_unchanged() { let (mut s, ids) = new_session(); @@ -2882,6 +2887,7 @@ mod layers_panel_parity_tests { doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::>().join(" ") } + /// The id of the layer called `name`, searching the whole layer tree. fn id_of(s: &Session, name: &str) -> u64 { s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0 } @@ -2898,6 +2904,7 @@ mod layers_panel_parity_tests { assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}"); } + /// Previewed moves join and release clipping groups exactly as the committed reorder does, copies included. #[test] fn preview_follows_clip_groups_like_the_committed_reorder() { // The fixtures of `clip_group::reorder_joins_and_leaves_groups`. @@ -2924,6 +2931,7 @@ mod layers_panel_parity_tests { assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U"); } + /// Copying the locked Background in a preview gives the same unlocked copy, joining the clip group it lands in, as the command. #[test] fn preview_copies_the_background_like_the_committed_copy() { let mut s = clip_stack("A B*"); @@ -2944,6 +2952,7 @@ mod layers_panel_parity_tests { assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px); } + /// A drop that would move the Background off the bottom has no preview, as the command refuses it. #[test] fn preview_refuses_what_would_leave_the_background_off_the_bottom() { let mut s = clip_stack("A B"); @@ -2960,6 +2969,7 @@ mod layers_panel_parity_tests { } } + /// The UI runs commands through `start`; Expand New Effects applies there as well as to `execute`. #[test] fn expand_new_effects_applies_to_ui_start_dispatch_too() { let (mut s, ids) = new_session(); @@ -2970,6 +2980,7 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&target)); } + /// Whether new effects start expanded is view state: it adds no history step, and editing an existing effect keeps its disclosure. #[test] fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { let (mut s, ids) = new_session(); diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index ef6bbccf7a..43955812dd 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -67,6 +67,8 @@ pub(crate) struct ReorderPreview { pub document: std::sync::Arc, } +/// Cache the on-canvas preview of the layer drop in `payload` (a `layer.moveTo` request), unless Preview on Canvas is off. +/// Marks the destination as hovered every frame; the preview itself is rebuilt only when the target changes. pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Value) { if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { app.reorder_preview = None; @@ -96,6 +98,7 @@ pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Val }); } +/// The cached reorder preview and its compositor cache key for document `index`, if it was built for that document's current revision. pub(crate) fn display_reorder(app: &PhotocraftApp, index: usize) -> Option<(std::sync::Arc, u64)> { if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { return None; @@ -244,6 +247,7 @@ fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { ui.checkbox(&mut draft.show_layer_mask_badges, tl!("Show layer mask badges")); } +/// Show the pending Layers panel dialog (Panel Options, New Layer, New Group, Lock Layers) as a modal and apply it on OK. pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { let Some(mut dialog) = app.ui.layers_panel_dialog.take() else { return }; let mut confirmed = false; @@ -335,6 +339,7 @@ pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { mod tests { use super::*; + /// A fresh install shows Medium thumbnails of the whole document, like Photoshop's Panel Options defaults. #[test] fn default_panel_options_match_medium_document_previews() { let options = LayersPanelOptions::default(); @@ -366,6 +371,7 @@ mod tests { assert!(!app.session.prefs().interface.clip_thumbnails_to_layer_bounds); } + /// The options survive a UiState round trip; an open dialog is view state and is never serialized. #[test] fn options_round_trip_without_a_pending_dialog() { let mut ui = crate::state::UiState::default(); @@ -379,6 +385,7 @@ mod tests { assert!(restored.layers_panel_dialog.is_none()); } + /// Every option, including the mask badges, goes to the preferences and comes back from them. #[test] fn prefs_options_save_and_restore_all_switches() { let mut session = photocraft_engine::Session::new(); @@ -401,6 +408,7 @@ mod tests { assert_eq!(app.ui.layers_panel_options, saved); } + /// The flyout's Flatten Image runs the real `layer.flattenImage` command, and Undo brings the layers back. #[test] fn flyout_flatten_routes_to_real_engine_and_undo_restores_layers() { let mut session = photocraft_engine::Session::new(); @@ -420,6 +428,7 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.layers.len(), count); } + /// Thumbnail and row sizes grow from None to Large in both themes, with the rows at least as tall as the thumbnails. #[test] fn geometry_matches_four_real_panel_sizes() { for pro in [false, true] { @@ -434,6 +443,7 @@ mod tests { assert_eq!(thumbnail_geometry(LayerThumbnailSize::Large, true), (48.0, 56.0)); } + /// Preferences saved before an option existed still load, with the default for the missing ones. #[test] fn panel_options_missing_fields_deserialize_with_defaults() { let options: LayersPanelOptions = serde_json::from_value(json!({})).unwrap(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0922e800fa..7c227e186d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -39,6 +39,7 @@ pub struct LayersPanelOptions { } impl LayersPanelOptions { + /// Every option at the Photoshop default: Medium thumbnails, filters shown, new effects expanded, no mask badges. pub fn defaults() -> Self { Self { thumbnail_size: LayerThumbnailSize::Medium, @@ -53,6 +54,7 @@ impl LayersPanelOptions { } impl Default for LayersPanelOptions { + /// Same as [`LayersPanelOptions::defaults`], so serde fills missing fields with the Photoshop defaults. fn default() -> Self { Self::defaults() } From f0ef89baf6c6e6ccd0939cb16631c76e33edba05 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:12:45 +0300 Subject: [PATCH 0638/1452] fix(caf): an erased sampling area previews as an error, and a failed OK keeps the workspace open (#7384) --- crates/ui-egui/src/caf_ui.rs | 46 ++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs index 747e9d4bcf..3352e6c397 100644 --- a/crates/ui-egui/src/caf_ui.rs +++ b/crates/ui-egui/src/caf_ui.rs @@ -307,6 +307,10 @@ pub struct CafWorkspace { pub error: Option, } +/// The preview's error when the sampling area has no pixels outside the fill area (the engine's +/// fill refuses that case with the same words). +const NOTHING_TO_SAMPLE: &str = "nothing to sample from: the sampling area has no pixels outside the fill area"; + /// The plan of the current state (without strokes when `strokes` is false). fn plan_params(w: &CafWorkspace, strokes: bool) -> Value { let o = &w.opts; @@ -623,14 +627,23 @@ pub fn apply(app: &mut PhotocraftApp) -> Result { r } -/// OK: fill and close. +/// OK: fill and close. A fill that fails (nothing to sample from, a fill area too large, …) +/// leaves the workspace open with the error in its panel, so the areas can be fixed and OK +/// pressed again instead of the work being thrown away. pub fn commit(app: &mut PhotocraftApp) { - let ok = apply(app); - if let Err(e) = ok { - app.ui.status = e; - app.ui.status_error = true; + match apply(app) { + Ok(_) => close(app), + Err(e) => report(app, e), } - close(app); +} + +/// An Apply or OK that failed: the error in the panel and the status bar. +fn report(app: &mut PhotocraftApp, e: String) { + if let Some(w) = app.distort.caf.as_mut() { + w.error = Some(e.clone()); + } + app.ui.status = e; + app.ui.status_error = true; } /// Cancel (button, Esc): closes; what was applied stays. The settings are kept. @@ -678,6 +691,9 @@ fn undo(app: &mut PhotocraftApp) { w.redo.push(step); w.restate_all(); w.stale(); + // The sampling area changed, as with a new stroke: an error a failed OK or Apply left + // in the panel no longer applies (the other steps rebase, which clears it too). + w.error = None; false } Step::Fill(_) => { @@ -710,6 +726,7 @@ fn redo(app: &mut PhotocraftApp) { w.undo.push(step); w.restate_all(); w.stale(); + w.error = None; false } Step::Fill(e) => { @@ -895,10 +912,14 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if flag("waitPreview") { wait_preview(app)?; } - // OK last, with the settings given alongside. + // OK last, with the settings given alongside. A failed fill keeps the workspace open, as + // the OK button does. if flag("commit") { let r = apply(app); - close(app); + match &r { + Ok(_) => close(app), + Err(e) => report(app, e.clone()), + } return r.map(|v| json!({"committed": true, "result": v})); } Ok(app.distort.caf.as_ref().map(CafWorkspace::describe).unwrap_or(Value::Null)) @@ -932,6 +953,12 @@ fn compute_preview( photocraft_engine::EngineError::Cancelled => "cancelled".to_string(), e => e.to_string(), })?; + // Patches come only from the sampling area outside the fill area. With none (the Sampling + // Brush erased it all, a Custom area not painted yet) the fill would come out black, so the + // preview says so instead of showing that. + if !plan.source.iter().any(|s| *s) { + return Err(NOTHING_TO_SAMPLE.into()); + } let (w, h) = (plan.rect.width() as usize, plan.rect.height() as usize); let n = plan.fmt.channels(); let filled = @@ -1566,8 +1593,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some("cancel") => cancel(app), Some("apply") => { if let Err(e) = apply(app) { - app.ui.status = e; - app.ui.status_error = true; + report(app, e); } } _ => {} From ccdf082c62da26ffea52cbea51e03ebc3195c877 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:12:46 +0300 Subject: [PATCH 0639/1452] test(caf): erased sampling area previews as an error; a failed OK keeps the workspace open (#7384) --- crates/ui-egui/src/caf_ui.rs | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs index 3352e6c397..149474b260 100644 --- a/crates/ui-egui/src/caf_ui.rs +++ b/crates/ui-egui/src/caf_ui.rs @@ -1704,6 +1704,65 @@ mod tests { assert_eq!(d["fillArea"], json!(400)); } + /// A sampling area the Sampling Brush erased entirely previews as an error naming the + /// problem, not as the black fill it would make; undoing the stroke brings the preview back. + #[test] + fn an_erased_sampling_area_previews_as_an_error() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular", "waitPreview": true})).unwrap(); + assert!(ws(&app).describe()["previewError"].is_null()); + // Subtracting (the default) with a brush wider than the 200 × 160 window. + let e = control(&mut app, &json!({"stroke": {"points": [[100.0, 80.0]], "size": 600}, "waitPreview": true})).unwrap_err(); + assert!(e.contains("nothing to sample from"), "{e}"); + let d = ws(&app).describe(); + assert_eq!(d["sampledProxyPixels"].as_u64(), Some(0), "{d}"); + assert!(d["previewError"].as_str().is_some_and(|e| e.contains("nothing to sample from")), "{d}"); + let d = control(&mut app, &json!({"undo": true, "waitPreview": true})).unwrap(); + assert!(d["previewError"].is_null(), "{d}"); + } + + /// OK whose fill fails keeps the workspace open with the error in its panel, from the button + /// and from the control channel, so the work isn't thrown away; once the fill can run, OK + /// fills and closes. + #[test] + fn a_failed_ok_keeps_the_workspace_open() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.session.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap(); + commit(&mut app); + assert!(app.distort.caf.is_some(), "OK keeps the workspace open when the fill fails"); + assert!(ws(&app).error.as_deref().is_some_and(|e| e.contains("locked")), "{:?}", ws(&app).error); + assert!(app.ui.status_error); + assert!(control(&mut app, &json!({"commit": true})).unwrap_err().contains("locked")); + assert!(app.distort.caf.is_some(), "the control channel's OK too"); + app.session.execute("layer.setProps", json!({"locks": {"pixels": false}})).unwrap(); + let steps = app.session.active().unwrap().history.past_len().max(steps); + commit(&mut app); + assert!(app.distort.caf.is_none(), "OK closes once the fill runs"); + assert!(app.session.active().unwrap().history.past_len() > steps, "and fills"); + } + + /// Undo or Redo of a sampling stroke changes what OK would fill, so it clears the error a + /// failed OK left in the panel, as a new stroke does; otherwise the stale error would stay + /// drawn over the recovered preview. + #[test] + fn undo_and_redo_of_a_stroke_clear_a_failed_oks_error() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"stroke": {"points": [[20.0, 20.0], [60.0, 20.0]], "size": 10}})).unwrap(); + app.session.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap(); + commit(&mut app); + assert!(ws(&app).error.is_some(), "the failed OK reports in the panel"); + control(&mut app, &json!({"undo": true})).unwrap(); + assert!(ws(&app).error.is_none(), "Undo of the stroke clears it"); + commit(&mut app); + assert!(ws(&app).error.is_some()); + control(&mut app, &json!({"redo": true})).unwrap(); + assert!(ws(&app).error.is_none(), "Redo of the stroke clears it too"); + } + #[test] fn apply_fills_and_stays_open_ok_closes_cancel_keeps_what_was_applied() { let mut app = app_with_selection(); From f7942efd1d6869a9c788799108a2d348201bc61a Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:49:38 +0300 Subject: [PATCH 0640/1452] Smart Filter editor starts missing parameters from the spec defaults open_editor opened the filter's dialog and copied the recorded parameters over its fields. filter_dialog::open starts from the user's last-used values, so a recorded filter that lacked a key (an older file, or a command that gained a parameter) showed, and on OK saved, the remembered value instead of the default. Reset the freshly opened filter dialog to the schema defaults, as Alt+Cancel does, before the recorded parameters are copied in. Adjustment editors are unchanged, and a plain Filter menu dialog still remembers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/smart_ui.rs | 12 +++++++--- crates/ui-egui/src/smart_ui/editor_tests.rs | 26 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 6ce5f9c7fe..2cd61a3000 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -356,9 +356,15 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: return; } }; - let Some(id) = crate::adjust_dialog::open(app, &f.command) - .or_else(|| crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()) - else { + let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; + // The filter dialog opens on the user's last-used values; a recorded filter that lacks a + // key must show (and save) the schema default instead. + let mut fields = app.ui.dialog_mut(id)?.fields.clone(); + crate::filter_dialog::reset_fields(app, &mut fields); + app.ui.dialog_mut(id)?.fields = fields; + Some(id) + }) else { return; }; if let Some(d) = app.ui.dialog_mut(id) { diff --git a/crates/ui-egui/src/smart_ui/editor_tests.rs b/crates/ui-egui/src/smart_ui/editor_tests.rs index c007b2ca6b..c5814b8d17 100644 --- a/crates/ui-egui/src/smart_ui/editor_tests.rs +++ b/crates/ui-egui/src/smart_ui/editor_tests.rs @@ -152,3 +152,29 @@ fn ctrl_i_uses_existing_invert_command_and_undo_redo_keep_the_source() { h.state_mut().run("edit.redo", json!({})).unwrap(); assert_eq!(smart(h.state(), layer).smart_filters.len(), 3); } + +/// A recorded filter that lacks a parameter reopens with the schema default for it, not with the +/// value the user last used in that filter's dialog; the parameters it does hold still win. +#[test] +fn filter_editor_starts_missing_parameters_from_defaults_not_remembered_values() { + let (mut app, layer) = app(8, "rgb"); + let command = "filter.sharpen.smartSharpen"; + app.session.prefs.edit(|p| { + p.dialogs.insert(command.into(), json!({"reduceNoise": 60, "radius": 9})); + }); + let ctx = egui::Context::default(); + let mut sm = smart(&app, layer).clone(); + let index = sm.smart_filters.iter().position(|f| f.command == command).unwrap(); + sm.smart_filters[index].params = json!({"amount": 125, "radius": 2}); + + super::open_editor(&mut app, &ctx, layer.0, &sm, index); + let fields = &app.ui.dialogs.last().unwrap().fields; + assert!(fields.contains_key("__smartFilter"), "it edits the recorded filter"); + assert_eq!(fields["reduceNoise"].as_f64(), Some(10.0), "the spec default, not the remembered 60"); + assert_eq!(fields["amount"].as_f64(), Some(125.0)); + assert_eq!(fields["radius"].as_f64(), Some(2.0), "recorded values beat the remembered 9"); + + // A plain Filter menu dialog still starts from the remembered values. + let id = crate::filter_dialog::open(&mut app, command).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["reduceNoise"].as_f64(), Some(60.0)); +} From 89fa322a3c6f65373fe49944f39c238d9413e143 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:50:27 +0200 Subject: [PATCH 0641/1452] Crop: Esc and Enter go to an open options bar popup first (#7386) (#7388) The shortcut pass runs before the panels are drawn, and its crop/polygon branch consumed Esc and Enter even while an egui popup of the options bar was open (the Ratio list, the overlay and gear menus): Esc cancelled the crop and left the list open, Enter committed the crop beneath it. Skip the branch while `egui::Popup::is_any_open` (kept in memory from the last frame; `ctx.any_popup_open` only sees the current pass). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/shortcuts.rs | 5 +- crates/ui-egui/tests/crop_popup_keys.rs | 92 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/crop_popup_keys.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bfa1904c4f..1ad8d6d92a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -618,7 +618,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - if !app.ui.polygon.is_empty() || app.ui.crop_rect.is_some() { + // An open options bar popup (the Crop Ratio list, the overlay and gear menus) takes the keys + // first: Esc closes it, and neither key reaches the crop beneath (#7386). Its id is kept in + // memory from the last frame; `ctx.any_popup_open` only sees this frame's, drawn later. + if (!app.ui.polygon.is_empty() || app.ui.crop_rect.is_some()) && !egui::Popup::is_any_open(ctx) { if pressed(Key::Enter) { if !app.ui.polygon.is_empty() { crate::canvas::commit_polygon(app); diff --git a/crates/ui-egui/tests/crop_popup_keys.rs b/crates/ui-egui/tests/crop_popup_keys.rs new file mode 100644 index 0000000000..9c893ae7e0 --- /dev/null +++ b/crates/ui-egui/tests/crop_popup_keys.rs @@ -0,0 +1,92 @@ +//! Crop tool: Esc and Enter belong to an open options bar popup first (#7386). The shortcut pass +//! runs before the panels are drawn, so the crop branch used to take the key: Esc cancelled the +//! frame and left the Ratio list open, Enter committed the crop beneath it. +//! +//! Skips when no GPU adapter exists (like `crop_default_mode.rs`). + +use egui::Key; +use egui_kittest::kittest::Queryable; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + // The queries below use English labels; don't follow the system locale. + app.session.prefs.edit(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 200x100 document with the Crop tool's 1:1 frame pending and the Ratio list open again. +fn setup() -> Option { + let mut h = harness()?; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).expect("new"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50]})); + h.get_by_value("Ratio").click(); + h.run_steps(3); + h.get_by_label("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the 1:1 frame is pending"); + assert!(!egui::Popup::is_any_open(&h.ctx), "the list closed after the pick"); + h.get_by_value("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "the Ratio list is open again"); + Some(h) +} + +#[test] +fn esc_closes_the_open_ratio_list_and_keeps_the_frame() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + let frame = h.state().ui.crop_rect; + h.key_press(Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx), "Esc closes the list"); + assert_eq!(h.state().ui.crop_rect, frame, "the frame stays as it was"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "still pending"); + // With the list closed, the keys reach the crop again: Enter commits it. + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(100, 100), "the next Enter crops"); +} + +#[test] +fn enter_with_the_ratio_list_open_does_not_commit_the_crop() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 100), "nothing cropped"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the frame is still pending"); +} From 79793acefe6002b4a0d6dd512725f2612bb85438 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:50:28 +0300 Subject: [PATCH 0642/1452] Smart Filters: document the functions and tests the PR adds CodeRabbit measured the docstring coverage of the PR at 59.49%, under its 80% threshold. Add short doc comments saying what each helper does and which behaviour each test pins. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fx_view_cmds.rs | 2 ++ crates/engine/src/smart_cmds/tests.rs | 2 ++ crates/engine/tests/photoshop_oracles.rs | 1 + crates/ui-egui/src/filter_dialog.rs | 2 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/smart_filter_dialog.rs | 10 ++++++++++ crates/ui-egui/src/smart_filter_dialog/tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/smart_ui.rs | 1 + crates/ui-egui/src/smart_ui/editor_tests.rs | 7 +++++++ 9 files changed, 42 insertions(+) diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 0d68230ba5..76bbc781ee 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -19,6 +19,7 @@ fn has_effects(s: &Session) -> std::result::Result<(), String> { if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) } } +/// Whether the layer has a list to disclose: effects, or Smart Filters on a Smart Object. fn has_children(l: &Layer) -> bool { !l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) } @@ -96,6 +97,7 @@ mod tests { !s.active().unwrap().fx_collapsed.contains(&LayerId(id)) } + /// Smart Filters hide and show with the layer's effects, as view state: no document edit, revision or history step. #[test] fn smart_filters_share_the_effects_disclosure_without_an_edit() { let (mut s, a, b) = session(); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index b7a282698d..3666a4818c 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -7,6 +7,7 @@ const DEPTHS: [u64; 3] = [8, 16, 32]; const W: i32 = 64; const H: i32 = 48; +/// A partial filter strength keeps the source alpha and interpolates the filtered coverage, at every sample depth. #[test] fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { for sample in photocraft_color::SampleType::ALL { @@ -42,6 +43,7 @@ fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { } } +/// Blending a filter result in Screen, Multiply or Luminosity keeps source-over coverage where the filter changed alpha. #[test] fn non_normal_coverage_changes_retain_source_over_semantics() { for sample in photocraft_color::SampleType::ALL { diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/photoshop_oracles.rs index e07b451efc..1b3e37d3e9 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/photoshop_oracles.rs @@ -80,6 +80,7 @@ fn rerender(doc: &mut Document) -> Result { Ok(n) } +/// Re-rendering Photoshop's Screen/Motion Blur oracle matches its merged composite, so the result retains the input's coverage. #[test] fn screen_motion_blur_retains_input_coverage() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-blend-screen-100.psd"); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 4ba1774b52..74d8bd9de6 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -953,6 +953,7 @@ pub(crate) fn preview_document_for_target( s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } +/// Whether `command` is a Smart Filter edit (its parameters or its blending) rather than a filter run on its own. fn is_smart_edit(command: &str) -> bool { matches!(command, "layer.smartFilter.setParams" | "layer.smartFilter.blendingOptions") } @@ -975,6 +976,7 @@ pub(crate) fn smart_preview_factor(doc: &Document, command: &str, params: &Value factor } +/// Scale the pixel-sized parameters of `command` down by the preview proxy factor `k`, keeping each above its minimum and negative lengths negative. fn scale_preview_params(command: &str, params: &mut Value, k: u32) { if k > 1 && let Some(o) = params.as_object_mut() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f0e11f7ce..584d0928df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2334,6 +2334,7 @@ impl PhotocraftApp { self.mask_thumb_kind(ctx, doc, id, mask, mask_thumbs_ui::THUMB_MASK) } + /// The thumbnail of `mask` under cache `kind`, re-rendered only when the mask's pixels or the document width change; Smart Filter and layer masks are cached apart. pub(crate) fn mask_thumb_kind( &mut self, ctx: &egui::Context, diff --git a/crates/ui-egui/src/smart_filter_dialog.rs b/crates/ui-egui/src/smart_filter_dialog.rs index d510ed74c5..cf184c5671 100644 --- a/crates/ui-egui/src/smart_filter_dialog.rs +++ b/crates/ui-egui/src/smart_filter_dialog.rs @@ -20,6 +20,7 @@ pub(crate) struct Targets { } impl Targets { + /// Remember a weak handle to `doc` and return the opaque token that stands for it in a dialog's target. fn capture(&mut self, doc: &Arc) -> Result { self.next = self.next.checked_add(1).ok_or("Smart Filter target counter exhausted")?; self.snapshots.retain(|(_, doc)| doc.strong_count() > 0); @@ -31,11 +32,13 @@ impl Targets { Ok(self.next) } + /// Whether `token` was captured from this very document allocation, not just one with the same id. fn matches(&self, token: u64, doc: &Arc) -> bool { self.snapshots.iter().find(|(id, _)| *id == token).and_then(|(_, doc)| doc.upgrade()).is_some_and(|old| Arc::ptr_eq(&old, doc)) } } +/// Identify the Smart Filter at `layer`/`index` (document, revision, filter) so a stale dialog can be told apart from a current one. fn snapshot(app: &PhotocraftApp, layer: LayerId, index: usize) -> Result { let st = app.session.active().ok_or("no document open")?; if app.session.job_on(st.doc.id).is_some() { @@ -49,6 +52,7 @@ fn snapshot(app: &PhotocraftApp, layer: LayerId, index: usize) -> Result Result { let mut target = snapshot(app, layer, index)?; let doc = &app.session.active().ok_or("no document open")?.doc; @@ -76,6 +80,7 @@ pub(crate) fn validate_target(app: &PhotocraftApp, target: &Value) -> Result<(La Ok((layer, index)) } +/// Open the Blending Options dialog for one Smart Filter, starting from its recorded mode and opacity (shown as a percentage). pub fn open(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result { let target = self::target(app, layer, index)?; let st = app.session.active().ok_or("no document open")?; @@ -97,10 +102,12 @@ pub fn open(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result) -> bool { fields.get("__command").and_then(Value::as_str) == Some(COMMAND) && fields.contains_key(TARGET) } +/// The dialog title, naming the filter being edited. pub(crate) fn title(fields: &Map) -> String { crate::i18n::fmt(tl!("Blending Options ({filter})"), &[("filter", fields.get("__filterName").and_then(Value::as_str).unwrap_or_default())]) } @@ -116,6 +123,7 @@ fn blend_params(fields: &Map) -> Result { Ok(json!({"blend": blend, "opacity": opacity / 100.0})) } +/// The command a confirmed Smart Filter dialog runs on the validated target: its blending, or `layer.smartFilter.setParams` for a parameter editor. pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map) -> Result<(String, Value), String> { let (layer, index) = validate_target(app, fields.get(TARGET).ok_or("no Smart Filter target")?)?; if owns(fields) { @@ -135,6 +143,7 @@ fn mode_note(blend: Option<&str>) -> Option<&'static str> { (blend == Some(BlendMode::Dissolve.label())).then(|| tl!("Dissolve is approximated: it blends without Photoshop's dither.")) } +/// The Blending Options form: Mode (with the Dissolve note), Opacity, Preview and the result viewport. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { ui.horizontal(|ui| { ui.label(tl!("Mode")); @@ -168,6 +177,7 @@ struct ViewImage { texture: egui::TextureHandle, } +/// Show the document as the dialog will leave it (the live preview, or the unchanged document while Preview is off) as a fixed-size thumbnail. fn viewport(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map, preview: bool) { let Some(st) = app.session.active() else { return }; let doc = if preview { diff --git a/crates/ui-egui/src/smart_filter_dialog/tests.rs b/crates/ui-egui/src/smart_filter_dialog/tests.rs index 6e1ce5708e..b3fb099d60 100644 --- a/crates/ui-egui/src/smart_filter_dialog/tests.rs +++ b/crates/ui-egui/src/smart_filter_dialog/tests.rs @@ -1,6 +1,7 @@ use super::*; use photocraft_doc::{SmartFilter, SmartObject}; +/// A document reopened with the same id is a different allocation, so a dialog opened on the old one cannot edit it. #[test] fn a_same_id_reopened_document_rejects_the_previous_dialog() { let (mut app, layer) = app(8, "rgb"); @@ -18,6 +19,7 @@ fn a_same_id_reopened_document_rejects_the_previous_dialog() { assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); } +/// An 8×8 image still gets the full-height preview area instead of collapsing it. #[test] fn tiny_images_keep_a_usable_preview_viewport() { let (mut app, layer) = app(8, "rgb"); @@ -37,7 +39,9 @@ fn tiny_images_keep_a_usable_preview_viewport() { .clear(); } +/// Run `body` for one frame and return every string it painted. fn painted_text(app: &mut PhotocraftApp, fields: &mut Map) -> Vec { + /// Gather the text of `shape`, descending into nested shape groups. fn collect(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), @@ -55,6 +59,7 @@ fn painted_text(app: &mut PhotocraftApp, fields: &mut Map) -> Vec painted } +/// Of all the blend modes, only Dissolve shows the approximation note beside the Mode control. #[test] fn only_dissolve_shows_the_approximation_note_near_the_mode_control() { let note = "Dissolve is approximated: it blends without Photoshop's dither."; @@ -72,6 +77,7 @@ fn only_dissolve_shows_the_approximation_note_near_the_mode_control() { assert_eq!(mode_note(Some("Dissolve")), Some(note)); } +/// A document with one Smart Object holding Box Blur and Smart Sharpen, wrapped in an app that renders on the calling thread. pub(crate) fn app(depth: u32, mode: &str) -> (PhotocraftApp, LayerId) { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "mode": mode})).unwrap(); @@ -89,15 +95,18 @@ pub(crate) fn app(depth: u32, mode: &str) -> (PhotocraftApp, LayerId) { (app, layer) } +/// The Smart Object content of `layer` in the active document. pub(crate) fn smart(app: &PhotocraftApp, layer: LayerId) -> &SmartObject { let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { panic!("smart") }; sm } +/// A copy of the Smart Object's filters, for comparing before and after an edit. fn filters(app: &PhotocraftApp, layer: LayerId) -> Vec { smart(app, layer).smart_filters.clone() } +/// Blending edits one filter in a single history step, opacity is entered as a percentage, and undo/redo restore it. #[test] fn blending_edits_one_filter_with_percent_opacity_and_undo_redo() { for depth in [8, 16, 32] { @@ -142,6 +151,7 @@ fn blending_edits_one_filter_with_percent_opacity_and_undo_redo() { } } +/// The preview, switching it off and on, and Cancel never change the document or its history. #[test] fn preview_toggle_and_cancel_leave_document_and_history_untouched() { let (mut app, layer) = app(8, "rgb"); @@ -164,6 +174,7 @@ fn preview_toggle_and_cancel_leave_document_and_history_untouched() { assert_eq!(app.session.active().unwrap().history.past_len(), steps); } +/// A dialog whose document or filter has since changed, or been switched away from, refuses to confirm. #[test] fn stale_filter_dialogs_cannot_edit_a_switched_or_changed_target() { for command in ["file.new", "edit.undo", "layer.smartFilter.delete", "layer.smartFilter.move"] { @@ -181,6 +192,7 @@ fn stale_filter_dialogs_cannot_edit_a_switched_or_changed_target() { } } +/// A reduced-size preview scales the filters' pixel parameters and the shared mask, and still contains the same number of filters. #[test] fn reduced_preview_scales_placement_stack_and_mask_without_adding_filters() { let (mut app, layer) = app(16, "rgb"); @@ -206,6 +218,7 @@ fn reduced_preview_scales_placement_stack_and_mask_without_adding_filters() { assert!(Arc::ptr_eq(&original, &app.session.active().unwrap().doc)); } +/// An edited blend and an Invert recorded earlier survive a save and reload of the native format. #[test] fn native_save_preserves_edited_blending_and_existing_invert() { let (mut app, layer) = app(8, "rgb"); @@ -221,6 +234,7 @@ fn native_save_preserves_edited_blending_and_existing_invert() { assert_eq!(serde_json::to_value(&sm.smart_filters).unwrap(), serde_json::to_value(filters(&app, layer)).unwrap()); } +/// An opacity outside 0–100% or not a number fails the dialog and leaves the document alone. #[test] fn invalid_opacity_fails_without_an_edit() { let (mut app, layer) = app(8, "rgb"); @@ -233,6 +247,7 @@ fn invalid_opacity_fails_without_an_edit() { } } +/// Blending edits the filter the dialog opened on, whatever layer is selected and whether or not a layer mask is targeted. #[test] fn blending_does_not_follow_layer_selection_or_the_layer_mask_target() { let (mut app, layer) = app(8, "rgb"); @@ -255,6 +270,7 @@ fn blending_does_not_follow_layer_selection_or_the_layer_mask_target() { ); } +/// Blending an Invert smart filter keeps the translucent pixels' alpha at 8, 16 and 32 bits. #[test] fn invert_blending_keeps_transparency_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 2cd61a3000..1d8e6d68ce 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -83,6 +83,7 @@ pub fn paint_badge(painter: &egui::Painter, center: egui::Pos2, color: Color32) } } +/// Paint the Smart Filters header and one row per filter of Smart Object `l` (top filter first), with their drag, drop and click handling; actions go to `actions`. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let LayerContent::Smart(sm) = &l.content else { return }; if sm.smart_filters.is_empty() { diff --git a/crates/ui-egui/src/smart_ui/editor_tests.rs b/crates/ui-egui/src/smart_ui/editor_tests.rs index c5814b8d17..6e0b9d14f8 100644 --- a/crates/ui-egui/src/smart_ui/editor_tests.rs +++ b/crates/ui-egui/src/smart_ui/editor_tests.rs @@ -7,6 +7,7 @@ use crate::{ smart_filter_dialog::tests::{app, smart}, }; +/// The app with a recorded Smart Object, in a `width`-point dock and the given theme, ready to click. fn harness(width: f32, scale: f32, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { let (app, _) = app(8, "rgb"); let mut h = @@ -24,6 +25,7 @@ fn harness(width: f32, scale: f32, theme: crate::theme::ThemeKind) -> Harness<'s h } +/// Click `at` with the primary button, hovering first so the row reacts. fn click(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2) { h.hover_at(at); h.run_steps(1); @@ -33,6 +35,7 @@ fn click(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2) { h.run_steps(2); } +/// A filter row's name and blend icon are separate double-click targets, side by side without overlap, in every theme and dock width. #[test] fn names_and_blend_icons_have_separate_double_click_targets_and_no_overlap() { for theme in crate::theme::ThemeKind::ALL { @@ -64,6 +67,7 @@ fn names_and_blend_icons_have_separate_double_click_targets_and_no_overlap() { } } +/// The effects disclosure hides the effects and Smart Filters stacks together, as a view change only. #[test] fn disclosure_hides_both_stacks_without_changing_pixels_history_or_selection() { let mut h = harness(290.0, 1.5, Default::default()); @@ -89,6 +93,7 @@ fn disclosure_hides_both_stacks_without_changing_pixels_history_or_selection() { assert!(h.query_by_label("Blending Options (Box Blur)").is_some()); } +/// An adjustment on a Smart Object reopens its editor with the stored values and OK updates it in one step; Invert has no editor. #[test] fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending() { let (mut app, layer) = app(8, "rgb"); @@ -117,6 +122,7 @@ fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending( assert!(app.ui.dialogs.is_empty(), "Invert has no parameter editor"); } +/// The Smart Filters mask and the layer mask of one layer are cached as separate thumbnails. #[test] fn smart_and_layer_masks_have_separate_thumbnail_cache_entries() { let (mut app, layer) = app(8, "rgb"); @@ -132,6 +138,7 @@ fn smart_and_layer_masks_have_separate_thumbnail_cache_entries() { assert_eq!(first, app.mask_thumb(&ctx, &doc, layer, mask)); } +/// ⌘I on a Smart Object adds the ordinary Invert as a smart filter, and undo/redo leave the source contents alone. #[test] fn ctrl_i_uses_existing_invert_command_and_undo_redo_keep_the_source() { let mut h = harness(290.0, 1.0, Default::default()); From 80553ecd939ace5dd530416f827ad6b9657236dd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:32 +0200 Subject: [PATCH 0643/1452] Refuse strict ATN export of Close with an explicit document target (#7393) Fixes #7299 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 7 ++++++- crates/engine/src/atn_cmds/tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index fa86e292f3..d027dd15cb 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1577,7 +1577,12 @@ fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } } -fn close_to(_: &str, _: &Value) -> Option { +/// A Close of an explicit document has no mapping here: written as a plain Close it would +/// close the active document on playback, so it is left to `skipUnsupported`. +fn close_to(_: &str, p: &Value) -> Option { + if p.get("document").is_some_and(|d| !d.is_null()) { + return None; + } Some(desc("null").with("Svng", enm("YsN ", "N "))) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 980d5eb9e2..fd8038775e 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1005,3 +1005,18 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } + +/// Photoshop's Close has no document target: a Close of a specific document must not be +/// exported as a plain Close, which would close the active document on playback. +#[test] +fn close_with_document_target_is_not_exported_as_plain_close() { + let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() }; + assert!(export("Audit4", &[&targeted], &[], false).is_err()); + let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]); + let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() }; + let (bytes, skipped) = export("Audit4", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); +} From 713dbbed2f779a393fc86949a1077b317f1d50a7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:37 +0200 Subject: [PATCH 0644/1452] Save Actions ATN files atomically so a failed write keeps the old file (#7403) Fixes #7290 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 3 ++- crates/engine/src/atn_cmds/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d027dd15cb..e0767330ca 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3264,7 +3264,8 @@ fn save(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { - std::fs::write(path, bytes) + // Atomic replacement (temporary file + rename): a failed write keeps the previous ATN (#7290). + photocraft_format::atomic_write(std::path::Path::new(path), bytes) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index fd8038775e..b280a6d679 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -585,6 +585,33 @@ fn save_writes_the_file_and_the_set_of_the_selected_action() { std::fs::remove_dir_all(&dir).ok(); } +/// #7290: saving over an existing ATN must replace it atomically (a temporary file renamed over +/// it), never truncate and rewrite it in place, so a write that fails partway (a full disk) +/// leaves the previously valid file readable. A handle opened on the old file still sees the old +/// bytes after an atomic replacement; an in-place rewrite changes them under it. +#[cfg(unix)] +#[test] +fn save_over_an_existing_file_never_rewrites_it_in_place() { + use std::io::Read; + let mut s = Session::new(); + s.actions.list.push(Action::new("Owned", vec![("select.all".into(), json!({}))])); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Owned.atn"); + let p = path.to_string_lossy().to_string(); + s.execute("actions.save", json!({"path": p})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut old = std::fs::File::open(&path).unwrap(); + s.actions.list.push(Action::new("Second", vec![("select.all".into(), json!({}))])); + s.execute("actions.save", json!({"path": p})).unwrap(); + let mut seen = Vec::new(); + old.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, before, "the existing ATN was rewritten in place"); + let after = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after.actions.len(), 2); + let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, vec![std::ffi::OsString::from("Owned.atn")], "no temporary file is left behind"); +} + #[test] fn layer_param_accepts_a_name() { let mut s = Session::new(); From 65778c1f7e930d9a3d0b37ced92bbe43735a8d26 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:41 +0200 Subject: [PATCH 0645/1452] Raw tone curve: no NaN between a knot at black and the next one (#7416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `develop::tone` interpolates the camera curve logarithmically between knots, and a curve whose first knot sits at black (0, 0) made the first segment divide by log₂ 0: every value in it came out NaN, and `retone` (the open-time Shadows/Highlights preview) propagated it into the pixels. From a knot at black the segment is now linear, where log₂ has no value; the other segments are unchanged. Test: `retone_lifts_shadows_with_a_curve_from_black` (finite output, shadows lifted, highlights kept) fails without the fix. Split out of #3816 at the reviewer's request. Co-authored-by: Claude Fable 5.1 --- crates/raw/src/develop.rs | 3 ++- crates/raw/src/tone.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index e3ab70b0c7..5d28508750 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -535,7 +535,8 @@ pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 { } let i = curve.partition_point(|p| p[0] <= v).clamp(1, curve.len() - 1); let ([x0, y0], [x1, y1]) = (curve[i - 1], curve[i]); - let f = (v.log2() - x0.log2()) / (x1.log2() - x0.log2()); + // Logarithmic between knots; linear from a knot at black, where log₂ has no value. + let f = if x0 > 0.0 { (v.log2() - x0.log2()) / (x1.log2() - x0.log2()) } else { (v - x0) / (x1 - x0) }; y0 + (y1 - y0) * f } diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 80a80bfdd6..1c2730bd2c 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -710,6 +710,19 @@ mod tests { assert!(Haze::new(w, h, |i| px[i], 0.0).is_none()); } + #[test] + fn retone_lifts_shadows_with_a_curve_from_black() { + // A curve whose first knot is black: its first segment must not divide by log₂ 0. + let (w, h) = (96, 64); + let curve = [[0.0, 0.0], [0.05, 0.12], [0.3, 0.6], [1.0, 1.0]]; + let px: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 48 { [0.12, 0.11, 0.1, 1.0] } else { [0.7, 0.63, 0.56, 1.0] }).collect(); + let mut out = px.clone(); + retone(&mut out, w, h, &curve, SHADOWS); + assert!(out.iter().flatten().all(|v| v.is_finite())); + assert!(out[30 * w + 10][0] > px[30 * w + 10][0] + 0.05, "{:?}", out[30 * w + 10]); + assert!((out[30 * w + 80][0] - px[30 * w + 80][0]).abs() < 0.03, "{:?}", out[30 * w + 80]); + } + #[test] fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() { let (w, h) = (300, 120); From 491af6d42c3043a079ef4a37b2562ba2d8e7acb4 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:47 +0200 Subject: [PATCH 0646/1452] Camera Raw histogram: spikes run off the top, as in Photoshop's Camera Raw (#7419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's histogram fitted its tallest interior column to the plot, so one spike (a dark sky, a bright wall) flattened the midtones' hill to a few pixels. Camera Raw 16.1.1 scales linearly by the typical columns and lets a spike run off the top (measured on Photoshop 25.4: 49 px per 10 000 pixels in a 106 px plot, the 95th-percentile column at 14 546, the tallest at 21 600). `rgb_histogram::Spikes`: `Fit` (the old behaviour, every other histogram keeps it) and `Clip` (the scale is at most 1.5 × the 95th-percentile column, taller columns clip at the top). The Camera Raw dialog's histogram uses `Clip`. Test: `camera_raw_scale_lets_a_shadow_spike_run_off_the_top`. Split out of #3816 at the reviewer's request. Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/camera_raw_scope_ui.rs | 3 +- crates/ui-egui/src/rgb_histogram.rs | 62 ++++++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index c9f98080c9..9cb5233d00 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -384,7 +384,8 @@ fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeSt response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram"))); ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background()); let plot = rect.shrink(4.0); - crate::rgb_histogram::paint(ui.painter(), plot, d.histogram(), &t); + // Camera Raw's scale: the typical columns set it and a shadow or highlight spike runs off the top. + crate::rgb_histogram::paint_with(ui.painter(), plot, d.histogram(), &t, crate::rgb_histogram::Spikes::Clip); let corner = 18.0; if let Some(sample) = d.hover_sample(false) { crate::rgb_histogram::paint_sample(ui.painter(), plot, sample.rgb, &t); diff --git a/crates/ui-egui/src/rgb_histogram.rs b/crates/ui-egui/src/rgb_histogram.rs index f8787ad66b..efae4cce41 100644 --- a/crates/ui-egui/src/rgb_histogram.rs +++ b/crates/ui-egui/src/rgb_histogram.rs @@ -5,7 +5,7 @@ use photocraft_algo::histogram::{BINS, RgbHistogram}; /// A five-bin binomial display kernel softens quantization combs. It cannot bridge /// a wide empty range. Clipping bins are pinned and excluded from filtering adjacent bins. -fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { +fn curves(h: &RgbHistogram, spikes: Spikes) -> [[f32; BINS]; 3] { let mut curves = [[0.0; BINS]; 3]; for (out, bins) in curves.iter_mut().zip(&h.channels) { for (i, value) in out.iter_mut().enumerate() { @@ -22,7 +22,18 @@ fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { // preserve them at the edges, capped at the top. A flat black/white image uses its edge peak. let interior = curves.iter().flat_map(|c| c.iter().skip(1).take(BINS - 2)).copied().fold(0.0f32, f32::max); let peak = if interior > 0.0 { interior } else { curves.iter().flatten().copied().fold(0.0f32, f32::max) }; - let scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + let mut scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + if spikes == Spikes::Clip { + // Camera Raw scales linearly with the 95th-percentile column at two thirds of the height + // and lets taller spikes run off the top (measured on Photoshop 25.4's Camera Raw 16.1.1: + // 49 px per 10 000 pixels in a 106 px plot, 95th percentile 14 546, full 21 600). + let mut columns: Vec = (1..BINS - 1).map(|i| curves.iter().map(|c| c[i]).fold(0.0f32, f32::max)).collect(); + columns.sort_unstable_by(f32::total_cmp); + let p95 = columns.get(columns.len() * 95 / 100).copied().unwrap_or(0.0); + if p95 > 0.0 { + scale = scale.min(p95 * 1.5); + } + } for value in curves.iter_mut().flatten() { *value = (*value / scale).min(1.0); } @@ -101,11 +112,24 @@ fn fill(rect: Rect, curves: &[[f32; BINS]; 3], t: &Tokens) -> Mesh { mesh } +/// How a histogram treats columns far above the rest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Spikes { + /// Fit the tallest interior column (edge columns excluded). + Fit, + /// Camera Raw's: scale by the typical columns, spikes run off the top. + Clip, +} + pub(crate) fn paint(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens) { + paint_with(painter, rect, h, t, Spikes::Fit); +} + +pub(crate) fn paint_with(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens, spikes: Spikes) { if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() { return; } - let curves = curves(h); + let curves = curves(h, spikes); painter.add(fill(rect, &curves, t)); for (channel, values) in curves.iter().enumerate() { if !values.iter().any(|&v| v > 0.0) { @@ -139,7 +163,7 @@ pub(crate) fn paint_sample(painter: &egui::Painter, rect: Rect, rgb: [f32; 3], t #[cfg(test)] fn mesh(rect: Rect, h: &RgbHistogram, t: &Tokens) -> Mesh { - fill(rect, &curves(h), t) + fill(rect, &curves(h, Spikes::Fit), t) } #[cfg(test)] @@ -161,6 +185,28 @@ mod tests { }) } + #[test] + fn camera_raw_scale_lets_a_shadow_spike_run_off_the_top() { + // A dark-sky photo: a tall spike in the first columns over a hill in the midtones. + let mut h = RgbHistogram::default(); + for c in &mut h.channels { + for (i, v) in c.iter_mut().enumerate() { + let x = i as f32 / 255.0; + *v = (14_000.0 * (-((x - 0.5) / 0.18).powi(2)).exp()) as u64 + 3_000; + } + c[1] = 88_000; + c[2] = 42_000; + } + let fit = curves(&h, Spikes::Fit); + let clip = curves(&h, Spikes::Clip); + assert!(fit[1][128] < 0.25, "fitting the spike flattens the hill: {}", fit[1][128]); + assert!((0.5..0.75).contains(&clip[1][128]), "Camera Raw keeps the hill at about two thirds: {}", clip[1][128]); + assert_eq!(clip[1][1], 1.0, "the spike is clipped at the top"); + // Without spikes both scales agree. + let flat = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; + assert_eq!(curves(&flat, Spikes::Fit), curves(&flat, Spikes::Clip)); + } + #[test] fn shared_scale_and_overlaps_are_exact_for_every_theme() { let h = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; @@ -217,15 +263,15 @@ mod tests { let mut h = RgbHistogram { channels: [[10; BINS]; 3], ..Default::default() }; h.channels[0][0] = 1_000_000; h.channels[1][255] = 1_000_000; - let p = curves(&h); + let p = curves(&h, Spikes::Fit); assert_eq!(p[0][0], 1.0); assert_eq!(p[1][255], 1.0); assert!((p[0][1] - 1.0 / 1.1).abs() < 0.0001); assert!((p[1][254] - 1.0 / 1.1).abs() < 0.0001); let mut h = RgbHistogram::default(); h.channels[0][255] = 100; - assert!(curves(&h)[0][255] > 0.9); - assert_eq!(curves(&h)[0][254], 0.0); + assert!(curves(&h, Spikes::Fit)[0][255] > 0.9); + assert_eq!(curves(&h, Spikes::Fit)[0][254], 0.0); } #[test] @@ -247,7 +293,7 @@ mod tests { channel[i] = 100; } } - let p = curves(&h); + let p = curves(&h, Spikes::Fit); let low = p[0][40..210].iter().copied().fold(f32::INFINITY, f32::min); let high = p[0][40..210].iter().copied().fold(0.0f32, f32::max); assert!(high - low < 0.2, "quantization comb still dominates the contour: {}", high - low); From 70664978863424db8d166e29935fe2fab5b5da96 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:21 +0200 Subject: [PATCH 0647/1452] Keep fractional translation-only Free Transform on .atn import (#7401) Fixes #7298 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 9 ++++++--- crates/engine/src/atn_cmds/tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index e0767330ca..a32256920a 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1613,14 +1613,17 @@ fn transform_from(d: &Descriptor) -> Mapped { if d.get("Skew").is_some() || d.get("Usng").is_some() { return unsupported; } - let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + // A whole-pixel move is a plain `layer.translate`; a fractional one needs `edit.transform` + // to keep its sub-pixel placement and interpolation. + let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; match (ref_class(r).as_deref(), target(r)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), (Some("Lyr"), _) => { let Some(mut lp) = layer_target(r) else { return unsupported }; if geometry_only { - lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); - lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).into()); return step("layer.translate", Value::Object(lp)); } lp.extend(p); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b280a6d679..335a3cbead 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -649,7 +649,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { let moved = desc("null") .with("null", target_ref("Lyr ")) .with("FTcs", enm("QCSt", "Qcsa")) - .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.0)).with("Vrtc", px(12.0)))) .with("Intr", enm("Intp", "Bcbc")); let scaled = desc("null") .with("null", target_ref("Lyr ")) @@ -1047,3 +1047,24 @@ fn close_with_document_target_is_not_exported_as_plain_close() { let imported = import(&bytes).unwrap(); assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); } + +/// A translation-only Free Transform with a fractional offset must not collapse into an +/// integer `layer.translate`: the sub-pixel placement and interpolation have to survive. +#[test] +fn fractional_translation_only_transform_keeps_offset_and_interpolation() { + let params = json!({"dx": 0.5, "dy": 0, "interpolation": "bilinear"}); + let d = transform_to("edit.transform", ¶ms).unwrap(); + let file = AtnFile { + name: UnicodeString::new_nul("Frac"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imp.unsupported.is_empty()); + let steps = &imp.actions[0].steps; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "edit.transform"); + assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); + assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); +} From 25b79243f1ea2f7f4100fdb91c331ca7f2225f66 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:54:43 +0300 Subject: [PATCH 0648/1452] Smart Filters: document the functions next to the ones the PR changes CodeRabbit counts the functions around the lines a PR changes, not only the changed ones, so the PR's docstring coverage stays low (59.49%, threshold 80%) unless the untouched neighbours are documented too. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fx_view_cmds.rs | 6 ++++++ crates/engine/src/smart_cmds/tests.rs | 1 + crates/engine/tests/photoshop_oracles.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/smart_ui.rs | 1 + 5 files changed, 10 insertions(+) diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 76bbc781ee..af550a367b 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -10,10 +10,12 @@ use crate::{EngineError, Result, Session}; const ID: &str = "layer.setEffectsExpanded"; +/// A `layer.setEffectsExpanded` parameter error. fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: ID.into(), msg: msg.into() } } +/// The command is available while some layer has effects or Smart Filters to disclose. fn has_effects(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) } @@ -24,6 +26,7 @@ fn has_children(l: &Layer) -> bool { !l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) } +/// An optional boolean param: `None` when absent or null, an error when it is anything but true or false. fn opt_bool(p: &Value, key: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -64,6 +67,7 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result { Ok(json!({"expanded": expanded, "layers": targets.len()})) } +/// Register `layer.setEffectsExpanded`: view state, so it is not journaled and adds no undo step. pub fn specs() -> Vec { vec![CommandSpec { id: ID, @@ -93,6 +97,7 @@ mod tests { (s, a, b) } + /// Whether the layer's effects list is shown, i.e. it is not in the document's collapsed set. fn open(s: &Session, id: u64) -> bool { !s.active().unwrap().fx_collapsed.contains(&LayerId(id)) } @@ -120,6 +125,7 @@ mod tests { assert!(s.active().unwrap().fx_collapsed.is_empty()); } + /// Toggling one layer or all of them flips only the disclosure state: no history step, no duplicates, and the document stays clean. #[test] fn toggles_one_layer_or_all_without_history() { let (mut s, a, b) = session(); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 3666a4818c..f955a21ee5 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -77,6 +77,7 @@ fn non_normal_coverage_changes_retain_source_over_semantics() { } } +/// A `depth`-bit document with one empty layer. fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/photoshop_oracles.rs index 1b3e37d3e9..b1f0a133a1 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/photoshop_oracles.rs @@ -93,6 +93,7 @@ fn screen_motion_blur_retains_input_coverage() { assert!(error <= PASS_TOL, "Screen/Motion Blur error {error}, {bad}% pixels exceed 2/255"); } +/// Re-render every Photoshop oracle in the corpus with PhotoCraft's filters and compare it with Photoshop's own merged composite. #[test] fn photoshop_oracles_rerendered() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 584d0928df..893401ba85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2330,6 +2330,7 @@ impl PhotocraftApp { id } + /// The cached thumbnail of `mask`, the layer mask of layer `id`. pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { self.mask_thumb_kind(ctx, doc, id, mask, mask_thumbs_ui::THUMB_MASK) } diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 1d8e6d68ce..8afc3578ea 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -206,6 +206,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l /// Filters row, all of them). Also what a click selects. pub(crate) type FilterDrag = (u64, Option); +/// Memory key of the smart filter row being dragged. pub(crate) fn drag_key() -> egui::Id { egui::Id::new("smart-filter-drag") } From a83ec3d2c95fab263d6b18b7f7ea9ad413badcb8 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:54:54 +0300 Subject: [PATCH 0649/1452] Layers panel options: document the functions next to the ones the PR changes CodeRabbit counts the functions around the lines a PR changes, not only the changed ones, so the PR's docstring coverage stays under its 80% threshold unless the untouched neighbours are documented too. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 1 + crates/ui-egui/src/clip_line_ui/tests.rs | 3 +++ crates/ui-egui/src/mask_thumbs_ui.rs | 1 + 3 files changed, 5 insertions(+) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2081db277c..2705077d6c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2771,6 +2771,7 @@ mod tests { assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err()); } + /// `document.inspect` lists the selected layers and marks them in the layer list. #[test] fn inspect_reports_selection() { let mut s = session(8); diff --git a/crates/ui-egui/src/clip_line_ui/tests.rs b/crates/ui-egui/src/clip_line_ui/tests.rs index aeab35f23c..d7c443aab9 100644 --- a/crates/ui-egui/src/clip_line_ui/tests.rs +++ b/crates/ui-egui/src/clip_line_ui/tests.rs @@ -83,14 +83,17 @@ fn the_clip_cursor_shows_only_with_alt_over_a_line() { assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "mid-row: no line"); } +/// A 32-point-high row for `layer` starting `top` points down, with no thumbnail, name or indicators. fn row(layer: LayerId, top: f32) -> RowRects { RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), thumbnail: None, name: None, indicators: Vec::new() } } +/// The id of the layer called `name`, wherever it sits in the tree. fn named(doc: &Document, name: &str) -> LayerId { doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id } +/// A clipping line appears only between siblings that are neighbours in the stack, not at a group's edge, in the eye column or inside a row. #[test] fn only_siblings_next_to_each_other_meet_at_a_line() { // Background, `d`, and the group `g { c }` above it. diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 225cd47e8f..66534b6f86 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -172,6 +172,7 @@ pub fn paint( type Rects = (Vec<(MaskKind, Rect)>, Vec<(MaskKind, Rect)>); +/// Memory key of the per-layer record of where the mask thumbnails and chains were drawn. fn rects_id() -> egui::Id { egui::Id::new("mask-thumb-rects") } From 0e8840f1571eb9d35d230e9306ed8792bfcf7c9e Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:32 +0300 Subject: [PATCH 0650/1452] Fill layers: document the functions and tests the PR adds or changes CodeRabbit measured the docstring coverage of the PR at 78.75%, under its 80% threshold. Add short doc comments to the functions #3975 adds or changes and to the ones next to them, saying what each does and which behaviour each test pins. The Layers panel functions of #3289 are left to that PR. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 ++ crates/engine/src/layer_multi_cmds.rs | 1 + crates/engine/src/pattern_cmds.rs | 2 ++ crates/engine/src/pattern_fill_cmds.rs | 3 +++ crates/ui-egui/src/layers_panel_ui.rs | 1 + 5 files changed, 9 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e036bf8212..b3b8e823ca 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -406,6 +406,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str pub(crate) const NEW_LAYER_PATH: &str = r##""path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask, or white with no selection)"##; +/// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask). fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result { let label = format!("New {} Layer", adj.label()); let name = adj.label().to_string(); @@ -419,6 +420,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res Ok(json!({ "layer": id.0 })) } +/// Apply `adj` in place to the targeted layer mask, the targeted alpha channel or Quick Mask, or else the active layer's pixels, as one history step named `label`. fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) -> Result { if is_mask_target(p) { // The targeted layer mask (#780): ⌘I inverts it, as in Photoshop. diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1ea70ecb2c..e7c3a626ed 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2970,6 +2970,7 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&target)); } + /// New Solid Color and Gradient fill layers get the default mask only while Use Default Masks on Fill Layers is on. #[test] fn fill_layers_respect_use_default_masks_option() { let (mut s, _) = new_session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 87ae0322f4..c81d4145a6 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -328,6 +328,7 @@ pub(crate) fn insert_fill_layer( Ok(id) } +/// `layer.newFillLayer.pattern`: a Pattern Fill layer above the active one, masked as the Layers panel's Use Default Masks on Fill Layers option says. fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); @@ -335,6 +336,7 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "pattern": pat.id})) } +/// `brush.texturePattern`: use a pattern as the brush texture, converted to grey as the luminance of its composite over white. fn brush_texture(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "brush.texturePattern", p)?; let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten(); diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index 0ecf09f371..82fd02faa9 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -132,6 +132,7 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { Ok(doc) } +/// Register `layer.fill.pattern.set` (the Pattern Fill dialog's command), journaled like any other edit. pub fn specs() -> Vec { vec![CommandSpec { id: SET, @@ -217,6 +218,7 @@ mod tests { assert!(preview(&s, "edit.fill", &json!({})).is_err()); } + /// At 8, 16 and 32 bits the Pattern Fill preview and the committed layer carry the default mask only while the option is on, and creating one is one undoable step. #[test] fn create_preview_and_commit_honor_default_masks_option() { for depth in [8, 16, 32] { @@ -249,6 +251,7 @@ mod tests { } } + /// Malformed or out-of-range params, and missing layers or patterns, are errors in both the command and its preview, never panics. #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index 8d29c0de5c..a902916622 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -382,6 +382,7 @@ mod tests { assert!(restored.layers_panel_dialog.is_none()); } + /// With the option off a new fill layer has no mask and the mask is not targeted; with it on the new layer's mask is targeted. #[test] fn disabling_default_fill_masks_never_targets_an_absent_mask() { let mut session = photocraft_engine::Session::new(); From 18f8d33577f5648e03934c1c9ce421ea3d7c872f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 11:00:42 -0400 Subject: [PATCH 0651/1452] docs: add SonarCloud and CodeRabbit badges to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 12e2ef4b12..1e85aca5b0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha + Scanned by SonarCloud + Reviewed by CodeRabbit

From 87ff8bbc66b861a22f00a3327efbb9da67c5041b Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 18:29:08 +0300 Subject: [PATCH 0652/1452] Use a press menu for Color panel HSB/RGB mode (#7317). The combo box was the nearest to the Color Lookup LUT picker in harness tests. Co-authored-by: Cursor --- crates/ui-egui/src/panels.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1c5ad1b38a..d38575274d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2303,11 +2303,21 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// HSB or RGB sliders under the colour field (#7172). fn color_slider_mode_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); - let mut mode = app.ui.color_panel.slider_mode; - let opts = [(ColorPanelSliderMode::Hsb, "HSB"), (ColorPanelSliderMode::Rgb, "RGB")]; - if !widgets::dropdown_wheel_hovered(ui, "color-panel-slider-mode", &mut mode, &opts, 72.0).0.is_empty() { - app.ui.color_panel.slider_mode = mode; - } + let label = match app.ui.color_panel.slider_mode { + ColorPanelSliderMode::Hsb => tl!("HSB"), + ColorPanelSliderMode::Rgb => tl!("RGB"), + }; + // A press menu, not a combo box: the default layout keeps the Color panel open beside + // adjustment editors, and harness tests pick the combo nearest "3D LUT File" (#7317). + let btn = ui.add_sized([72.0, ui.spacing().interact_size.y], egui::Button::new(label)); + crate::press_menu::show(&btn, |ui, menu| { + if menu.item(ui, tl!("HSB")) { + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Hsb; + } + if menu.item(ui, tl!("RGB")) { + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb; + } + }); } /// Hue, Saturation and Brightness sliders for the foreground colour. From 4f0876958db142b61c790f1a23139e2cd5bd1854 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 21:04:44 +0530 Subject: [PATCH 0653/1452] CLI convert: refuse to write over the input file unless --in-place (#7437) `photocraft-cli convert in.jpg in.jpg` re-encoded the original in place and exited 0, so one wrong output template destroyed the source (a lossy JPEG re-encode can't be undone). `batch` already refuses an --out folder that is its --in folder (#492); convert now applies the same rule: an that is the file, however it is spelt (./, absolute, hard or symbolic link), is refused before anything is read, and --in-place opts in. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 18 ++++++++++++++-- apps/photocraft-cli/tests/cli.rs | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 03001ab02d..69bc1a9632 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,8 +15,10 @@ pub const USAGE: &str = "\ photocraft-cli: headless PhotoCraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). + An that is the file is refused, as the result would replace the original; + --in-place allows it. TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as @@ -69,7 +71,7 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--in-place", "--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", @@ -239,11 +241,23 @@ fn automation_workspace(args: &Args) -> Result { .map_err(|error| error.to_string()) } +/// An output that is the input file itself, however it is spelt (relative, with `./`, through a +/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it +/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` +/// folder (#7437). +fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { + if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { + return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); + } + Ok(()) +} + fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { let [input, output] = a.positional.as_slice() else { return Err("convert needs ".into()); }; let opts = export_opts(a)?; + refuse_overwriting_input(a, input, output)?; let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; warn_all(err, &o.warnings); warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 3c28a8196e..b8aa434b3e 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -566,6 +566,42 @@ fn batch_refuses_to_write_over_its_inputs() { assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced"); } +/// #7437: `convert` to its own input file, however the output is spelt, is refused before +/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492). +#[test] +fn convert_refuses_to_write_over_its_input() { + let d = tmp("convert-in-place"); + write_png(&d.join("in.png"), 8, 4, 5); + let before = std::fs::read(d.join("in.png")).unwrap(); + let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap(); + let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")]; + std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap(); + outs.push(PathBuf::from("hard.png")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap(); + outs.push(PathBuf::from("soft.png")); + } + for out in &outs { + for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] { + let o = convert(out, extra); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display()); + assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}"); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display()); + } + } + // Another file converts as before, leaving the original alone. + ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"])); + assert!(d.join("out.jpg").is_file()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); + // `--in-place` opts in: the result replaces the original, which still opens. + ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"])); + let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let info: Value = serde_json::from_str(&out).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); +} + #[test] fn commands_listing() { let (out, _) = ok(bin().arg("commands")); From 9eef6aa51e3c7fc8e6a07dc275315cae971f5ecf Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 21:13:06 +0530 Subject: [PATCH 0654/1452] same_file: compare file identity on Windows, so a hard link is the same file On Windows `same_file` compared canonical path strings, so a hard link to the input (another name for the same file) passed the new convert guard without --in-place. It now compares the volume serial number and file index (the `same-file` crate, already in the dependency tree; std's file_index is unstable), as unix compares dev and inode, falling back to the case-insensitive canonical path when a file won't open. The engine's same_file test now checks hard links on every platform. (Review on #7454.) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 5 +++++ crates/engine/src/file_cmds.rs | 7 ++++++- crates/engine/src/web_cmds/tests.rs | 7 ++----- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26920a968e..132863df32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,6 +4812,7 @@ dependencies = [ "photocraft-vector", "rayon", "roxmltree", + "same-file", "serde", "serde_json", "tempfile", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 65dd1e16e0..fb3bc3fbec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" + # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] photocraft-ipp = { workspace = true } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f99aefcff0..3db9a662c3 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -234,7 +234,12 @@ pub fn same_file(a: &str, b: &str) -> bool { } #[cfg(windows)] { - // NTFS and FAT compare names without case. + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: NTFS and FAT compare names without case. if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 594fe8d496..bd55a549cb 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -476,11 +476,8 @@ fn same_file_sees_through_other_names_for_one_file() { assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); - #[cfg(unix)] - { - std::fs::hard_link(&a, &b).unwrap(); - assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); - } + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); } From 957e68f600fbe3094527ca3be4ead37f53becad6 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:46:18 +0300 Subject: [PATCH 0655/1452] Expand New Effects follows the destination of Transfer Effects layer.layerStyle.transferEffects names the layer that receives the effects in "to"; it has no "layer" param, so the disclosure target fell back to the active layer and the wrong layer's effects were expanded or collapsed. Resolve "to" for that command before recording new_fx_target. Every other Layer Style command keeps resolving "layer" as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 8 +++++++- crates/engine/src/layer_multi_cmds.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5566d39c3f..590a1a77cc 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -727,8 +727,14 @@ impl Session { // desktop UI normally calls `start`, even for synchronous Layer Style commands. // The layer the command will change, named as the command accepts it (id or name), else the // active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands. + // Transfer Effects changes its destination (`to`), not the layer it takes the effects from. let new_fx_target = if id.starts_with("layer.layerStyle.") { - let target = crate::commands::layer_param(self, ¶ms).ok(); + let target = if id == "layer.layerStyle.transferEffects" { + crate::commands::layer_param(self, &json!({"layer": params.get("to").cloned().unwrap_or(Value::Null)})) + } else { + crate::commands::layer_param(self, ¶ms) + } + .ok(); self.active().and_then(|st| { let target = target?; (st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target)) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2705077d6c..935559192e 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -3013,4 +3013,19 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed"); assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed"); } + + /// Transfer Effects changes the layer it moves the effects to, so that is the layer whose + /// disclosure follows the option, not the active layer or the one the effects came from. + #[test] + fn expand_new_effects_follows_the_transfer_destination() { + let (mut s, ids) = new_session(); + let (from, to, active) = (ids[0], ids[1], ids[2]); + s.execute("layer.layerStyle.dropShadow", json!({"layer": from.0, "size": 2})).unwrap(); + option(&mut s, "expandNewEffects", false); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + s.execute("layer.layerStyle.transferEffects", json!({"from": from.0, "to": to.0, "copy": true})).unwrap(); + assert!(s.active().unwrap().doc.layer(to).is_some_and(|l| !l.effects.items.is_empty())); + assert!(s.active().unwrap().fx_collapsed.contains(&to), "the destination's new effects start collapsed"); + assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed"); + } } From 642baa07c512efdfad651831bc2a6fc8194f8d90 Mon Sep 17 00:00:00 2001 From: Ni-zav Date: Sun, 18 Oct 2026 23:01:58 +0700 Subject: [PATCH 0656/1452] fix(screen-picker): keep capture preparation invisible --- crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/i18n/de.tsv | 1 - crates/ui-egui/src/i18n/el.tsv | 1 - crates/ui-egui/src/i18n/es.tsv | 1 - crates/ui-egui/src/i18n/fr.tsv | 1 - crates/ui-egui/src/i18n/hu.tsv | 1 - crates/ui-egui/src/i18n/id.tsv | 1 - crates/ui-egui/src/i18n/it.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/ko.tsv | 1 - crates/ui-egui/src/i18n/nl.tsv | 1 - crates/ui-egui/src/i18n/pl.tsv | 1 - crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - crates/ui-egui/src/i18n/sr.tsv | 1 - crates/ui-egui/src/i18n/tr.tsv | 1 - crates/ui-egui/src/i18n/uk.tsv | 1 - crates/ui-egui/src/i18n/vi.tsv | 1 - crates/ui-egui/src/i18n/zh-hans.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 1 - crates/ui-egui/src/lib.rs | 10 ++++++---- 21 files changed, 6 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 13f954c3df..01a8b9e4d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2375,7 +2375,6 @@ cameraRaw Color Barvy Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce Click to pick · Esc to cancel Kliknutí vybere · Esc zruší Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení - Preparing screen capture… Příprava snímku obrazovky… Scroll to zoom Rolováním přibližujte Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte Show/Hide All Panels Zobrazit/skrýt všechny panely diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ee96014a1a..489647ed21 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2415,7 +2415,6 @@ Sample a pixel anywhere on the screen Ein Pixel an beliebiger Stelle auf dem Bildschirm auswählen Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen - Preparing screen capture… Bildschirmaufnahme wird vorbereitet… Scroll to zoom Zum Zoomen scrollen Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen Show/Hide All Panels Alle Bedienfelder ein-/ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f60c7af7a5..410da1a188 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3553,7 +3553,6 @@ fontWeight Thin Πολύ λεπτά # Screen color picker (#1232) Pick screen color Επιλογή χρώματος οθόνης Pick a screen pixel or press Esc to cancel Επιλέξτε ένα εικονοστοιχείο της οθόνης ή πατήστε Esc για ακύρωση - Preparing screen capture… Προετοιμασία λήψης στιγμιότυπου οθόνης… Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f462f89160..b5db4e9925 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2446,7 +2446,6 @@ cameraRaw Amount Cantidad Sample a pixel anywhere on the screen Tomar un píxel en cualquier lugar de la pantalla Click to pick · Esc to cancel Clic para elegir · Esc para cancelar Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar - Preparing screen capture… Preparando captura de pantalla… Scroll to zoom Desplace para ampliar Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar Show/Hide All Panels Mostrar/ocultar todos los paneles diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index da139e3674..ab0cca6f40 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2797,7 +2797,6 @@ cameraRaw Blending Fusion Sample a pixel anywhere on the screen Prélever un pixel n’importe où sur l’écran Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler - Preparing screen capture… Préparation de la capture d’écran… Scroll to zoom Défiler pour zoomer Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster Show/Hide All Panels Afficher/masquer tous les panneaux diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 63cc685eb8..9a7283a5a1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3432,7 +3432,6 @@ cameraRaw Yellow Sárga PhotoCraft — Screen Color Picker PhotoCraft — Képernyő-színválasztó Photomerge Fotóegyesítés Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz - Preparing screen capture… Képernyőkép előkészítése… Pick screen color Szín kiválasztása a képernyőről Pick Color From Image Szín felvétele a képről Pins Tűk diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2717e93317..6924c45609 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2784,7 +2784,6 @@ cameraRaw Blending Pembauran Sample a pixel anywhere on the screen Ambil sampel piksel di mana saja pada layar Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan - Preparing screen capture… Menyiapkan tangkapan layar… Scroll to zoom Gulir untuk memperbesar Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ca0181461b..2d42c550cc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2447,7 +2447,6 @@ cameraRaw Amount Quantità Sample a pixel anywhere on the screen Campiona un pixel in qualsiasi punto dello schermo Click to pick · Esc to cancel Clic per scegliere · Esc per annullare Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare - Preparing screen capture… Preparazione dell’acquisizione dello schermo… Scroll to zoom Scorri per ingrandire Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare Show/Hide All Panels Mostra/nascondi tutti i pannelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2e8b2b4625..e05f27d718 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2348,7 +2348,6 @@ cameraRaw Color カラー Sample a pixel anywhere on the screen 画面上の任意のピクセルから色を取得 Click to pick · Esc to cancel クリックで選択 · Escでキャンセル Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル - Preparing screen capture… 画面キャプチャを準備しています… Scroll to zoom スクロールしてズーム Show/Hide All Panels すべてのパネルを表示/非表示 Show/Hide Panels パネルを表示/非表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2f0a95bf61..7de9f61f10 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2964,7 +2964,6 @@ cameraRaw Color 색상 Sample a pixel anywhere on the screen 화면의 아무 픽셀에서 색상 추출 Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소 Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소 - Preparing screen capture… 화면 캡처 준비 중… Scroll to zoom 스크롤하여 확대/축소 Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기 Show/Hide All Panels 모든 패널 표시/숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e5f6b3177e..4ed7d8a744 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2383,7 +2383,6 @@ Sample a pixel anywhere on the screen Een pixel ergens op het scherm bemonsteren Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren - Preparing screen capture… Schermafbeelding voorbereiden… Scroll to zoom Scrollen om te zoomen Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken Show/Hide All Panels Alle panelen tonen/verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f2ad9396da..a27cb4af8d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3382,7 +3382,6 @@ cameraRaw Roughness Chropowatość Sample a pixel anywhere on the screen Pobierz kolor piksela w dowolnym miejscu ekranu Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować - Preparing screen capture… Przygotowywanie zrzutu ekranu… Scroll to zoom Przewijaj, aby zmieniać powiększenie Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować Open in camera raw Otwórz w Camera Raw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0c9af2c683..0a64497704 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2801,7 +2801,6 @@ cameraRaw Whites Brancos Sample a pixel anywhere on the screen Amostrar um pixel em qualquer lugar da tela Click to pick · Esc to cancel Clique para escolher · Esc para cancelar Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar - Preparing screen capture… Preparando captura de tela… Scroll to zoom Role para ampliar Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar Show/Hide All Panels Mostrar/ocultar todos os painéis diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d7a0575ba4..2dbbf4003c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2644,7 +2644,6 @@ cameraRaw Roughness Неровность Sample a pixel anywhere on the screen Выберите пиксель в любом месте экрана Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены - Preparing screen capture… Подготовка снимка экрана… Scroll to zoom Колесо — изменить масштаб Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать Show/Hide All Panels Показать/скрыть все панели diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index bb792e1a7c..4a5f7459df 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2775,7 +2775,6 @@ cameraRaw Blending Stapanje Sample a pixel anywhere on the screen Uzorkujte piksel bilo gde na ekranu Click to pick · Esc to cancel Kliknite da uzmete boju · Esc za otkazivanje Pick a screen pixel or press Esc to cancel Izaberite piksel na ekranu ili pritisnite Esc da otkažete - Preparing screen capture… Priprema snimka ekrana… Scroll to zoom Pomerajte točkić za zumiranje Show/Hide All Panels Prikaži/sakrij sve panele Show/Hide Panels Prikaži/sakrij panele diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cf31594432..01c37a18b2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2355,7 +2355,6 @@ Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın - Preparing screen capture… Ekran görüntüsü hazırlanıyor… Scroll to zoom Yakınlaştırmak için kaydırın Show/Hide All Panels Tüm Panelleri Göster/Gizle Show/Hide Panels Panelleri Göster/Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 85e09e4117..79ebc8df36 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2616,7 +2616,6 @@ cameraRaw Roughness Шорсткість Sample a pixel anywhere on the screen Взяти зразок пікселя будь-де на екрані Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування - Preparing screen capture… Підготовка знімка екрана… Scroll to zoom Прокрутіть для масштабування Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити Show/Hide All Panels Показати/приховати всі панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8c33f3b54d..1fea5e6b38 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2176,7 +2176,6 @@ layers Fill: Độ tô: open source mã nguồn mở Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Vị trí bảng được lưu trong không gian làm việc. Phím tắt, menu và thanh công cụ là tùy chọn. Pick a screen pixel or press Esc to cancel Chọn một điểm ảnh trên màn hình hoặc nhấn Esc để hủy - Preparing screen capture… Đang chuẩn bị ảnh chụp màn hình… Position and Size Vị trí và kích thước Preserve Transparency Giữ độ trong suốt Preset Name Tên thiết lập sẵn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2e20395868..375b61c40b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2386,7 +2386,6 @@ cameraRaw Blending 混合 Sample a pixel anywhere on the screen 在屏幕任意位置采样像素 Click to pick · Esc to cancel 单击选取 · Esc 取消 Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消 - Preparing screen capture… 正在准备屏幕截图… Scroll to zoom 滚动以缩放 Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口 Show/Hide All Panels 显示/隐藏所有面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e1e3ea9c19..de931038db 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2356,7 +2356,6 @@ cameraRaw Blending 混合 Sample a pixel anywhere on the screen 在螢幕任意位置取樣像素 Click to pick · Esc to cancel 按一下選取 · Esc 取消 Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消 - Preparing screen capture… 正在準備螢幕截圖… Scroll to zoom 捲動以縮放 Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗 Show/Hide All Panels 顯示/隱藏所有面板 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6440265a05..38118955c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1816,12 +1816,10 @@ impl eframe::App for PhotocraftApp { crate::widgets::set_field_resolution(&ctx, dpi, ppi); // View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns). screen_picker::show(&ctx); - if screen_picker::busy(&ctx) { - // While the capture service is still returning a snapshot there is no picker - // viewport yet. Keep a visible Cancel action even during this phase (#4737). + if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) { egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| { ui.spinner(); - ui.label(if screen_picker::showing(&ctx) { tl!("Pick a screen pixel or press Esc to cancel") } else { tl!("Preparing screen capture…") }); + ui.label(tl!("Pick a screen pixel or press Esc to cancel")); if ui.button(tl!("Cancel")).clicked() { screen_picker::cancel(&ctx); } @@ -1829,6 +1827,10 @@ impl eframe::App for PhotocraftApp { self.automation_input = false; return; } + if screen_picker::busy(&ctx) { + ui.disable(); + ui.set_opacity(1.0); + } // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); From 08a7ff622a64b7cf014692bb874c619f6141fb08 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 18:14:57 +0200 Subject: [PATCH 0657/1452] Type options: the "T" icon shows its "Font size" tooltip (#6437) The size field's drag area is laid over the icon after it, so egui hovered the area and never the icon. scrub_option_icon hands the tooltip to that area. --- crates/ui-egui/src/type_tool.rs | 3 +-- crates/ui-egui/src/type_tool_tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 21 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index ed530fb2d1..5ce01c559f 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1744,10 +1744,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); - let icon = icon.on_hover_text(tl!("Font size")); icon.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Font size"))); // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). - crate::widgets::scrub_option_label(ui, &icon); + crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); let (unit, label) = type_unit(app); let mut display = size * unit; if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index b14cd16cf7..d89704db59 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -491,6 +491,28 @@ fn options_bar_t_icon_scrub_stops_at_the_size_limits() { assert_eq!(size_at(h.state(), id, 0), 1296.0, "Shift (10 pt per point) past the top stops at the maximum"); } +/// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip +/// (a second node with the same name) shows. +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> bool { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + h.query_all_by_label(label).count() == 2 +} + +/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the +/// icon after it, must not take its hover. +#[test] +fn options_bar_t_icon_shows_its_tooltip() { + let mut app = new_app(); + app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); + let mut h = options_harness(app); + assert!(tooltip_shows(&mut h, "Set the text color"), "the probe sees an ordinary tooltip"); + h.hover_at(egui::pos2(890.0, 55.0)); + h.run_steps(4); + assert!(tooltip_shows(&mut h, "Font size"), "hovering the \"T\" icon shows \"Font size\""); +} + /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the /// column's centre line. fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6e841292e7..455c13187e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -386,6 +386,8 @@ struct LabelScrub { max_gap: f32, /// Options captions defer interaction until the adjacent field bounds are known. option_area: bool, + /// An options icon's tooltip, shown by the drag area laid over it (which takes its hover). + tip: Option<&'static str>, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -407,12 +409,23 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { /// A tool-options caption whose scrub target includes the adjacent field's outer box. /// Keep the original label bounds for linking; register the hit area before the numeric editor. pub fn scrub_option_label(ui: &Ui, label: &Response) { + scrub_option_area(ui, label, None); +} + +/// [`scrub_option_label`] for an icon with tooltip `tip`. The field's drag area is laid over the +/// icon after it, so egui hovers the area and not the icon: `on_hover_text` on the icon never shows. +pub fn scrub_option_icon(ui: &Ui, icon: &Response, tip: &'static str) { + scrub_option_area(ui, icon, Some(tip)); +} + +fn scrub_option_area(ui: &Ui, label: &Response, tip: Option<&'static str>) { let scrub = LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: label.id.with("pc-option-scrub"), rect: label.rect, max_gap: 24.0, option_area: true, + tip, steps: 0.0, started: false, dragging: false, @@ -468,6 +481,7 @@ fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: rect: original_rect, max_gap, option_area: false, + tip: None, steps, started: resp.drag_started(), dragging: resp.dragged(), @@ -537,7 +551,12 @@ fn value_field_box( // numeric button. It is not focusable; the editor drawn next wins inside its bounds. // Preserve a popup field's trailing arrow, and never extend outside the UI clip. let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); - let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + let mut resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + if let Some(tip) = s.tip + && resp.hover_pos().is_some_and(|p| s.rect.contains(p)) + { + resp = resp.on_hover_text(tip); + } label_scrub_response(ui, &resp, s.rect, s.max_gap) }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); From 5c7c8479234da520d374381c7573eb9e7b55a76b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 10:41:35 -0600 Subject: [PATCH 0658/1452] perf(canvas): report how long the GPU spends on a composite (#7275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composite_ms` measures encoding and submitting a composite, not running it: on a 24 MP refresh it read 7.7 ms for work the GPU spent ~171 ms on, so nothing in the app could see a regression that moved work onto the GPU. A timestamp is written on the GPU timeline at the start and end of every command encoder a refresh submits, and the spans are summed and read back asynchronously. Nothing waits, so a measurement arrives a frame or two later and carries the pixel count of the refresh it belongs to. It is reported as a new `Option` `perf.gpu_ms`; `composite_ms` keeps its meaning, so its consumers are untouched. At 24 MP it reports 83.9 ms against a waited wall clock of 88.4 ms, where the app previously said 3.1 ms. What it leaves out, documented in the module: queue-level uploads, which wgpu runs in its own command buffer ahead of ours, and the end-of-batch writeback after the last timestamp. That is ~5 % of the reported time here and proportional to the composite, against the ~22x of the number it replaces. Unavailable rather than wrong: without TIMESTAMP_QUERY and TIMESTAMP_QUERY_INSIDE_ENCODERS the timer is never built and `gpu_ms` is None — on older drivers, the CPU compositor, wasm, and Apple silicon, whose GPUs support counter sampling only at stage boundaries. `PHOTOCRAFT_NO_GPU_TIMING` suppresses both the timer and the feature request. Pass-boundary timestamps would cover Apple silicon and need only TIMESTAMP_QUERY, but they capture 70-78 % of the encoder-level span here and the shortfall grows with document size, so they were measured and rejected. D3D12 is exercised: device creation, a 100 ns/tick period and the full write/resolve/map round-trip succeed with no validation errors, including across a flushed three-buffer refresh. Numbers throughout are from an integrated Intel GPU on Mesa/Vulkan and a software D3D12 adapter; no vendor Windows driver and no Apple hardware was tested. --- crates/gpu/src/lib.rs | 99 +++++++++- crates/gpu/src/timing.rs | 308 +++++++++++++++++++++++++++++++ crates/gpu/tests/timing.rs | 282 ++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 9 +- crates/ui-egui/src/gpu_canvas.rs | 116 +++++++++++- crates/ui-egui/src/lib.rs | 12 ++ 6 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 crates/gpu/src/timing.rs create mode 100644 crates/gpu/tests/timing.rs diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3d60adaf1e..e99dc53416 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -42,6 +42,7 @@ mod fx; pub mod health; pub mod plan; mod prefix; +pub mod timing; use std::collections::HashMap; use std::num::NonZeroU64; @@ -55,6 +56,7 @@ use photocraft_raster::{Surface, Tile}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; +pub use timing::{GpuTimer, GpuTiming}; /// Accumulator format for intermediate buffers. Full float: discontinuous operations /// (Posterize, Threshold, Hard Mix, Dissolve) must land on the same side of their thresholds as @@ -107,6 +109,12 @@ pub struct ChunkOut<'a> { /// What a render did (for profiling and tests). #[derive(Clone, Copy, Debug, Default)] pub struct Stats { + /// How long the GPU spent executing a composite, once its timestamps came back. Because the + /// readback is asynchronous this belongs to an *earlier* render than the one returning it + /// (it carries that render's own pixel count, so the two are never confused), and it is + /// `None` unless [`Compositor::measure_gpu_time`] is on and a measurement has landed. + /// Always `None` from [`Compositor::encode`], which submits nothing of its own. + pub gpu_time: Option, pub passes: usize, pub chunks: usize, pub slots: u32, @@ -474,6 +482,9 @@ pub struct Compositor { cmyk: u64, /// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy. health: Option, + /// Timestamp queries measuring how long the GPU spends on a refresh (see + /// [`Compositor::measure_gpu_time`]); `None` = not measured. + timer: Option, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -709,6 +720,7 @@ impl Compositor { effect_maps: map_error.is_none(), cmyk: 0, health: None, + timer: None, } .with_texture_limit(device.limits().max_texture_dimension_2d) } @@ -961,6 +973,41 @@ impl Compositor { self.health.as_ref().and_then(DeviceHealth::fault) } + /// Measure how long the GPU spends executing each [`Compositor::render`], with timestamp + /// queries read back asynchronously (nothing waits for the GPU). Returns whether the device + /// supports it: without [`timing::FEATURES`] there is no measurement at all, and callers + /// report no GPU time rather than the encode-and-submit cost `composite_ms` already is. + /// + /// A measurement arrives a frame or two after the refresh it belongs to, so it carries that + /// refresh's own pixel count; take it with [`Compositor::take_gpu_timing`]. + pub fn measure_gpu_time(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> bool { + // `PHOTOCRAFT_NO_GPU_TIMING` turns it off: a kill switch for a driver whose timestamp + // queries misbehave, and the control arm for measuring what the queries themselves + // cost — the device is identical either way, so only the instrumentation varies. + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return false; + } + if self.timer.is_none() { + self.timer = timing::GpuTimer::new(device, queue); + } + self.timer.is_some() + } + + /// Whether [`Compositor::measure_gpu_time`] is on for this compositor. + pub fn measures_gpu_time(&self) -> bool { + self.timer.is_some() + } + + /// The most recent finished GPU measurement, leaving none behind (a stale number is never + /// reported twice as new). `None` while unmeasured, unsupported, or still in flight. + /// + /// [`Compositor::render`] already drains this into [`Stats::gpu_time`], so the app never + /// calls it; it is how a bench or a test collects a measurement without issuing another + /// render to carry it. + pub fn take_gpu_timing(&mut self) -> Option { + self.timer.as_mut().and_then(timing::GpuTimer::take) + } + /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning; a @@ -976,8 +1023,47 @@ impl Compositor { ) -> Result { let result = scoped_work(device, self.health.is_some(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); - let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; + if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) { + // The readback map completes on a poll. The app polls every frame, but a + // non-blocking poll here keeps the measurements coming whatever the caller does. + match &self.health { + Some(h) => h.poll(device), + None => { + let _ = device.poll(wgpu::PollType::Poll); + } + } + } + // Whatever came back since the last render travels out with this one's stats, so + // nothing has to reach back into the compositor for it afterwards. + let landed = self.timer.as_mut().and_then(timing::GpuTimer::take); + if let Some(t) = &mut self.timer { + let r = region.intersect(&doc.bounds()); + t.start(&mut encoder, r.width() as u64 * r.height() as u64); + } + let mut stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true); + if let Ok(s) = &mut stats { + s.gpu_time = landed; + } + if let Some(t) = &mut self.timer { + // A refresh that failed part way measures nothing: its spans describe work the + // caller is about to redo on the CPU. + match stats { + Ok(_) => t.finish(&mut encoder), + Err(_) => { + t.cancel(); + // The refresh is lost, but a measurement that came back with it is not: + // hand it to the next render rather than drop it on the fallback path. + if let Some(landed) = landed { + t.restore(landed); + } + } + } + } + let stats = stats?; queue.submit([encoder.finish()]); + if let Some(t) = &mut self.timer { + t.submitted(); + } Ok(stats) }); if result.is_err() { @@ -1129,7 +1215,7 @@ impl Compositor { stats.evicted += evicted; freed += bytes; if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { - submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); self.staged = 0; self.recorded = 0; work = 0; @@ -1404,7 +1490,7 @@ impl Compositor { if self.recorded >= FLUSH_OPS && let Some(last) = batch.as_deref_mut() { - submit_batch(device, queue, encoder, self.health.as_ref(), last); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), last); self.staged = 0; self.recorded = 0; stats.flushes += 1; @@ -2039,9 +2125,14 @@ fn submit_batch( queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, health: Option<&DeviceHealth>, + timer: Option<&mut timing::GpuTimer>, last: &mut Option, ) { - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let mut done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + if let Some(t) = timer { + // One refresh, several command buffers: the measurement spans all of them. + t.split(&mut done, encoder); + } let prev = last.replace(queue.submit([done.finish()])); #[cfg(not(target_arch = "wasm32"))] if let Some(prev) = prev { diff --git a/crates/gpu/src/timing.rs b/crates/gpu/src/timing.rs new file mode 100644 index 0000000000..e79548d84c --- /dev/null +++ b/crates/gpu/src/timing.rs @@ -0,0 +1,308 @@ +//! GPU execution time of a composite (#7186), measured with wgpu timestamp queries. +//! +//! `GpuCanvas::refresh` reports `composite_ms`, the CPU cost of *encoding and submitting* the +//! composite. That is not the cost of running it: at 24 MP the encode costs ~7.7 ms and the GPU +//! then spends ~171 ms finishing the work, so every consumer of `composite_ms` under-reports a +//! GPU-bound canvas by more than an order of magnitude and cannot see a regression that moves +//! work onto the GPU. +//! +//! Waiting for the GPU to report the true number would serialise the two processors and make the +//! app slower, so this measures instead of waiting: a timestamp is written on the GPU timeline at +//! the start and end of every command encoder a refresh submits, and the values are read back +//! asynchronously. The sum of the spans is the time the GPU spent executing that refresh, with +//! the CPU encoding gaps between submissions excluded. Nothing blocks; the numbers simply arrive +//! a frame or two later, so each measurement carries the pixel count of the refresh it belongs to +//! and is never attributed to whichever refresh happens to be in flight when it lands. +//! +//! **What it does not count.** The spans run between bottom-of-pipe timestamps inside the +//! refresh's own command buffers. Two kinds of GPU work fall outside them: queue-level uploads +//! (`Queue::write_texture`), which wgpu executes in its own command buffer before ours, and +//! whatever the GPU still does after the final timestamp retires — the end-of-batch writeback of +//! the render targets. Measured against a waited-for wall clock on Intel UHD P630 / Mesa, with +//! no uploads outstanding, the uncounted remainder is **~5 % of the reported time**, and it is +//! proportional to the composite rather than a fixed submit latency (1.0 ms at 6 MP, 4.5 ms at +//! 24 MP, 7.6 ms at 42.6 MP, against 21 / 83 / 158 ms reported). So the number is a slight +//! under-report of the GPU's total contribution, by a few per cent rather than the ~22x of the +//! encode-and-submit time it replaces. +//! +//! **What it costs.** Nothing measurable. The same refresh on the same device with +//! `PHOTOCRAFT_NO_GPU_TIMING` set waits 88.1 ms against 87.9 ms measured — a difference of +//! −0.2 ms on an ~88 ms refresh, i.e. inside the noise of a shared host. Per refresh the +//! queries add two timestamp writes (two more per flush), one resolve and one copy of at most +//! 1 KiB, a `map_async` and an `Arc` allocation; the query set and both buffers are allocated +//! once per compositor and are size-independent, so a resize reallocates nothing. +//! +//! The measurement is *optional by construction*. Without `TIMESTAMP_QUERY` and +//! `TIMESTAMP_QUERY_INSIDE_ENCODERS` on the device (older drivers, most browsers, the Noop +//! backend), [`GpuTimer::new`] returns `None` and callers report no GPU time at all rather than a +//! plausible wrong number — reporting a plausible wrong number is the failure this fixes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// The GPU execution time of one composite, with the refresh it measured. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuTiming { + /// Time the GPU spent executing the refresh's command buffers (ms). Excludes queue-level + /// uploads, which run in wgpu's own command buffer ahead of ours, and the end-of-batch work + /// after the last timestamp: see the module doc for the size of that (~5 % here, more on a + /// refresh that uploads). + pub ms: f64, + /// Pixels that refresh composited (so a late measurement stays interpretable). + pub px: u64, + /// Command buffers the refresh submitted (1 unless it flushed). + pub spans: u32, +} + +/// Device features [`GpuTimer`] needs. +pub const FEATURES: wgpu::Features = wgpu::Features::TIMESTAMP_QUERY.union(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS); + +/// Spans one measurement may hold. A refresh submits once per flush; a 200 MP document flushes a +/// handful of times, so this is far above anything real. A measurement that would exceed it is +/// dropped rather than truncated (a truncated sum is exactly the under-report being fixed). +const SPANS: u32 = 64; +/// Any span total above this is a wrapped or garbage timestamp, not a composite. +const SANE_MS: f64 = 600_000.0; + +/// Readback state of the buffer holding the resolved timestamps. +const UNMAPPED: u8 = 0; +const MAPPED: u8 = 1; +const FAILED: u8 = 2; + +/// Timestamp queries for one compositor, reused for every refresh. +/// +/// At most one measurement is in flight: while the last one is still being read back a refresh +/// goes unmeasured, which costs a sample rather than correctness. +pub struct GpuTimer { + set: wgpu::QuerySet, + /// Resolved timestamps (`QUERY_RESOLVE | COPY_SRC`). + resolve: wgpu::Buffer, + /// Host-visible copy of `resolve` (`MAP_READ | COPY_DST`). + read: wgpu::Buffer, + /// Nanoseconds per timestamp tick, as the queue reports it. + period_ns: f32, + /// Timestamps written into the current recording. + written: u32, + /// Whether a span is open (a start timestamp waiting for its end). + open: bool, + /// Pixels of the recording in progress. + px: u64, + /// The recording overflowed [`SPANS`]: finish without resolving. + overflowed: bool, + /// The recording in progress has already had command buffers submitted (it flushed), so its + /// query slots may still be in use by the GPU. + flushed: bool, + /// A recording was abandoned after it had already submitted timestamps. Writing a query + /// again resets that slot (wgpu-core resets per write), and resetting one an in-flight + /// submission is still writing is undefined, with no way to learn when it has drained. + /// That only happens when a refresh fails after a flush, which on this path means the + /// device faulted and the canvas is about to drop to the CPU compositor anyway, so + /// measuring simply stops: no number, which is the contract everywhere else here. + stopped: bool, + /// Timestamps and pixels of the measurement being read back, and its readback flag. + in_flight: Option<(u32, u64, Arc)>, + /// [`Self::finish`] recorded a readback that [`Self::submitted`] has yet to map. Without + /// this, a refresh that starts before the last measurement lands would map the readback + /// buffer a second time while the first map is still pending, which wgpu rejects. + unmapped: bool, + /// The most recent completed measurement, until a caller takes it. + ready: Option, +} + +impl std::fmt::Debug for GpuTimer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GpuTimer").field("recording", &self.open).field("in_flight", &self.in_flight.is_some()).field("ready", &self.ready).finish() + } +} + +impl GpuTimer { + /// A timer for `device`, or `None` when it has no timestamp queries (the caller then reports + /// no GPU time, never an estimate). + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option { + if !device.features().contains(FEATURES) { + return None; + } + let period_ns = queue.get_timestamp_period(); + if !period_ns.is_finite() || period_ns <= 0.0 { + return None; + } + let bytes = u64::from(SPANS) * 2 * 8; + Some(Self { + set: device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("pc_compose_time"), ty: wgpu::QueryType::Timestamp, count: SPANS * 2 }), + resolve: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_resolve"), + size: bytes, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + read: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_read"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + period_ns, + written: 0, + open: false, + px: 0, + overflowed: false, + flushed: false, + stopped: false, + in_flight: None, + unmapped: false, + ready: None, + }) + } + + /// Start measuring a refresh of `px` pixels, opening its first span in `encoder`. Does + /// nothing while a previous measurement is still being read back (that refresh goes + /// unmeasured) — call [`Self::collect`] first to give it the chance to land. + pub fn start(&mut self, encoder: &mut wgpu::CommandEncoder, px: u64) { + if self.stopped || self.in_flight.is_some() || self.open { + return; + } + self.written = 0; + self.overflowed = false; + self.flushed = false; + self.px = px; + self.open = true; + self.stamp(encoder); + } + + /// Close the span in `done` and open the next in `next`: one refresh that submits several + /// command buffers is one measurement, summed over them. + pub fn split(&mut self, done: &mut wgpu::CommandEncoder, next: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(done); + self.stamp(next); + self.flushed = true; + } + + /// Close the last span and record the resolve and readback copy into `encoder`, which the + /// caller is about to submit. Call [`Self::submitted`] once it has. + pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(encoder); + self.open = false; + let count = self.written; + if self.overflowed || count < 2 || !count.is_multiple_of(2) { + self.abandon(); + return; + } + let bytes = u64::from(count) * 8; + encoder.resolve_query_set(&self.set, 0..count, &self.resolve, 0); + encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes); + self.in_flight = Some((count, self.px, Arc::new(AtomicU8::new(UNMAPPED)))); + self.unmapped = true; + self.written = 0; + // This recording is over: its slots are protected by `in_flight` until the readback + // lands, not by `flushed`. Leaving `flushed` set would let a later refresh that + // recorded nothing (because `start` found the readback still in flight) stop measuring + // for good when it failed, on the strength of this recording's flush. + self.flushed = false; + } + + /// Map the readback buffer after the caller submitted the encoder [`Self::finish`] wrote + /// into. The map completes on a later device poll; nothing blocks here. + pub fn submitted(&mut self) { + if !self.unmapped { + return; + } + self.unmapped = false; + let Some((count, _, flag)) = &self.in_flight else { return }; + let (bytes, flag) = (u64::from(*count) * 8, flag.clone()); + self.read.slice(0..bytes).map_async(wgpu::MapMode::Read, move |r| { + flag.store(if r.is_ok() { MAPPED } else { FAILED }, Ordering::Release); + }); + } + + /// Abandon the recording in progress (the refresh failed part way, so its spans mean + /// nothing). + pub fn cancel(&mut self) { + self.open = false; + self.abandon(); + } + + /// Drop the recording in progress. Once its timestamps have reached the GPU its slots can't + /// be reused safely, so measuring stops for good rather than resetting a live query. + fn abandon(&mut self) { + self.stopped |= self.flushed; + self.written = 0; + self.overflowed = false; + self.flushed = false; + } + + /// Whether a measurement is being read back (so this refresh won't be measured). + pub fn busy(&self) -> bool { + self.in_flight.is_some() + } + + /// Read back a finished measurement if the map completed, and return whatever is ready. + /// Cheap and non-blocking; callers poll it per refresh or per frame. + pub fn collect(&mut self) -> Option { + if let Some((count, px, flag)) = &self.in_flight { + match flag.load(Ordering::Acquire) { + MAPPED => { + let (count, px) = (*count, *px); + self.ready = self.value(count, px); + self.read.unmap(); + self.in_flight = None; + } + FAILED => { + self.in_flight = None; + } + _ => {} + } + } + self.ready + } + + /// Put a measurement back after a caller took it but could not use it (its refresh failed), + /// so the sample is reported by the next one instead of being lost on the fallback path. + /// A measurement that has landed since wins: it is the newer of the two. + pub fn restore(&mut self, t: GpuTiming) { + self.ready.get_or_insert(t); + } + + /// Take the most recent completed measurement, leaving none behind, so a stale number is + /// never reported twice as if it were new. + pub fn take(&mut self) -> Option { + self.collect(); + self.ready.take() + } + + /// Sum the resolved spans of a mapped readback. + fn value(&self, count: u32, px: u64) -> Option { + let bytes = u64::from(count) * 8; + let view = self.read.slice(0..bytes).get_mapped_range().ok()?; + let mut ticks = 0u64; + for pair in view.as_chunks::<16>().0 { + let (a, b) = pair.split_at(8); + let start = u64::from_le_bytes(a.try_into().ok()?); + let end = u64::from_le_bytes(b.try_into().ok()?); + // A driver that reset or wrapped its timer gives an end before its start: the whole + // measurement is then unusable, and no number is better than a wrong one. + if end < start { + return None; + } + ticks += end - start; + } + drop(view); + let ms = ticks as f64 * f64::from(self.period_ns) / 1.0e6; + (ms.is_finite() && ms < SANE_MS).then_some(GpuTiming { ms, px, spans: count / 2 }) + } + + /// Write one timestamp, noting an overflow instead of wrapping round the query set. + fn stamp(&mut self, encoder: &mut wgpu::CommandEncoder) { + if self.written >= SPANS * 2 { + self.overflowed = true; + return; + } + encoder.write_timestamp(&self.set, self.written); + self.written += 1; + } +} diff --git a/crates/gpu/tests/timing.rs b/crates/gpu/tests/timing.rs new file mode 100644 index 0000000000..24c2057253 --- /dev/null +++ b/crates/gpu/tests/timing.rs @@ -0,0 +1,282 @@ +//! The canvas timing must measure GPU *execution*, not encode-and-submit (#7186). +//! +//! `composite_ms` reports the CPU cost of handing the composite to the driver, which on a +//! GPU-bound refresh is a small fraction of what the refresh costs — at 24 MP it read 7.7 ms for +//! work that took ~171 ms. `Compositor::measure_gpu_time` adds timestamp queries whose spans are +//! summed into a [`GpuTiming`]. This test pins that down from both sides: the measurement must +//! track the waited-for wall clock and must be several times the submit time, so an accidental +//! return to timing the submit fails it. +//! +//! **Hardware only.** A software rasteriser composites on the CPU, where submit and execution +//! are the same work, so the whole premise disappears and the test would pass on a bug. It +//! therefore refuses to run on a CPU adapter and says so, as every GPU timing measurement in +//! this project must (egui_kittest's `default_wgpu_setup` prefers CPU adapters on purpose). + +use std::time::Instant; + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_geom::Rect; +use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing}; + +/// Pixels in `r`, as the compositor counts them. +fn area(r: Rect) -> u64 { + r.width() as u64 * r.height() as u64 +} + +fn block_on(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::yield_now(); + } +} + +/// A real GPU with timestamp queries, or `None` with the reason printed. +fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&options)) else { + eprintln!("SKIP gpu timing: no adapter"); + return None; + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu || info.backend == wgpu::Backend::Noop { + // Not a skip we can be quiet about: a GPU timing test on llvmpipe measures nothing. + eprintln!("SKIP gpu timing: {} is a software adapter, which cannot show a GPU/CPU split", info.name); + return None; + } + if !adapter.features().contains(timing::FEATURES) { + eprintln!("SKIP gpu timing: {} has no timestamp queries", info.name); + return None; + } + let descriptor = wgpu::DeviceDescriptor { required_features: timing::FEATURES, ..Default::default() }; + let Ok((device, queue)) = block_on(adapter.request_device(&descriptor)) else { + eprintln!("SKIP gpu timing: no device with timestamp queries"); + return None; + }; + let mut comp = Compositor::new(&device); + assert!(comp.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build"); + eprintln!("gpu timing on {} ({:?}, {:?})", info.name, info.device_type, info.backend); + Some((device, queue, comp)) +} + +fn rnd(seed: u32, i: u32) -> f32 { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^= h >> 16; + (h & 0xffff) as f32 / 65535.0 +} + +/// A document whose composite is clearly GPU-bound: several full-canvas blended photo layers. +fn document(size: Size) -> Document { + let mut doc = Document::with_background("timing", size, ColorMode::Rgb, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); + let fmt = doc.pixel_format(); + let rect = Rect::from_xywh(0, 0, size.width, size.height); + for (i, blend) in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Overlay, BlendMode::SoftLight].into_iter().enumerate() { + let mut layer = Layer::raster(format!("l{i}"), fmt); + let channels = fmt.channels(); + let px: Vec = (0..rect.width() * rect.height()).flat_map(|p| (0..channels).map(move |c| rnd(7 + i as u32 * 131 + c as u32 * 7919, p))).collect(); + if let Some(s) = layer.surface_mut() { + s.write_region(rect, &px); + } + layer.blend = blend; + layer.opacity = 0.8; + doc.layers.push(layer); + } + doc +} + +/// Render `region` and return (submit ms, waited-for wall ms, the measurement). +fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option) { + let t0 = Instant::now(); + comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU"); + let submit_ms = t0.elapsed().as_secs_f64() * 1000.0; + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + // The readback map completes on a poll, and its callback runs on the next one. + let mut timing = None; + for _ in 0..4 { + timing = comp.take_gpu_timing(); + if timing.is_some() { + break; + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + } + (submit_ms, wall_ms, timing) +} + +#[test] +fn gpu_time_tracks_execution_not_submission() { + let Some((device, queue, mut comp)) = gpu() else { return }; + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + // Warm the residency and pipelines: the first refresh uploads every layer. + let _ = measure(&device, &queue, &mut comp, &doc, full); + + // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`, + // and CPU contention on a shared machine only ever inflates it — one run that happened to + // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison + // while the measurement itself was right. The GPU side is unaffected by that contention, + // so taking the least-disturbed encode tests the same property without the false failure. + let mut best: Option<(f64, f64, Option)> = None; + for _ in 0..3 { + let run = measure(&device, &queue, &mut comp, &doc, full); + if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) { + best = Some(run); + } + } + let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time"); + let t = timing.expect("a hardware device with timestamp queries must report a GPU time"); + eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); + + assert_eq!(t.px, area(full), "the measurement carries the pixels of the refresh it measured"); + assert!(t.spans >= 1, "at least one command buffer was timed"); + + // The premise: on this adapter the refresh really is dominated by GPU execution. A machine + // fast enough that it isn't cannot tell the two timings apart, so say so instead of failing. + if wall_ms < 3.0 * submit_ms { + eprintln!("SKIP the comparison: this GPU finishes a 6 MP composite in {wall_ms:.1} ms against {submit_ms:.1} ms of encoding"); + return; + } + + // Timing the submit again (the bug) would report roughly `submit_ms` here. + assert!(t.ms > 3.0 * submit_ms, "gpu time {:.1} ms looks like the submit time {submit_ms:.1} ms, not the execution", t.ms); + // …and it must be the real execution, not merely a bigger number: the waited-for wall clock + // is encode + execute, so the GPU span sits below it and accounts for most of it. + assert!(t.ms <= wall_ms * 1.25, "gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + assert!(t.ms >= (wall_ms - submit_ms) * 0.5, "gpu time {:.1} ms is far below the {:.1} ms the refresh waited", t.ms, wall_ms - submit_ms); + + // A quarter of the canvas is a quarter of the per-pixel work: a constant or a stale value + // would not follow it. + let quarter = Rect::from_xywh(0, 0, size.width / 2, size.height / 2); + let (_, _, small) = measure(&device, &queue, &mut comp, &doc, quarter); + let s = small.expect("the smaller refresh is measured too"); + eprintln!("quarter: gpu {:.1} ms for {} px against {:.1} ms for {} px", s.ms, s.px, t.ms, t.px); + assert_eq!(s.px, area(quarter)); + assert!(s.ms < t.ms * 0.6, "a quarter-canvas refresh took {:.1} ms against the full canvas's {:.1} ms", s.ms, t.ms); +} + +#[test] +fn an_unmeasured_compositor_reports_nothing() { + let Some((device, queue, _)) = gpu() else { return }; + let mut comp = Compositor::new(&device); + assert!(!comp.measures_gpu_time(), "timing is off until it is asked for"); + let doc = document(Size::new(256, 256)); + comp.render(&device, &queue, &doc, Rect::from_xywh(0, 0, 256, 256), |_, _| {}).expect("composites"); + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); +} + +#[test] +fn a_refresh_that_submits_several_times_is_one_measurement() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // A budget far below the document's layer pages makes the refresh evict as it goes, which is + // what makes it submit in batches: the measurement must span all of them, not just the last. + comp.set_memory_budget(24 << 20); + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + let _ = measure(&device, &queue, &mut comp, &doc, full); + let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full); + let t = timing.expect("a batched refresh is measured like any other"); + eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms); + assert_eq!(t.px, area(full)); + if t.spans < 2 { + eprintln!("SKIP the sum: this refresh still fit one command buffer"); + return; + } + assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + // What this bound excludes is summing only the last command buffer. A correct sum measures + // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so + // no single buffer of the several carries much over half of it, and timing only the last + // would land near 40 %. 0.6 sits between the two with room on both sides for a machine + // whose split is less even than this one's. + assert!( + t.ms >= (wall_ms - submit_ms) * 0.6, + "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?", + t.ms, + wall_ms - submit_ms + ); +} + +#[test] +fn refreshes_faster_than_the_readback_stay_valid() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // Refreshes arriving before the last measurement has been read back must simply go + // unmeasured. Mapping the readback buffer again while its first map is still pending is a + // validation error, and an unusable device drops the whole canvas to the CPU compositor. + let health = DeviceHealth::watch(&device); + comp.set_health(health.clone()); + let size = Size::new(600, 400); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + for i in 0..16 { + comp.render(&device, &queue, &doc, full, |_, _| {}).unwrap_or_else(|e| panic!("refresh {i} failed: {e}")); + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.fault(), None, "hurried refreshes must not fault the device"); + assert_eq!(health.errors(), 0, "no uncaptured wgpu errors"); +} + +/// A refresh that recorded nothing must not stop the timer for good. `finish` leaves the +/// measurement's slots protected by the pending readback, not by `flushed`; if `flushed` +/// survived a successful finish, the next refresh to fail while that readback was still in +/// flight would set `stopped` on the strength of the *earlier* refresh's flush — even though it +/// wrote no timestamps itself — and the compositor would silently never measure again. +#[test] +fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { + let Some((device, queue, _)) = gpu() else { return }; + let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries"); + + // A refresh that flushes once, then succeeds. + let mut first = device.create_command_encoder(&Default::default()); + let mut second = device.create_command_encoder(&Default::default()); + timer.start(&mut first, 1024); + timer.split(&mut first, &mut second); + queue.submit([first.finish()]); + timer.finish(&mut second); + queue.submit([second.finish()]); + timer.submitted(); + + // A later refresh fails before recording anything, because the readback is still in flight. + let mut third = device.create_command_encoder(&Default::default()); + timer.start(&mut third, 2048); + timer.cancel(); + drop(third); + + // The first measurement still lands... + let mut first_timing = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + first_timing = timer.take(); + if first_timing.is_some() { + break; + } + } + let landed = first_timing.expect("the flushed measurement completes"); + assert_eq!(landed.spans, 2, "both command buffers are one measurement"); + + // ...and the timer still measures afterwards, which is what the bug destroyed. + let mut next = device.create_command_encoder(&Default::default()); + timer.start(&mut next, 4096); + timer.finish(&mut next); + queue.submit([next.finish()]); + timer.submitted(); + let mut after = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + after = timer.take(); + if after.is_some() { + break; + } + } + let after = after.expect("the timer still measures after a refresh that recorded nothing"); + assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d8d943c81f..a5f1452dd1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1648,11 +1648,11 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: // An adjustment preview composited where the view was: catch up where it moved to. if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } if let Some(r) = crate::adjust_preview::live_uncovered(app, id, raw_key, visible) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } return true; } @@ -1693,7 +1693,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: log::info!("{e}; using the CPU compositor"); } if r.kind != crate::gpu_canvas::REFRESH_DEFERRED { - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } if r.kind == "full" { crate::notices::slow_cpu_refresh(app, id.0, r.composite_ms + r.upload_ms, r.fallback.as_deref()); @@ -2015,6 +2015,9 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option let (display, _) = canvas_display(app, &doc, None); let r = gpu.refresh(key, &frame.doc, frame.damage, display.as_deref()); app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + app.perf.record_gpu(t); + } crate::adjust_preview::proxy_drawn(app, &frame); } Some((frame.k, key, size)) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 1770979db1..65388d5049 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -295,6 +295,15 @@ impl GpuCanvas { } } + /// Take any GPU measurement that has landed since the last call, without compositing + /// anything. [`Compositor::render`] carries measurements out in its stats, but a refresh is + /// the only thing that collects them, so the last refresh before the app goes idle would + /// never be reported. The frame loop calls this so it is. + pub fn take_gpu_timing(&self) -> Option { + let mut r = self.rs.renderer.write(); + r.callback_resources.get_mut::()?.compositor.as_mut()?.take_gpu_timing() + } + /// GPU bytes the wgpu compositor holds: resident layer pages and caches (effect maps and /// lower-stack chunks). Keep the scorecard's memory measurements inclusive of reuse. pub fn compositor_bytes(&self) -> Option<(u64, usize)> { @@ -472,6 +481,8 @@ impl GpuCanvas { }, }; comp.set_health(self.health.clone()); + // Measured, not waited for: the timestamps come back a frame or two later. + comp.measure_gpu_time(device, queue); if let Some(b) = res.compositor_budget { comp.set_memory_budget(b); } @@ -636,8 +647,10 @@ impl GpuCanvas { Ok(stats) => { out.kind = if damage.is_some() { "gpu-rect" } else { "gpu-full" }; out.px = region.width() as u64 * region.height() as u64; + // Encoding and submitting only: the GPU is still running this work. out.composite_ms = now_ms() - t0; out.uploads = stats.tiles_uploaded as u64; + out.gpu = stats.gpu_time; return out; } Err(e) => out.fallback = Some(e.0), @@ -876,6 +889,14 @@ pub struct Refresh { pub upload_ms: f64, /// Layer tiles the wgpu compositor uploaded. pub uploads: u64, + /// How long the GPU spent executing a recent wgpu composite, once its timestamps came back + /// (a frame or two after that refresh, so usually an earlier one than this). It carries its + /// own pixel count and is never this refresh's unless they match. `None` when the device has + /// no timestamp queries, on the CPU compositor, and on wasm: no number at all is reported + /// rather than `composite_ms`, which measures encoding and submitting, not executing. It + /// leaves out layer-tile uploads (`uploads` above counts them), so a first refresh of a + /// document under-reports by more than the few per cent of a warm one. + pub gpu: Option, /// Why the wgpu compositor wasn't used. pub fallback: Option, } @@ -903,11 +924,42 @@ pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: imp if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { create.device_descriptor = std::sync::Arc::new(move |adapter| { on_adapter(adapter); - wgpu::DeviceDescriptor { label: Some("photocraft wgpu device"), required_limits: device_limits(adapter), ..Default::default() } + device_descriptor(adapter) }); } } +/// How the app asks for its device: egui's defaults, the adapter's texture and buffer size +/// limits ([`device_limits`]), and timestamp queries where the adapter has them, so the canvas +/// can report how long the GPU actually spends compositing ([`photocraft_gpu::GpuTimer`]). +/// A bench or test that builds its own device should use this rather than [`device_limits`] +/// alone, or it measures a device the app never creates. (`bevel_bench`, `fx_bench` and +/// `interactive_bench` still call [`device_limits`] directly and so report no GPU time.) +pub fn device_descriptor(adapter: &wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { + label: Some("photocraft wgpu device"), + required_limits: device_limits(adapter), + required_features: timing_features(adapter), + ..Default::default() + } +} + +/// The timestamp-query features when the adapter supports all of them, else none: asking for a +/// feature an adapter lacks fails device creation, and a canvas without GPU timing is far better +/// than no canvas. +/// +/// `PHOTOCRAFT_NO_GPU_TIMING` suppresses the request as well as the timer +/// ([`photocraft_gpu::Compositor::measure_gpu_time`]), so the variable also rescues a driver +/// that advertises the features and then fails or misbehaves on device creation — not only one +/// whose timestamps come back wrong. +pub fn timing_features(adapter: &wgpu::Adapter) -> wgpu::Features { + let want = photocraft_gpu::timing::FEATURES; + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return wgpu::Features::empty(); + } + if adapter.features().contains(want) { want } else { wgpu::Features::empty() } +} + /// egui's base limits (WebGL2's on GL), clamped to adapter support, with its maximum texture and buffer sizes. pub fn device_limits(adapter: &wgpu::Adapter) -> wgpu::Limits { device_limits_for(adapter.get_info().backend, adapter.limits()) @@ -938,7 +990,9 @@ pub struct Perf { pub ui_ms: f64, /// Worst UI frame of the last ~second (ms). pub ui_max_ms: f64, - /// Last canvas refresh: CPU composite (ms). + /// Last canvas refresh, CPU side (ms): the CPU composite on the CPU path, and on the GPU + /// path the cost of *encoding and submitting* the composite — not of running it, which is + /// `gpu_ms`. pub composite_ms: f64, /// Last canvas refresh: conversion + upload (ms). pub upload_ms: f64, @@ -950,6 +1004,16 @@ pub struct Perf { pub gpu: bool, /// Layer tiles uploaded by the last GPU composite. pub gpu_uploads: u64, + /// How long the GPU spent executing a recent composite (ms), from timestamp queries read + /// back after the fact. `None` on the CPU compositor, on a device without timestamp queries + /// and on wasm — there is then no honest number to report, and `composite_ms` is not one. + /// Excludes layer-tile uploads and end-of-batch work (see `photocraft_gpu::timing`), so it + /// is a few per cent under the GPU's full share of a warm refresh and further under a cold + /// one. + pub gpu_ms: Option, + /// Pixels of the refresh `gpu_ms` measured, which is a frame or two behind + /// `last_refresh_px` and may be a different size. + pub gpu_ms_px: u64, /// Why the last refresh fell back to the CPU compositor (None = GPU composited). pub gpu_fallback: Option, /// Adapter, backend, driver and fallback state (Help › System Info). @@ -1102,10 +1166,28 @@ impl Perf { self.upload_ms = upload_ms; if !kind.starts_with("gpu") { self.gpu_uploads = 0; + // The CPU compositor did this one: there is no GPU time, and the last GPU + // refresh's would be read as this refresh's. + self.gpu_ms = None; + self.gpu_ms_px = 0; } else { self.gpu_fallback = None; } } + + /// Record a canvas refresh and any GPU measurement that came back with it. + pub fn record_refresh(&mut self, r: &Refresh) { + self.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + self.record_gpu(t); + } + } + + /// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]). + pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) { + self.gpu_ms = Some(t.ms); + self.gpu_ms_px = t.px; + } pub fn span(&mut self, name: &'static str, ms: f64) { self.spans.insert(name, (ms * 100.0).round() / 100.0); } @@ -2380,6 +2462,36 @@ mod mip_tests; mod tests { use super::*; + /// A GPU measurement arrives a frame or two after its refresh, so most GPU refreshes carry + /// none; the last one reported must stand until a newer one lands. A CPU refresh is + /// different: there is no GPU time at all then, and keeping the old number would show it as + /// this refresh's. Runs anywhere — the GPU timing tests need hardware and CI has none. + #[test] + fn a_gpu_measurement_stands_until_replaced_and_a_cpu_refresh_clears_it() { + let timing = |ms, px| photocraft_gpu::GpuTiming { ms, px, spans: 1 }; + let refresh = |kind, gpu| Refresh { kind, gpu, ..Default::default() }; + let mut perf = Perf::default(); + assert_eq!(perf.gpu_ms, None, "nothing is reported before a measurement"); + + perf.record_refresh(&refresh("gpu-full", Some(timing(83.9, 24_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000)); + + // The common case: a GPU refresh whose own measurement has not come back yet. + perf.record_refresh(&refresh("gpu-rect", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000), "the last measurement stands"); + + perf.record_refresh(&refresh("gpu-rect", Some(timing(11.2, 1_098_640)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(11.2), 1_098_640), "a newer measurement replaces it"); + + // The CPU compositor did this one: the GPU number belongs to no refresh on screen. + perf.record_refresh(&refresh("full", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (None, 0), "a CPU refresh clears it rather than showing a stale one"); + + // And it comes back on the next GPU refresh that carries one. + perf.record_refresh(&refresh("gpu-full", Some(timing(42.0, 6_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(42.0), 6_000_000)); + } + #[test] fn device_limits_support_v3d_shader_variable_limit() { let supported = wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..wgpu::Limits::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f97c6968fc..2c23a29e36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2119,6 +2119,18 @@ impl eframe::App for PhotocraftApp { ctx.set_cursor_icon(egui::CursorIcon::Wait); } let ui_ms = gpu_canvas::now_ms() - t0; + // A GPU measurement lands a frame or two after the refresh it belongs to. Refreshes + // carry them out, but the last refresh before the app settles has none following it, so + // drain the timer here too or its number is never reported. Drain it either way: a + // measurement left sitting would surface later against an unrelated refresh. Report it + // only while the canvas is still on the GPU, since a CPU refresh clears `gpu_ms` + // precisely so a GPU number is never shown as that refresh's. + if let Some(gpu) = &self.gpu + && let Some(t) = gpu.take_gpu_timing() + && self.perf.last_refresh.starts_with("gpu") + { + self.perf.record_gpu(t); + } self.perf.frame(ui_ms); if self.stroke_trace.enabled() { self.stroke_trace.canvas(self.perf.frame_refresh_ms); From fa99e183b6a8ded4b23e3389bed2e5e5d0e7c4b1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 18 Oct 2026 17:04:55 +0000 Subject: [PATCH 0659/1452] Color panel field fills its group in a custom workspace too #2455 stretched the pro Color panel's SV field and hue strip to the dock group's height, but only the legacy dock told the field that height. The shared docking renderer from #3097 drew the panel without it, so once the layout was customized the field fell back to 120 pt. Both docks now offer the group body's height through one helper. --- crates/ui-egui/src/dock.rs | 5 +---- crates/ui-egui/src/panel_docking.rs | 1 + crates/ui-egui/src/panel_docking_tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/panels.rs | 11 ++++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index deaea4e783..0412c4fc67 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -521,10 +521,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.set_min_height(inner); body(app, ui, g, tab); } else { - // The pro Color field stretches to the group's height instead of a fixed 120 pt. - if t.pro && g == Group::Color && tab == 0 { - ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), inner)); - } + crate::panels::offer_color_field_fill(ui, t.pro, g, tab); // No scroll fade: it darkens real colours, swatches and previews at the edge // (#2726); the scroll bar already shows there is more. ui.spacing_mut().scroll.fade.strength = 0.0; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a62ecafff3..f68c3e46e7 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -547,6 +547,7 @@ fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) { if group.scrolls_itself(tab) { crate::panels::dock_body(app, ui, *group, tab); } else { + crate::panels::offer_color_field_fill(ui, pro, *group, tab); egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index a4ae378eda..193ff2e6b8 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -585,6 +585,23 @@ fn explicit_reveal_activates_existing_tab_and_expands_custom_group() { assert_eq!(app.ui.docking, before); } +// #2455 in a custom workspace: the pro Color field fills a tall floating group, not 120 pt. +#[test] +fn pro_color_field_fills_a_tall_custom_group() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"color","x":40,"y":60,"width":320,"height":560})).unwrap(); + assert!(app.ui.docking.is_some()); + let h = harness(app, vec2(1280.0, 800.0)); + // The SV field and the 14 pt hue strip beside it are draggable, equally tall and 8 pt apart. + let drags: Vec = + h.ctx.viewport(|v| v.this_pass.widgets.layers().flat_map(|(_, w)| w).filter(|w| w.sense.senses_drag()).map(|w| w.rect).collect()); + let field = drags + .iter() + .find(|f| drags.iter().any(|s| s.width() == 14.0 && s.top() == f.top() && s.height() == f.height() && (s.left() - f.right() - 8.0).abs() < 0.5)) + .expect("Color field with its hue strip"); + assert!(field.height() > 300.0, "field stays short in a 560 pt group: {field:?}"); +} + // #4028: in a custom workspace too, a new adjustment layer brings Properties to the front. #[test] fn a_new_adjustment_layer_activates_properties_in_a_custom_workspace() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 10476013ee..f2961fd7b6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3794,10 +3794,19 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) { } /// Where the dock tells the Color field how tall its group body is. -pub(crate) fn color_field_fill_id() -> egui::Id { +fn color_field_fill_id() -> egui::Id { egui::Id::new("color-field-fill-height") } +/// The pro Color field stretches to its dock group's height instead of a fixed 120 pt: a dock +/// calls this with the group body's `ui` before drawing the tab. +pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::dock::Group, tab: usize) { + if pro && group == crate::dock::Group::Color && tab == 0 { + let inner = ui.available_height().max(0.0); + ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner)); + } +} + /// Height of the Color field: the group body's height (when docked) less the hex/RGB readout, /// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. fn color_field_height(avail: Option, width: f32) -> f32 { From 1876a3e585d532944be1a647545bc78273d2a028 Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 21:04:36 +0300 Subject: [PATCH 0660/1452] fix(atn): normalise recorded active layer to target form and expand refusal tests (#7248) --- crates/engine/src/atn_cmds.rs | 10 ++++++++- crates/engine/src/atn_cmds/tests.rs | 31 +++++++++++++++++++++++++++ crates/engine/src/jobs.rs | 3 +-- crates/engine/src/layer_multi_cmds.rs | 22 +++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 45c61d55aa..40ae238a68 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3080,7 +3080,15 @@ pub fn export( skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); continue; } - return Err(format!("step {} of `{}` (`{id}`) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name)); + let step_name = match params.get("layer") { + Some(Value::Number(n)) => format!("`{id}` with numeric layer {n}"), + _ => format!("`{id}`"), + }; + return Err(format!( + "step {} of `{}` ({step_name}) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", + i + 1, + a.name + )); }; item.enabled = a.step_enabled(i); item.with_dialog = a.dialogs.contains(&i); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c21ae6590c..667285c0b9 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -976,19 +976,50 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"})); assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target"); + let err_msg = err_delete.unwrap_err().to_string(); + assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}"); // Same for layer.duplicate with numeric ID s.actions.list.push(Action::new("DuplicateNumeric", vec![("layer.duplicate".into(), json!({"layer": 42, "name": "Copy"}))])); let err_dup = s.execute("actions.save", json!({"action": "DuplicateNumeric"})); assert!(err_dup.is_err(), "strict export must reject numeric layer ID for layer.duplicate rather than falling back to current target"); + // Same for duplicate into new document with numeric ID + s.actions.list.push(Action::new("DuplicateNewDocNumeric", vec![("layer.duplicate".into(), json!({"layer": 42, "document": "new"}))])); + let err_dup_new = s.execute("actions.save", json!({"action": "DuplicateNewDocNumeric"})); + assert!(err_dup_new.is_err(), "strict export must reject numeric layer ID for duplicate into new document"); + // Same for layer.translate with numeric ID s.actions.list.push(Action::new("TranslateNumeric", vec![("layer.translate".into(), json!({"layer": 42, "dx": 5, "dy": 5}))])); let err_trans = s.execute("actions.save", json!({"action": "TranslateNumeric"})); assert!(err_trans.is_err(), "strict export must reject numeric layer ID for layer.translate rather than falling back to current target"); + // Same for layer.setProps with numeric ID + s.actions.list.push(Action::new("SetPropsNumeric", vec![("layer.setProps".into(), json!({"layer": 42, "opacity": 0.5}))])); + let err_set = s.execute("actions.save", json!({"action": "SetPropsNumeric"})); + assert!(err_set.is_err(), "strict export must reject numeric layer ID for layer.setProps"); + + // Same for visibility with numeric ID + s.actions.list.push(Action::new("HideNumeric", vec![("layer.hideLayers".into(), json!({"layer": 42}))])); + let err_hide = s.execute("actions.save", json!({"action": "HideNumeric"})); + assert!(err_hide.is_err(), "strict export must reject numeric layer ID for layer.hideLayers"); + + // Same for edit.transform with numeric ID + s.actions.list.push(Action::new("TransformNumeric", vec![("edit.transform".into(), json!({"layer": 42, "dx": 5, "dy": 5}))])); + let err_transf = s.execute("actions.save", json!({"action": "TransformNumeric"})); + assert!(err_transf.is_err(), "strict export must reject numeric layer ID for edit.transform"); + // With skipUnsupported: true, numeric layer references are skipped let skipped = s.execute("actions.save", json!({"action": "DeleteNumeric", "skipUnsupported": true})).unwrap(); assert_eq!(skipped["steps"], 0); assert_eq!(skipped["skipped"][0]["id"], "layer.delete"); + + // Blend-mode shortcut records layer.setProps with active layer ID, which normalises to the target form + let active_id = s.active().unwrap().active_layer.unwrap().0; + s.execute("actions.record", json!({"name": "RecordBlend"})).unwrap(); + s.execute("layer.setProps", json!({"layer": active_id, "blend": "multiply"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved_blend = s.execute("actions.save", json!({"action": "RecordBlend"})).unwrap(); + assert_eq!(saved_blend["steps"], 1, "recorded blend shortcut step with active layer id must export successfully as target layer"); + assert_eq!(saved_blend["skipped"].as_array().unwrap().len(), 0); } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 47045a37c2..2b3158350d 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -797,8 +797,7 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = if self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_step(self, id, params) } else { params }; self.journal.push((id.to_string(), params)); } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..f5c3e5ce8d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -118,6 +118,28 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value { p } +/// Normalise parameters when recording an action step. +/// +/// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer` +/// parameter references the document's sole selected layer, normalises it to the target form +/// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when +/// replayed or exported to `.atn` (#7248). +pub(crate) fn recorded_step(s: &Session, id: &str, p: Value) -> Value { + if id == "layer.select" { + return recorded_selection(s, p); + } + let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(st) = s.active() else { return p }; + if st.selected_layers().as_slice() == [layer_id] { + let mut p = p; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + } + return p; + } + p +} + fn selection_target(s: &Session, p: &Value) -> Result { let Some(name) = p.get("name") else { if p.get("occurrence").is_some() { From 19ccfa9004adc773895505dc2c1c6d7157865afe Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:12:34 +0200 Subject: [PATCH 0661/1452] i18n(cs): bring the Czech catalog up to date Add 127 labels that the interface looks up through variables and that cs.tsv was missing (live keys only, checked against the code), update one hint to quote the translated option, and fix the capitalisation of five existing entries. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 144 +++++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..2d32fae394 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2369,11 +2369,11 @@ cameraRaw Color Barvy Apply At Canvas Bounds Použít efekt na hranicích plátna # Shared screen eyedropper and text Color Picker (#1128). - Color Picker (Text Color) Výběr barvy (Barva textu) + Color Picker (Text Color) Výběr barvy (barva textu) Color Picker ({name}) Výběr barvy ({name}) - Color Picker (Fill Color) Výběr barvy (Barva výplně) + Color Picker (Fill Color) Výběr barvy (barva výplně) Fill color Barva výplně - Color Picker (Stroke Color) Výběr barvy (Barva tahu) + Color Picker (Stroke Color) Výběr barvy (barva tahu) Stroke color Barva tahu Pick screen color Vybrat barvu obrazovky Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce @@ -2525,7 +2525,7 @@ fontWeight Italic Kurzíva Match Canvas Podle plátna Auto Adjust Opacity Automaticky upravit krytí Crop Shield color Barva stínění oříznutí - Color Picker (Crop Shield Color) Výběr barvy (Barva stínění oříznutí) + Color Picker (Crop Shield Color) Výběr barvy (barva stínění oříznutí) From Center Ze středu Link radii Propojit poloměry Star Ratio: Poměr hvězdy: @@ -2642,7 +2642,7 @@ cameraData Lens Objektiv Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení # Edit › Content-Aware Fill workspace - Sampling Brush Tool Nástroj Štětec vzorkování + Sampling Brush Tool Nástroj štětec vzorkování Sampling Area Overlay Překrytí oblasti vzorkování Show Sampling Area Zobrazit oblast vzorkování Indicates Označuje @@ -3782,7 +3782,7 @@ navigator Zoom Zvětšení Use the embedded profile ({profile}) Použít vložený profil ({profile}) Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile}) Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky - Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev. + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou „Ptát se při nesouladu profilů“ v nabídce Úpravy › Nastavení barev. Button Mode Režim tlačítka Button Mode On Režim tlačítka zapnutý @@ -3838,3 +3838,135 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + +# Labels the UI looks up through a variable (tool, glyph-group, Liquify and warp names, command +# parameters and choices humanized from their keys, history steps and contextual +# Camera Raw / layer labels) that other catalogs already covered. Translated the same way as +# above, from the plain meaning of the English label. + Export As: {name} Exportovat jako: {name} + Tool Nástroj + Arc Lower Oblouk dole + Arc Upper Oblouk nahoře + Arrows Šipky + Basic Latin Základní latinka + Bloat Nafouknout + Burn Ztmavení + Checkerboard Šachovnice + Clone Stamp Klonovací razítko + Color Sampler Tool Nástroj vzorkovač barev + Currency Měnové symboly + Cyrillic Cyrilice + Decrease Type Size Zmenšit velikost písma + Decrease Type Size by 10 Zmenšit velikost písma o 10 + Delete Snapshot Odstranit snímek + Detect Faces Rozpoznat obličeje + Dodge Zesvětlení + Erase with Brush or Pencil (hold) Mazat štětcem nebo tužkou (při podržení klávesy) + Fill with Background Color Vyplnit barvou pozadí + Fill with Background Color, Preserve Transparency Vyplnit barvou pozadí, zachovat průhlednost + Fill with Foreground Color Vyplnit barvou popředí + Fill with Foreground Color, Preserve Transparency Vyplnit barvou popředí, zachovat průhlednost + Forward Warp Deformovat tažením + Freeze Lasso Laso zmrazení + Freeze Mask Zmrazit oblast + Greek Řecké písmo + Hand Tool (hold) Nástroj ruka (při podržení klávesy) + History Brush Štětec historie + Increase Type Size Zvětšit velikost písma + Increase Type Size by 10 Zvětšit velikost písma o 10 + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: zvolte „Metrické“, „Optické“ nebo hodnotu v 1/1000 em (Alt+←/→ v místě textového kurzoru) + Latin 1 Latinka 1 + Latin Extended-A Rozšířená latinka A + Latin Extended-B Rozšířená latinka B + Lock Transparent Pixels Zamknout průhledné pixely + Make Selection from Path Vytvořit výběr z cesty + Mask None Zrušit maskování + Math Symbols Matematické symboly + New Snapshot Vytvořit snímek + Numbers Čísla + Pattern Fill… Výplň vzorkem… + Pick Color From Image Vybrat barvu z obrazu + Proof Setup… Nastavení nátisku… + Pucker Stáhnout + Push Left Tlačit doleva + Reconstruct All Rekonstruovat vše + Save Path Uložit cestu + Sponge Houba + Target Channel 3 Zacílit na kanál 3 + Target Channel 4 Zacílit na kanál 4 + Target Channel 5 Zacílit na kanál 5 + Target Channel 6 Zacílit na kanál 6 + Target Channel 7 Zacílit na kanál 7 + Target Channel 8 Zacílit na kanál 8 + Target Channel 9 Zacílit na kanál 9 + Target Composite Channel Zacílit na složený kanál + Temporary Dočasné + Thaw Mask Rozmrazit oblast + Toggle Layer Mask Overlay Přepnout překryv masky vrstvy + Twirl Clockwise Zvířit doprava + Twirl Counterclockwise Zvířit doleva + Warming Filter (85) Oteplovací filtr (85) + Zoom In (hold, drag to scrub) Přiblížit (při podržení klávesy, tažením plynule měníte zvětšení) + Zoom Out (hold) Oddálit (při podržení klávesy) + blue modrá + gray šedá + green zelená + none žádná + orange oranžová + red červená + violet fialová + yellow žlutá + Adobe-rgb-compat Kompatibilní s Adobe RGB (1998) + Any Bez omezení + Ask On Mismatch Ptát se při nesouladu profilů + Ask On Missing Ptát se při chybějícím profilu + Ask On Paste Ptát se při vkládání + Author Title Pracovní pozice autora + Based On Podle + Blend Text Gamma Gama prolnutí textu + Case Sensitive Rozlišovat velikost písmen + Ccw Proti směru hodinových ručiček + Change Find Změnit a najít další + Cmyk CMYK + Coated-cmyk CMYK pro křídový papír + Convert Převést + Copyright Url URL informací o autorských právech + Cw Po směru hodinových ručiček + Display-p3 Display P3 + Dont Enlarge Nezvětšovat + Forward Vpřed + Horizontal Distortion Vodorovné zkreslení + Intent Záměr převodu + Linear-srgb Lineární sRGB + Mask Channel Kanál masky + Monitor Profile Profil monitoru + Nearest Nejbližší soused + Policy Cmyk Zásady správy barev pro CMYK + Policy Gray Zásady správy barev pro šedou + Policy Rgb Zásady správy barev pro RGB + Prophoto-compat Kompatibilní s ProPhoto + R G B RGB + Rec2020 Rec. 2020 + Sgray sGray (tónová křivka sRGB) + Simulate Paper Simulovat papír + Source Channel Zdrojový kanál + Source Invert Invertovat zdroj + Source1 Channel Kanál zdroje 1 + Source1 Invert Invertovat zdroj 1 + Source2 Channel Kanál zdroje 2 + Source2 Invert Invertovat zdroj 2 + Title Název + Vertical Distortion Svislé zkreslení + Whole Word Celé slovo + Working Cmyk Pracovní CMYK + Working Gray Pracovní šedá + Working Rgb Pracovní RGB + Working-cmyk Pracovní CMYK + Warming Filter (LBA) Oteplovací filtr (LBA) + Warming Filter (81) Oteplovací filtr (81) + Cooling Filter (80) Ochlazovací filtr (80) + Cooling Filter (LBB) Ochlazovací filtr (LBB) + Cooling Filter (82) Ochlazovací filtr (82) +layers Fill Krytí výplně +layers Fill: Krytí výplně: +cameraRaw Dehaze Odstranění oparu From 9185b64b94c07458038e768f4b31778590ba93ee Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:45 -0400 Subject: [PATCH 0662/1452] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2c4b8cb1a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From ecc8fb4622d8a4c79435a8c393c857ec36179a23 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 14:34:20 -0400 Subject: [PATCH 0663/1452] HEIF: tag a BT.709 image with a linear transfer as linear sRGB cicp_profile left every integer BT.709 image untagged (read as sRGB), including transfer 8 (linear), which cicp_rgb already maps to linear sRGB. Only the SDR transfers stay untagged now. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/flat.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 8478a0b78c..fb270e78ac 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -60,11 +60,11 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { /// The profile a HEIF's `nclx` colour description stands for, when the file has no ICC profile /// (#6752): a BT.2020 or P3 image is tagged so, and a PQ or HLG one, which the decoder turned into /// linear light, gets a linear profile on its primaries. BT.709 SDR stays untagged, read as sRGB -/// as before. +/// as before; BT.709 with a linear transfer (8) is tagged linear sRGB. fn cicp_profile(img: &Image) -> Option>> { let c = img.cicp?; let linear = img.sample_type().is_float(); - if c.primaries == 1 && !linear { + if c.primaries == 1 && !linear && c.transfer != 8 { return None; } // Unspecified primaries on an HDR image: BT.2100's, which PQ and HLG are defined with. @@ -902,6 +902,9 @@ mod cicp_tests { for transfer in [1, 2, 13] { assert_eq!(cicp_profile(&tagged(CSample::U16, codecs::Cicp { primaries: 1, transfer, matrix: 6, full_range: false })), None); } + // BT.709 with a linear transfer: linear sRGB, not read as sRGB. + let linear = codecs::Cicp { primaries: 1, transfer: 8, matrix: 6, full_range: true }; + assert_eq!(description(cicp_profile(&tagged(CSample::U16, linear))).as_deref(), Some(Builtin::LinearSrgb.description())); let p3 = codecs::Cicp { primaries: 12, transfer: 13, matrix: 6, full_range: true }; assert_eq!(description(cicp_profile(&tagged(CSample::U8, p3))).as_deref(), Some(Builtin::DisplayP3.description())); assert_eq!(cicp_profile(&Image::new(1, 1, ChannelLayout::Rgb, CSample::U8).unwrap()), None, "no nclx, no profile"); From d90346d148af0d3bd161c55e6ff5fc1b971b13f3 Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 21:34:53 +0300 Subject: [PATCH 0664/1452] fix(atn): capture pre-command selection so recorded sole layer delete omits numeric id (#7248) --- crates/engine/src/atn_cmds/tests.rs | 10 ++++++++++ crates/engine/src/jobs.rs | 9 +++++---- crates/engine/src/layer_multi_cmds.rs | 9 ++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 667285c0b9..fe8ba02707 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1022,4 +1022,14 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let saved_blend = s.execute("actions.save", json!({"action": "RecordBlend"})).unwrap(); assert_eq!(saved_blend["steps"], 1, "recorded blend shortcut step with active layer id must export successfully as target layer"); assert_eq!(saved_blend["skipped"].as_array().unwrap().len(), 0); + + // Record explicit layer.delete of the sole selected layer in a two-layer document + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + let second_id = s.active().unwrap().active_layer.unwrap().0; + s.execute("actions.record", json!({"name": "RecordDelete"})).unwrap(); + s.execute("layer.delete", json!({"layer": second_id})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved_delete = s.execute("actions.save", json!({"action": "RecordDelete"})).unwrap(); + assert_eq!(saved_delete["steps"], 1, "recorded explicit delete of sole selected layer must export successfully as target delete"); + assert_eq!(saved_delete["skipped"].as_array().unwrap().len(), 0); } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 2b3158350d..534e6941de 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -726,6 +726,7 @@ impl Session { self.jobs.spawn = background; // Commands this one runs on its own behalf journal after this mark (#3067). let nested_from = self.journal.len(); + let pre_selected = if self.actions.recording.is_some() { self.active().map(|st| st.selected_layers()) } else { None }; // A replayable command is recorded as one step, so recording pauses while it runs: an // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. @@ -757,7 +758,7 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { - self.after_command(id, params, spec.journal, Some(nested_from)); + self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref()); Ok(Started::Done(v)) } } @@ -782,7 +783,7 @@ impl Session { /// dropped, so a recorded action replays the command once, not its steps as well (#3067). /// Edit › Transform › Again (and Again on a Copy) is not replayable and keeps the concrete /// steps it ran instead. - fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option) { + fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option, pre_selected: Option<&[LayerId]>) { // A layer-mask view ends when another layer becomes active (#196). if let Some(st) = self.active_mut() { crate::mask_view_cmds::fix(st); @@ -797,7 +798,7 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = if self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_step(self, id, params) } else { params }; + let params = if self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected) } else { params }; self.journal.push((id.to_string(), params)); } } @@ -854,7 +855,7 @@ impl Session { // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. if r.is_ok() { - self.after_command(command, params.clone(), *journal, Some(nested_from)); + self.after_command(command, params.clone(), *journal, Some(nested_from), None); } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index f5c3e5ce8d..bfe43bc472 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -124,13 +124,16 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value { /// parameter references the document's sole selected layer, normalises it to the target form /// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when /// replayed or exported to `.atn` (#7248). -pub(crate) fn recorded_step(s: &Session, id: &str, p: Value) -> Value { +pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Option<&[LayerId]>) -> Value { if id == "layer.select" { return recorded_selection(s, p); } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; - let Some(st) = s.active() else { return p }; - if st.selected_layers().as_slice() == [layer_id] { + let is_sole_selected = match pre_selected { + Some(sel) => sel == [layer_id], + None => s.active().is_some_and(|st| st.selected_layers().as_slice() == [layer_id]), + }; + if is_sole_selected { let mut p = p; if let Some(fields) = p.as_object_mut() { fields.remove("layer"); From 4e5f4ca617b64e4b0d7933bc2ecbb05e4c26d121 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 00:11:41 +0530 Subject: [PATCH 0665/1452] Marching ants in device pixels, like Photoshop's (#7489) The ants were snapped to the middle of an egui point and drawn one point wide without antialiasing. At a fractional display scale (125 %, 150 %) a point is not a whole number of pixels, so one edge of a selection came out 2 px thick and the next 1 px, and dashes were 5 px here and 4 px there. New ants.rs draws every ants outline (committed selection, marquee/lasso/ zoom/move-box drag previews, Free Transform, Content-Aware Move) in device pixels: every edge ceil(ppp) px thick, dashes and gaps 4x the thickness, a screen-fixed pattern whose rows shift against each other so dash ends slant as in Photoshop, and an unchanged march speed. Straight edges are pixel-aligned mesh quads cut to the view; slanted edges stay antialiased line shapes at the same thickness. Tests: ants::tests (unit), tests/marching_ants_pixels.rs (GPU render at 1/1.25/1.5/2x; on the old code it fails with top 1 px, right 2 px). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ants.rs | 314 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 112 +------ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/marquee_tests.rs | 5 +- crates/ui-egui/src/tool_feedback.rs | 66 +--- crates/ui-egui/tests/marching_ants_pixels.rs | 199 ++++++++++++ docs/ui-parity.md | 4 +- 7 files changed, 534 insertions(+), 167 deletions(-) create mode 100644 crates/ui-egui/src/ants.rs create mode 100644 crates/ui-egui/tests/marching_ants_pixels.rs diff --git a/crates/ui-egui/src/ants.rs b/crates/ui-egui/src/ants.rs new file mode 100644 index 0000000000..0e6c609163 --- /dev/null +++ b/crates/ui-egui/src/ants.rs @@ -0,0 +1,314 @@ +//! Marching ants: the selection outline, drawn the way Photoshop draws it (#7489). +//! +//! Everything is laid out in device pixels, not egui points. An edge is a band `width` pixels +//! thick on the pixel boundary nearest to it (one pixel at 100 %, two up to 200 %, …), and each +//! of its pixels is black or white by a dash pattern fixed to the screen: dashes and gaps of +//! `4 · width` pixels along the edge, each row of a thicker band shifted `width` pixels against +//! the row beside it, so a dash ends on a slant. The pattern slides over time (the march). +//! +//! A point is a fractional number of pixels on a scaled display (125 %, 150 %), so the ants used to +//! be snapped to a grid the screen doesn't have: one edge of a rectangle came out 2 px thick and the +//! next 1 px, the dashes 5 px long here and 4 px there. +//! +//! Horizontal and vertical edges go in one mesh of pixel-aligned quads, which egui draws as it is: +//! a line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen whole +//! (about 10⁵ segments, #3846). A slanted edge (a transformed outline, an ellipse, a lasso path) +//! stays a line shape, which egui antialiases. + +use egui::{Color32, Mesh, Pos2, Rect, Shape, Stroke, pos2}; + +/// Dash (and gap) length, in line widths. +const DASH: i64 = 4; +/// How fast the ants march, in line widths per second. +const SPEED: f64 = 10.0; +/// Longer than any line that fits the view: only its part in view is dashed. +const LONG: f32 = 8192.0; + +/// The ants on a display of `ppp` device pixels per point, at one moment of the march. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Style { + ppp: f32, + /// Line thickness, in device pixels. + width: i64, + /// Dash length, in device pixels. + dash: i64, + /// How far the pattern has marched, in device pixels. + phase: i64, +} + +impl Style { + /// The ants at frame time `time` (s). + pub(crate) fn new(ppp: f32, time: f64) -> Self { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + // A hair over a whole scale (a windowing system's rounding) is still that scale. + let width = ((ppp - 0.01).ceil() as i64).clamp(1, 8); + let dash = DASH * width; + let time = if time.is_finite() { time.max(0.0) } else { 0.0 }; + let phase = ((time * SPEED * width as f64) % (2 * dash) as f64) as i64; + Self { ppp, width, dash, phase } + } + + /// The black runs of `[lo, hi)` along one pixel row (or column) of a band. A pixel is black + /// where `(s + phase) mod 2·dash < dash`, `s` being its position along the edge plus `off`, + /// `width` times its position across it: `x + w·y` on a horizontal edge, `y + w·x` on a + /// vertical one. + fn runs(&self, lo: i64, hi: i64, off: i64) -> impl Iterator + '_ { + let period = 2 * self.dash; + let first = lo - (lo + off + self.phase).rem_euclid(period); + std::iter::successors(Some(first), move |s| Some(s + period)) + .take_while(move |&s| s < hi) + .map(move |s| (s.max(lo), (s + self.dash).min(hi))) + .filter(|(s, e)| e > s) + } +} + +/// The ants along `lines` (screen points) inside `clip`: a mesh of the horizontal and vertical +/// edges, and line shapes for the slanted ones. `snap_slanted` puts a slanted edge's ends on pixel +/// centres too: a traced outline in a rotated view, a staircase whose steps must meet. A drawn curve +/// (an ellipse, a lasso path) keeps its own points, or its nearly flat arcs turn into 1 px stairs +/// whose antialiased steps wash out to grey. +pub(crate) fn shapes(lines: impl Iterator, clip: Rect, style: Style, snap_slanted: bool) -> (Mesh, Vec) { + let Style { ppp, width: w, dash, .. } = style; + let mut mesh = Mesh::default(); + let mut slanted = Vec::new(); + // Device pixels → a rectangle in points. + let mut quad = |x0: i64, y0: i64, x1: i64, y1: i64, color: Color32| { + let r = Rect::from_min_max(pos2(x0 as f32 / ppp, y0 as f32 / ppp), pos2(x1 as f32 / ppp, y1 as f32 / ppp)); + mesh.add_colored_rect(r, color); + }; + // The view in device pixels, a dash period wider: a band is cut to it so a long edge costs + // what its visible part does, and the pattern is fixed to the screen so the cut doesn't move it. + let view = clip.expand(2.0 * dash as f32 / ppp); + let (vx0, vy0) = ((view.min.x * ppp).floor() as i64, (view.min.y * ppp).floor() as i64); + let (vx1, vy1) = ((view.max.x * ppp).ceil() as i64, (view.max.y * ppp).ceil() as i64); + // The first pixel of a band across its boundary: centred on it, the odd one after it. + let half = w / 2; + for (pa, pb) in lines { + // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). + if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + continue; + } + let (pa, pb) = if pa.distance(pb) > LONG { + let Some(inside) = crate::canvas::clip_line(view, pa, pb) else { continue }; + inside + } else { + (pa, pb) + }; + let (da, db) = (pa * ppp, pb * ppp); + let (ax, ay, bx, by) = (da.x.round() as i64, da.y.round() as i64, db.x.round() as i64, db.y.round() as i64); + if (ax, ay) == (bx, by) { + continue; + } + if ay == by { + // Horizontal: rows `y0..y0 + w`, from one end's band to the other's, so edges meet + // at corners. + let (x0, x1) = ((ax.min(bx) - half).max(vx0), (ax.max(bx) - half + w).min(vx1)); + let y0 = ay - half; + if x1 <= x0 { + continue; + } + quad(x0, y0, x1, y0 + w, Color32::WHITE); + for y in y0..y0 + w { + for (s, e) in style.runs(x0, x1, w * y) { + quad(s, y, e, y + 1, Color32::BLACK); + } + } + } else if ax == bx { + let (y0, y1) = ((ay.min(by) - half).max(vy0), (ay.max(by) - half + w).min(vy1)); + let x0 = ax - half; + if y1 <= y0 { + continue; + } + quad(x0, y0, x0 + w, y1, Color32::WHITE); + for x in x0..x0 + w { + for (s, e) in style.runs(y0, y1, w * x) { + quad(x, s, x + 1, e, Color32::BLACK); + } + } + } else { + // On the middle of the band a snapped straight edge would be. + let centre = w as f32 / 2.0 - half as f32; + let (da, db) = if snap_slanted { (pos2(ax as f32 + centre, ay as f32 + centre), pos2(bx as f32 + centre, by as f32 + centre)) } else { (da, db) }; + let len = da.distance(db); + let dir = (db - da) / len; + let stroke = |color| Stroke::new(w as f32 / ppp, color); + slanted.push(Shape::line_segment([da / ppp, db / ppp], stroke(Color32::WHITE))); + // Phased by screen position, like the straight edges, so dashes line up across joins. + let period = (2 * dash) as f32; + let mut t = -(da.x.floor() + da.y.floor() + style.phase as f32).rem_euclid(period); + while t < len { + let (s0, s1) = (t.max(0.0), (t + dash as f32).min(len)); + if s1 > s0 { + slanted.push(Shape::line_segment([(da + dir * s0) / ppp, (da + dir * s1) / ppp], stroke(Color32::BLACK))); + } + t += period; + } + } + } + (mesh, slanted) +} + +/// Draw the ants along `lines` (screen points) at frame time `time`; see [`shapes`]. +pub(crate) fn draw(painter: &egui::Painter, lines: impl Iterator, time: f64, snap_slanted: bool) { + let style = Style::new(painter.pixels_per_point(), time); + let (mesh, slanted) = shapes(lines, painter.clip_rect(), style, snap_slanted); + painter.extend(slanted); + if !mesh.is_empty() { + painter.add(Shape::mesh(mesh)); + } +} + +/// The segments of the polyline `points`, back to the first when `closed`. +pub(crate) fn polyline(points: &[Pos2], closed: bool) -> impl Iterator + '_ { + let back = points.first().zip(points.last()).filter(|_| closed && points.len() > 2).map(|(f, l)| (*l, *f)); + points.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).chain(back) +} + +/// Draw animated ants along the polyline `points` (screen points), and keep them marching. +pub(crate) fn draw_polyline(painter: &egui::Painter, points: &[Pos2], closed: bool) { + let time = painter.ctx().input(|i| i.time); + painter.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + draw(painter, polyline(points, closed), time, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + const VIEW: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(800.0, 600.0) }; + + /// The mesh's quads in device pixels, `[x0, y0, x1, y1, black]`, checking each lands exactly + /// on the pixel grid. + fn quads(mesh: &Mesh, ppp: f32) -> Vec<[i64; 5]> { + mesh.vertices + .chunks(4) + .map(|q| { + let px: Vec<[f32; 2]> = q.iter().map(|v| [v.pos.x * ppp, v.pos.y * ppp]).collect(); + for p in &px { + assert!((p[0] - p[0].round()).abs() < 1e-3 && (p[1] - p[1].round()).abs() < 1e-3, "off the pixel grid at {ppp}x: {p:?}"); + } + let xs = px.iter().map(|p| p[0].round() as i64); + let ys = px.iter().map(|p| p[1].round() as i64); + let black = q[0].color == Color32::BLACK; + [xs.clone().min().unwrap(), ys.clone().min().unwrap(), xs.max().unwrap(), ys.max().unwrap(), i64::from(black)] + }) + .collect() + } + + /// The black pixels of a rasterised mesh. + fn black_pixels(q: &[[i64; 5]]) -> std::collections::BTreeSet<(i64, i64)> { + q.iter().filter(|q| q[4] == 1).flat_map(|&[x0, y0, x1, y1, _]| (x0..x1).flat_map(move |x| (y0..y1).map(move |y| (x, y)))).collect() + } + + #[test] + fn the_line_is_whole_device_pixels_thick() { + for (ppp, w) in [(1.0, 1), (1.0001, 1), (1.25, 2), (1.5, 2), (1.75, 2), (2.0, 2), (2.5, 3), (3.0, 3), (0.0, 1), (f32::NAN, 1)] { + assert_eq!(Style::new(ppp, 0.0).width, w, "@{ppp}x"); + assert_eq!(Style::new(ppp, 0.0).dash, 4 * w); + } + } + + /// The bug in #7489: at 125 % one edge was 2 px and the next 1 px. + #[test] + fn every_edge_of_a_rectangle_is_as_thick_as_the_others_at_a_fractional_scale() { + for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] { + let style = Style::new(ppp, 0.0); + // Corners at odd and even points, which a fractional scale puts at different fractions + // of a pixel. + let r = [pos2(101.0, 60.0), pos2(203.0, 60.0), pos2(203.0, 241.0), pos2(101.0, 241.0)]; + let (mesh, slanted) = shapes(polyline(&r, true), VIEW, style, true); + assert!(slanted.is_empty()); + let q = quads(&mesh, ppp); + let w = style.width; + // Every white base is `w` thick, and every dash is one pixel row (or column) of it. + for &[x0, y0, x1, y1, black] in &q { + let across = (x1 - x0).min(y1 - y0); + assert_eq!(across, if black == 1 { 1 } else { w }, "@{ppp}x: {:?}", [x0, y0, x1, y1]); + } + // Dashes are 4·w long, but where the edge's ends cut them. + let dashes: Vec = q.iter().filter(|q| q[4] == 1).map(|&[x0, y0, x1, y1, _]| (x1 - x0).max(y1 - y0)).collect(); + assert!(dashes.iter().all(|&n| n <= 4 * w) && dashes.iter().filter(|&&n| n == 4 * w).count() * 2 > dashes.len(), "@{ppp}x: {dashes:?}"); + } + } + + /// A 2 px line's rows are shifted 2 px against each other, so a dash ends on a slant (as in + /// Photoshop: the row nearer the bottom or right leads). + #[test] + fn a_thick_line_ends_its_dashes_on_a_slant() { + let style = Style::new(2.0, 0.0); + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(90.0, 10.0)], false), VIEW, style, true); + let black = black_pixels(&quads(&mesh, 2.0)); + // Device row 19 and 20 straddle the edge at y = 20 px. + let row = |y: i64| -> Vec { (30..170).filter(|&x| black.contains(&(x, y))).collect() }; + let (upper, lower) = (row(19), row(20)); + assert_eq!(upper.len(), lower.len()); + let shifted: Vec = upper.iter().map(|x| x - 2).filter(|x| (30..168).contains(x)).collect(); + assert!(shifted.iter().all(|x| lower.contains(x)), "the lower row leads by 2 px: {upper:?} / {lower:?}"); + // The vertical edges too: the right column leads (starts higher) by 2 px. + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(10.0, 90.0)], false), VIEW, style, true); + let black = black_pixels(&quads(&mesh, 2.0)); + let col = |x: i64| -> Vec { (30..170).filter(|&y| black.contains(&(x, y))).collect() }; + let (left, right) = (col(19), col(20)); + let shifted: Vec = left.iter().map(|y| y - 2).filter(|y| (30..168).contains(y)).collect(); + assert!(shifted.iter().all(|y| right.contains(y)), "{left:?} / {right:?}"); + } + + /// At 100 % the pattern is the classic one: black where `(x + y + phase) mod 8 < 4`, so + /// dashes line up across a corner and march with time. + #[test] + fn the_ants_march_and_line_up_at_corners() { + let at = |time: f64| { + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(50.0, 10.0), pos2(50.0, 50.0)], false), VIEW, Style::new(1.0, time), true); + black_pixels(&quads(&mesh, 1.0)) + }; + let still = at(0.0); + for x in 10..=50 { + assert_eq!(still.contains(&(x, 10)), (x + 10).rem_euclid(8) < 4, "top edge at {x}"); + } + for y in 10..=50 { + assert_eq!(still.contains(&(50, y)), (50 + y).rem_euclid(8) < 4, "right edge at {y}"); + } + assert_ne!(still, at(0.25), "the ants march"); + assert_eq!(still, at(0.8), "one period later they are back"); + } + + #[test] + fn a_long_edge_costs_only_its_visible_part() { + let (mesh, _) = shapes(polyline(&[pos2(-1e7, 100.0), pos2(1e7, 100.0)], false), VIEW, Style::new(1.0, 0.0), true); + // 800 px in view: ~100 dashes, not 2.5 million. + assert!(mesh.vertices.len() < 4 * 200, "{} vertices", mesh.vertices.len()); + } + + #[test] + fn slanted_lines_are_antialiased_strokes_as_thick_as_the_straight_ones() { + let style = Style::new(1.5, 0.0); + let (mesh, slanted) = shapes(polyline(&[pos2(10.0, 10.0), pos2(80.0, 50.0)], false), VIEW, style, false); + assert!(mesh.is_empty()); + assert!(slanted.len() > 2); + for s in &slanted { + let Shape::LineSegment { stroke, .. } = s else { panic!("{s:?}") }; + assert!((stroke.width * 1.5 - 2.0).abs() < 1e-4, "2 device px: {}", stroke.width); + } + let colours: Vec = slanted.iter().filter_map(|s| if let Shape::LineSegment { stroke, .. } = s { Some(stroke.color) } else { None }).collect(); + assert!(colours.contains(&Color32::WHITE) && colours.contains(&Color32::BLACK)); + } + + #[test] + fn hostile_points_draw_nothing_and_never_panic() { + for (a, b) in [ + (pos2(f32::NAN, 0.0), pos2(10.0, 10.0)), + (pos2(f32::INFINITY, 0.0), pos2(10.0, 10.0)), + (pos2(5.0, 5.0), pos2(5.0, 5.0)), + (pos2(-1e30, -1e30), pos2(-1e30, 1e30)), + (pos2(1e30, 5.0), pos2(-1e30, 5.0)), + ] { + for snap in [true, false] { + let _ = shapes([(a, b)].into_iter(), VIEW, Style::new(1.25, f64::NAN), snap); + } + } + assert_eq!(polyline(&[], true).count(), 0); + assert_eq!(polyline(&[pos2(0.0, 0.0)], true).count(), 0); + assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0)], true).count(), 1); + assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)], true).count(), 3); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a5f1452dd1..3297795533 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -329,23 +329,20 @@ fn single_line_drag_px(app: &PhotocraftApp, d: &Drag) -> Option<[f64; 4]> { /// /// A rotated view projects the rectangle onto a rotated one, and an axis-aligned screen box /// through two of its corners is a different shape in a different place: the commit was always -/// right, only the hinting was drawn elsewhere (#2512). Every point is snapped to the half-pixel -/// grid like every other ants outline (`marching_ants_segments`), so the lines stay crisp at any -/// zoom (#172). +/// right, only the hinting was drawn elsewhere (#2512). The points are not snapped: the ants snap +/// straight edges to device pixels as they draw them (`crate::ants`), like every other outline, so +/// the lines stay crisp at any zoom (#172) and display scale (#7489). pub(crate) fn marquee_ants(xf: &ViewXform, tool: Tool, a: [f64; 2], b: [f64; 2]) -> Vec { let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0])); let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1])); - let at = |x: f64, y: f64| { - let p = xf.to_screen(x as f32, y as f32); - pos2(p.x.round() + 0.5, p.y.round() + 0.5) - }; + let at = |x: f64, y: f64| xf.to_screen(x as f32, y as f32); if tool != Tool::EllipseMarquee { return vec![at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; } // An axis-aligned view (rotation a multiple of 90 degrees) maps the ellipse onto the one - // inscribed in the snapped screen box of two opposite corners. Draw that, unsnapped: snapping - // each vertex on its own turns the nearly flat arcs at the edge midpoints into 1 px stairs, - // whose antialiased steps wash the ants out to grey on white and black. + // inscribed in the screen box of two opposite corners. The ants draw its vertices where they + // are: snapping each on its own turns the nearly flat arcs at the edge midpoints into 1 px + // stairs, whose antialiased steps wash the ants out to grey on white and black. let quarter = xf.rotation.rem_euclid(90.0); if quarter < 1e-4 || 90.0 - quarter < 1e-4 { let r = Rect::from_two_pos(at(x0, y0), at(x1, y1)); @@ -4366,7 +4363,7 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli } /// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. -fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { +pub(crate) fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. let (ax, ay) = (f64::from(a.x), f64::from(a.y)); @@ -4406,94 +4403,10 @@ mod transform_outline_clip_tests { } } -/// Marching ants along lines already placed on screen (a warped outline is a run of short ones). +/// Marching ants along lines already placed on screen (a warped outline is a run of short ones), +/// in device pixels like Photoshop's (`crate::ants`, #7489). pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator, time: f64) { - // Longer than any line that fits the view: only its part in view is dashed. - const LONG: f32 = 8192.0; - let dash = 4.0f32; - let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; - let clip = painter.clip_rect(); - // Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a - // line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen - // whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted - // segment (a transformed outline) stays a line shape, which egui antialiases. - let mut mesh = egui::Mesh::default(); - // A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line). - let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| { - let i = mesh.vertices.len() as u32; - for p in [a + side, a - side, b - side, b + side] { - mesh.colored_vertex(p, color); - } - mesh.add_triangle(i, i + 1, i + 2); - mesh.add_triangle(i, i + 2, i + 3); - }; - for (pa, pb) in lines { - // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). - if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { - continue; - } - let (pa, pb) = if pa.distance(pb) > LONG { - let Some(inside) = clip_line(clip.expand(4.0 * dash), pa, pb) else { continue }; - inside - } else { - (pa, pb) - }; - let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); - let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); - let len = pa.distance(pb); - if len < 0.5 { - continue; - } - let dir = (pb - pa) / len; - let side = dir.rot90() * 0.5; - let straight = pa.x == pb.x || pa.y == pb.y; - if straight { - // The white base runs half a pixel past both ends, so joined segments meet at corners. - quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE); - } else { - painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE)); - } - // Phase by screen position so dashes line up across joined segments. - let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0); - let mut t = -start; - while t < len { - let s0 = t.max(0.0); - let s1 = (t + dash).min(len); - if s1 > s0 { - if straight { - quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK); - } else { - painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK)); - } - } - t += dash * 2.0; - } - } - if !mesh.is_empty() { - painter.add(egui::Shape::mesh(mesh)); - } -} - -#[allow(dead_code)] -fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) { - let dash = 4.0; - let offset = ((time * 8.0) % (dash as f64 * 2.0)) as f32; - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); - let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - for w in pts.windows(2) { - let (a, b) = (w[0], w[1]); - let len = a.distance(b); - let dir = (b - a) / len.max(1e-3); - let mut t = -offset; - while t < len { - let s = t.max(0.0); - let e = (t + dash).min(len); - if e > s { - painter.line_segment([a + dir * s, a + dir * e], Stroke::new(1.0, Color32::BLACK)); - } - t += dash * 2.0; - } - } + crate::ants::draw(painter, lines, time, true); } /// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright @@ -5040,9 +4953,6 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View if let Some(lasso) = &d.lasso { pts.push(xf.to_screen(lasso.cursor[0] as f32, lasso.cursor[1] as f32)); } - // On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the - // canvas sits (a pixel edge would blur it to grey). - let pts: Vec = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect(); crate::tool_feedback::draw_ants(painter, &pts, false); } Tool::Gradient => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..ec67b40791 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod adjust_ui; mod alt_grab; pub mod analysis_ui; pub mod anchor_tools; +mod ants; pub mod artboard_tool; pub mod artboard_ui; pub(crate) mod background_redraw; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 321a31e73a..44e3df0b1b 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -2,7 +2,7 @@ //! ⌥ draws from the centre (both together work too), a "W × H px" readout follows the cursor, and //! modifiers held before the drag still pick add/subtract (#188) instead of constraining. -use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui::{Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use photocraft_geom::Rect; @@ -282,8 +282,7 @@ fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() { if tool == Tool::RectMarquee { // Each corner is a corner of the dragged rectangle, mapped: an axis-aligned box // through the two diagonal corners puts all four elsewhere. - let snap = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5); - let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| snap(xf.to_screen(x as f32, y as f32))); + let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| xf.to_screen(x as f32, y as f32)); for (i, (p, w)) in pts.iter().zip(want).enumerate() { assert!((p.x - w.x).abs() < 0.01 && (p.y - w.y).abs() < 0.01, "{tool:?}: corner {i} at {p:?}, the dragged rectangle's corner is at {w:?}"); } diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index 2e66ae07c1..e50714405e 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -168,53 +168,10 @@ pub fn draw_badge(painter: &egui::Painter, p: Pos2, b: Badge, centred: bool) { } } -/// Dash length of the marching ants, in screen points. -pub const DASH: f32 = 4.0; - -/// Marching ants along a polyline: a solid white line under black dashes, the dashes shifted by -/// `phase` (animate it with time). Visible on any image, like Photoshop's selection edges. -pub fn ants(points: &[Pos2], closed: bool, phase: f32) -> Vec { - let Some(&first) = points.first().filter(|_| points.len() >= 2) else { return Vec::new() }; - let mut pts = points.to_vec(); - if closed { - pts.push(first); - } - let mut out = vec![Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))]; - let black = Stroke::new(1.0, Color32::BLACK); - // Arc length so dashes run continuously across vertices. - let mut along = -phase.rem_euclid(DASH * 2.0); - for w in pts.windows(2) { - let (a, b) = (w[0], w[1]); - let len = a.distance(b); - if len < 1e-3 { - continue; - } - let dir = (b - a) / len; - // Position of this segment's start within the dash period. - let mut t = -(along.rem_euclid(DASH * 2.0)); - while t < len { - let (s0, s1) = (t.max(0.0), (t + DASH).min(len)); - if s1 > s0 { - out.push(Shape::line_segment([a + dir * s0, a + dir * s1], black)); - } - t += DASH * 2.0; - } - along += len; - } - out -} - -/// Marching-ants phase for the frame time `time` (s), and keep the animation running. -pub fn ants_phase(ctx: &egui::Context) -> f32 { - let time = ctx.input(|i| i.time); - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - ((time * 10.0) % (DASH as f64 * 2.0)) as f32 -} - -/// Draw animated marching ants along `points`. +/// Draw animated marching ants along `points`: the same ants as a selection's (`crate::ants`), +/// visible on any image, like Photoshop's selection edges. pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) { - let phase = ants_phase(painter.ctx()); - painter.extend(ants(points, closed, phase)); + crate::ants::draw_polyline(painter, points, closed); } /// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth). @@ -295,23 +252,8 @@ mod tests { } #[test] - fn ants_use_two_contrasting_colours_and_march() { + fn shape_previews_contrast_and_ellipses_close() { let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; - let a = ants(&pts, true, 0.0); - let cols = strokes(&a); - assert!(cols.contains(&Color32::WHITE) && cols.contains(&Color32::BLACK), "{cols:?}"); - // Roughly half the length is black dashes: (40 + 30 + 50) / 8 ≈ 15 dashes. - let dashes = cols.iter().filter(|c| **c == Color32::BLACK).count(); - assert!((13..=18).contains(&dashes), "{dashes}"); - // The phase moves the dashes (the ants march). - let first = |s: &[Shape]| { - s.iter().find_map(|s| match s { - Shape::LineSegment { points, stroke } if stroke.color == Color32::BLACK => Some(points[0]), - _ => None, - }) - }; - assert_ne!(first(&a), first(&ants(&pts, true, 2.0))); - assert!(ants(&pts[..1], false, 0.0).is_empty()); // Shape previews: the accent path over a dark halo. let s = contrast_path(pts.to_vec(), true, Color32::from_rgb(0, 120, 255)); let cols = strokes(&s); diff --git a/crates/ui-egui/tests/marching_ants_pixels.rs b/crates/ui-egui/tests/marching_ants_pixels.rs new file mode 100644 index 0000000000..8178bc96dd --- /dev/null +++ b/crates/ui-egui/tests/marching_ants_pixels.rs @@ -0,0 +1,199 @@ +//! #7489: the marching ants are drawn in device pixels, the same on every edge, at any display +//! scale, as Photoshop draws them. +//! +//! The ants were snapped to the middle of an egui point and drawn one point wide. At 125 % or +//! 150 % a point is not a whole number of pixels, so one edge of a rectangle came out 2 px wide +//! and the next 1 px, and the dashes 5 px long here and 4 px there. Photoshop draws them on the +//! screen's own pixels: black and white dashes, every edge as thick as every other, thicker on a +//! scaled display, the dash pattern running diagonally across the screen so a 2 px line's dashes +//! end on a slant. +//! +//! These tests render a committed rectangular selection with the real app offscreen on wgpu and +//! read the pixels along each edge. They skip when no GPU adapter exists. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +/// A rendered frame: RGBA8, physical pixels. +struct Image { + w: u32, + h: u32, + px: Vec, +} + +impl Image { + /// Luma of pixel `(x, y)`, or `None` off the frame. + fn luma(&self, x: i64, y: i64) -> Option { + if x < 0 || y < 0 || x >= i64::from(self.w) || y >= i64::from(self.h) { + return None; + } + let i = (y as usize * self.w as usize + x as usize) * 4; + let p = self.px.get(i..i + 3)?; + Some(((u16::from(p[0]) * 3 + u16::from(p[1]) * 6 + u16::from(p[2])) / 10) as u8) + } +} + +fn harness(ppp: f32) -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A fresh 400 × 300 document in `background` at 100 % with the rectangle `r` selected. +fn setup(h: &mut Harness, background: &str, r: [i32; 4]) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + app.run("file.new", json!({"width": 400, "height": 300, "background": background})).expect("new"); + app.sync_views(); + app.ui.extras.rulers = false; + app.run("select.rect", json!({"x": r[0], "y": r[1], "width": r[2] - r[0], "height": r[3] - r[1]})).expect("select"); + } + control(h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [200, 150]})); + h.run_steps(3); +} + +fn render(h: &mut Harness) -> Image { + let img = h.render().expect("render"); + Image { w: img.width(), h: img.height(), px: img.into_raw() } +} + +/// Device pixel of document point `(x, y)`. +fn device(h: &Harness, ppp: f32, x: f32, y: f32) -> (i64, i64) { + let p = ViewXform::active(h.state()).expect("an active document view").to_screen(x, y) * ppp; + (p.x.round() as i64, p.y.round() as i64) +} + +/// One edge of the outline as seen on screen: for every device pixel along its middle, the +/// pixels across it (±6) that are dark. +struct Edge { + /// Dark pixels across the edge at each step along it. + across: Vec>, + /// Pixels in the band that are neither black nor white (antialiasing grey). + greys: usize, +} + +/// Scan the edge from device pixel `a` to `b` (one of x or y shared), skipping 12 px at each end. +fn scan(img: &Image, a: (i64, i64), b: (i64, i64)) -> Edge { + let vertical = a.0 == b.0; + let (lo, hi) = if vertical { (a.1.min(b.1), a.1.max(b.1)) } else { (a.0.min(b.0), a.0.max(b.0)) }; + let mut across = Vec::new(); + let mut greys = 0; + for t in lo + 12..hi - 12 { + let mut dark = Vec::new(); + for o in -6..=6 { + let (x, y) = if vertical { (a.0 + o, t) } else { (t, a.1 + o) }; + let l = img.luma(x, y).unwrap_or(255); + if l < 64 { + dark.push(o); + } + if (24..232).contains(&l) { + greys += 1; + } + } + across.push(dark); + } + Edge { across, greys } +} + +/// The line is `w` px thick on every edge, crisp, with dashes of 4·w px. +#[test] +fn every_edge_is_equally_thick_at_any_display_scale() { + let _gpu = photocraft_testkit::gpu_lock(); + for ppp in [1.0f32, 1.25, 1.5, 2.0] { + let Some(mut h) = harness(ppp) else { return }; + h.run_steps(4); + // Edges at odd and even document columns, so a fractional scale lands them at different + // fractions of a pixel. + let r = [101, 60, 203, 241]; + setup(&mut h, "white", r); + let img = render(&mut h); + let w = ppp.ceil() as usize; + let [x0, y0, x1, y1] = r.map(|v| v as f32); + let (tl, tr, br, bl) = (device(&h, ppp, x0, y0), device(&h, ppp, x1, y0), device(&h, ppp, x1, y1), device(&h, ppp, x0, y1)); + let sides = [("top", (tl, tr)), ("right", (tr, br)), ("bottom", (bl, br)), ("left", (tl, bl))]; + let edges: Vec<(&str, Edge)> = sides.iter().map(|&(name, (a, b))| (name, scan(&img, a, b))).collect(); + // The bug: at 125 % one edge came out 2 px thick and the next 1 px. + let thickest: Vec<(&str, usize)> = edges.iter().map(|(name, e)| (*name, e.across.iter().map(Vec::len).max().unwrap_or(0))).collect(); + assert!(thickest.iter().all(|t| t.1 == thickest[0].1), "@{ppp}x: every edge as thick as the others: {thickest:?}"); + for (name, e) in edges { + assert_eq!(e.greys, 0, "@{ppp}x {name}: the ants are crisp black and white"); + let thick: Vec = e.across.iter().map(Vec::len).filter(|&n| n > 0).collect(); + assert!(!thick.is_empty(), "@{ppp}x {name}: the edge shows"); + // Where a dash covers every row across the line it is exactly `w` thick; at a dash's + // slanted end only some of them are dark. + let max = thick.iter().copied().max().unwrap_or(0); + assert_eq!(max, w, "@{ppp}x {name}: {w} px thick, got {thick:?}"); + // Half the line is black: dashes and gaps of 4·w px each. + let dark: usize = e.across.iter().map(Vec::len).sum(); + let frac = dark as f32 / (e.across.len() * w) as f32; + assert!((0.45..=0.55).contains(&frac), "@{ppp}x {name}: {frac:.2} of the line is black"); + // Along any one row of the line, black runs are 4·w px long. + let rows: std::collections::BTreeSet = e.across.iter().flatten().copied().collect(); + for row in rows { + let on: Vec = e.across.iter().map(|d| d.contains(&row)).collect(); + let mut runs = Vec::new(); + let mut n = 0; + for (i, &b) in on.iter().enumerate() { + if b { + n += 1; + } + if (!b || i + 1 == on.len()) && n > 0 { + runs.push(n); + n = 0; + } + } + // The first and last run may be cut by the scan's ends. + let inner = runs.get(1..runs.len().saturating_sub(1)).unwrap_or(&[]); + assert!(inner.iter().all(|&n| n == 4 * w), "@{ppp}x {name} row {row}: dashes {runs:?}, want {} px", 4 * w); + } + } + } +} + +/// On black the white dashes show, between black ones. +#[test] +fn the_ants_alternate_black_and_white() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = harness(1.0) else { return }; + h.run_steps(4); + let r = [100, 60, 200, 240]; + setup(&mut h, "black", r); + let img = render(&mut h); + let (a, b) = (device(&h, 1.0, 100.0, 60.0), device(&h, 1.0, 200.0, 60.0)); + let mut bright = 0; + let mut n = 0; + for x in a.0 + 12..b.0 - 12 { + n += 1; + bright += usize::from((-2..=2).any(|o| img.luma(x, a.1 + o).is_some_and(|l| l > 230))); + } + let frac = bright as f32 / n as f32; + assert!((0.4..=0.6).contains(&frac), "{frac:.2} of the top edge is white on black"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..05d242ab36 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (marching ants in device pixels, #7489; 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) · **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, @@ -114,6 +114,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Single-pixel rows and columns kept on release | partial | #2391 | | Esc deselects / ⌘Z undoes the last selection | partial | #2674, #1432 | | Select › Modify live preview | missing | #1752 | +| Marching ants as Photoshop draws them: device pixels, every edge equally thick (1 px at 100 %, 2 px up to 200 %), black/white dashes 4× the thickness, slanted dash ends, same look for drag previews and committed selections | done | `ants.rs`, `tests/marching_ants_pixels.rs` (#7489) | ## Pen, paths and shapes @@ -257,3 +258,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-18 | minor | Marching ants drawn in device pixels like Photoshop's: equally thick on every edge at 125 %/150 % display scale, thicker on HiDPI, slanted dash ends (#7489) | From 6b16152c80900a9f03b8441aa32e797f8ef52c18 Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 21:42:49 +0300 Subject: [PATCH 0666/1452] test(atn): assert session actions and doc revision unchanged on rejected save --- crates/engine/src/atn_cmds/tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index fe8ba02707..78aa463876 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -974,8 +974,12 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { // Explicit numeric ID reference cannot be silently converted to current target in strict export s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); + let before_actions = s.actions.list.clone(); + let before_rev = s.active().unwrap().revision; let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"})); assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target"); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); let err_msg = err_delete.unwrap_err().to_string(); assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}"); From 050c8ebed8530010fd475bdf9a8f99eb47b32c73 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:47:45 -0400 Subject: [PATCH 0667/1452] docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1e85aca5b0..7554fcd8f2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
Scanned by SonarCloud Reviewed by CodeRabbit

From b3bee7496e847a36532ade7dfe0231873dd6adb7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:16 -0400 Subject: [PATCH 0668/1452] Batch Same format: an input without an extension is saved as PNG same_format took everything after the last dot of the whole path, so a file name without an extension (or a dot in a folder name) gave a bogus output extension and the documented PNG fallback never ran. It now uses file_cmds::extension on the file name. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index cee41b9345..eb3f80016f 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1095,13 +1095,26 @@ pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, sa /// The extension a batch output takes with Format "Same": the input's own, except for formats /// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on -/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. +/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input +/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG. fn same_format(path: &str) -> String { const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub"]; - let ext = path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase(); + let ext = extension(path).unwrap_or_else(|| "png".into()); if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext } } +#[cfg(test)] +#[test] +fn same_format_takes_the_file_names_extension() { + assert_eq!(same_format("/in/a.PNG"), "png"); + assert_eq!(same_format("/in/IMG_0001.ARW"), "psd"); + assert_eq!(same_format("/in/a.kra"), "psd"); + // No extension in the file name: PNG, not the path after a dot in a folder name. + assert_eq!(same_format("/in/v1.2/scan"), "png"); + assert_eq!(same_format("C:\\in.d\\scan"), "png"); + assert_eq!(same_format("/in/.hidden"), "png"); +} + fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); From 56e8532c867edbf447758238e092d66457f2e0df Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:17 -0400 Subject: [PATCH 0669/1452] Actions: Select and Mask with Sample All Layers exports as smartBrushWorkspace Export picked refineSelectionEdge, whose fields have no Sample All Layers, so the value was dropped and re-import fell back to false. A step that sets it now exports as smartBrushWorkspace, which keeps it; other steps still export as refineSelectionEdge. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 14 +++++++++++++- crates/engine/src/atn_cmds/tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 7ffefce327..ed59999769 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3113,7 +3113,19 @@ const EVENTS: &[Event] = &[ ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), - ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE), + // `refineSelectionEdge` has no Sample All Layers, so a step with it exports as + // `smartBrushWorkspace` instead of dropping it. + Event { + code: "", + name: "refineSelectionEdge", + label: "Select and Mask", + ids: &["select.refineEdge"], + from: |d| match fields_from(d, REFINE_EDGE) { + Some(m) => step("select.refineEdge", Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| if p.get("sampleAllLayers").is_some() { None } else { fields_to(p, REFINE_EDGE, desc("null")) }, + }, ev!("", "smartBrushWorkspace", "Select and Mask", "select.refineEdge", SMART_BRUSH), ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]), Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to }, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 499df26086..dbb1db3127 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -630,6 +630,22 @@ fn retouching_set_events_map_to_their_commands() { assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]}))); } +/// Select and Mask exports as the older `refineSelectionEdge` event, which has no Sample All +/// Layers; a step that sets it exports as `smartBrushWorkspace` so the value survives. +#[test] +fn select_and_mask_keeps_sample_all_layers_on_export() { + let (event, d) = event_and_descriptor("select.refineEdge", &json!({"radius": 3, "output": "layerMask"})).unwrap(); + assert_eq!(event.name, "refineSelectionEdge"); + assert!(d.get("refineEdgeBorderRadius").is_some()); + for sample in [true, false] { + let params = json!({"radius": 3, "output": "layerMask", "sampleAllLayers": sample}); + let (event, d) = event_and_descriptor("select.refineEdge", ¶ms).unwrap(); + assert_eq!(event.name, "smartBrushWorkspace"); + let Mapped::Steps(steps) = (event.from)(&d) else { panic!("{d:?}") }; + assert_eq!(steps[0].1["sampleAllLayers"], sample); + } +} + #[test] fn step_commands_and_bad_params_fail_gracefully() { let mut s = Session::new(); From 8138fc12e707e2e03c6fc0f185efe75b8cbec720 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:18 -0400 Subject: [PATCH 0670/1452] test(web): a press, release and press in one frame keep all three events Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/touch_twins.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/src/touch_twins.rs b/crates/ui-egui/src/touch_twins.rs index a000d0b05b..aee4aa33d3 100644 --- a/crates/ui-egui/src/touch_twins.rs +++ b/crates/ui-egui/src/touch_twins.rs @@ -86,6 +86,17 @@ mod tests { assert!(tap(&mut harness, true), "a third tap opens it again"); } + /// A fast double tap delivered in one frame: each event changes the button's state, so none is + /// a twin. + #[test] + fn a_press_release_press_in_one_frame_keeps_all_three() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let mut raw = RawInput { time: Some(1.0), events: vec![button(p, true), button(p, false), button(p, true)], ..Default::default() }; + drop_twins(&ctx, &mut raw); + assert_eq!(raw.events.len(), 3); + } + #[test] fn a_press_held_for_long_still_passes() { let ctx = egui::Context::default(); From 4527b6a7e28270e2f326fb522818966aa002c3aa Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:19 -0400 Subject: [PATCH 0671/1452] New Document: presets keep the Color Profile; a stale profile is cleared - DocumentSettings gains an optional `profile`, so document.presets.save and Recent keep the Color Profile and command_params gives it back to file.new. Older presets load as the working space. - Applying a saved preset drops the profile picked before, and a profile that doesn't suit the mode is removed from the dialog state, so Create never sends it. - The rejected-profile test also checks the active document and its history are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document_preset_cmds.rs | 33 +++++++++++++++++++++++ crates/engine/src/tests.rs | 4 ++- crates/ui-egui/src/new_doc_ui.rs | 23 ++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index f0847e4b8b..52c637fdc0 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -102,6 +102,10 @@ pub struct DocumentSettings { pub background: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub background_color: Option<[f32; 3]>, + /// `file.new`'s Color Profile (#321): a built-in profile id or an .icc path; absent is the + /// working space, as in presets saved before it existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, /// Display units only; width/height are always pixels and resolution is always ppi. pub unit: String, pub resolution_unit: String, @@ -128,6 +132,9 @@ impl DocumentSettings { if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) { return Err("Background colour components must be within 0..1".into()); } + if self.profile.as_deref().is_some_and(|p| p.trim().is_empty()) { + return Err("A saved Color Profile needs a profile id or path".into()); + } if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") { return Err("Unknown dimension or resolution display unit".into()); } @@ -171,6 +178,9 @@ impl DocumentSettings { if let Some(c) = self.background_color { p["backgroundColor"] = json!(c); } + if let Some(profile) = &self.profile { + p["profile"] = json!(profile); + } p } } @@ -359,6 +369,29 @@ mod tests { next } + /// #321: a preset keeps New Document's Color Profile through a reload and back into + /// `file.new`; one saved without it (or before it existed) stays on the working space. + #[test] + fn the_color_profile_survives_save_reload_and_file_new() { + let mut s = Session::new(); + let mut config = settings(); + config["mode"] = json!("cmyk"); + config["profile"] = json!("coated-cmyk"); + let preset = saved(&mut s, "Press", config.clone()); + assert_eq!(preset["settings"]["profile"], "coated-cmyk"); + let next = reload(&s); + let params = next.presets.documents[0].settings.command_params(); + assert_eq!(params["profile"], "coated-cmyk"); + let mut s2 = Session::new(); + s2.execute("file.new", params).unwrap(); + assert!(s2.active().unwrap().doc.icc_profile.is_some()); + let plain = saved(&mut s, "Plain", settings()); + assert!(plain["settings"].get("profile").is_none()); + assert!(s.presets.documents[1].settings.command_params().get("profile").is_none()); + config["profile"] = json!(" "); + assert!(s.execute("document.presets.save", json!({"name":"Blank","settings":config})).is_err()); + } + #[test] fn every_field_survives_save_reload_and_file_new() { for mode in ["rgb", "gray", "cmyk", "lab"] { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c105158c9..9bb00b30b6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -177,10 +177,12 @@ fn file_new_tags_the_document_with_the_chosen_profile() { } // A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing. let open = s.documents().len(); + let (active, steps) = (s.active().unwrap().doc.id, s.active().unwrap().history.past_len()); for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] { assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}"); + assert_eq!(s.documents().len(), open, "{bad}"); + assert_eq!((s.active().unwrap().doc.id, s.active().unwrap().history.past_len()), (active, steps), "{bad}"); } - assert_eq!(s.documents().len(), open); } #[test] diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 761b1fd85c..ca836b61f5 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -346,6 +346,8 @@ pub fn apply_saved_preset(f: &mut Map, preset: &photocraft_engine /// Restore every setting of a saved or recent document (all but its name). pub fn apply_settings(f: &mut Map, settings: &DocumentSettings) { f.remove("backgroundColor"); + // A preset without a Color Profile is on the working space, whatever was picked before. + f.remove("profile"); if let Some(params) = settings.command_params().as_object() { f.extend(params.clone()); } @@ -745,7 +747,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); if widgets::dropdown(ui, "nd-profile", &mut profile, &shown, 240.0) { @@ -852,6 +856,24 @@ mod tests { } } + /// A saved preset replaces the Color Profile picked before: its own, or the working space. + #[test] + fn applying_a_preset_replaces_the_picked_profile() { + let mut s = photocraft_engine::Session::new(); + let base = json!({"width": 8, "height": 8, "mode": "cmyk", "unit": "px"}); + s.execute("document.presets.save", json!({"name": "Plain", "settings": base})).unwrap(); + let mut press = base.clone(); + press["profile"] = json!("coated-cmyk"); + s.execute("document.presets.save", json!({"name": "Press", "settings": press})).unwrap(); + let mut f = Map::new(); + f.insert("profile".into(), json!(photocraft_cms::Builtin::AdobeRgbCompat.id())); + apply_saved_preset(&mut f, &s.presets.documents[0]); + assert!(!f.contains_key("profile"), "{f:?}"); + assert!(s.execute("file.new", command_params(&f)).is_ok()); + apply_saved_preset(&mut f, &s.presets.documents[1]); + assert_eq!(f["profile"], "coated-cmyk"); + } + #[test] fn units_round_trip_through_pixels() { assert_eq!(to_unit(2100.0, "in", 300.0), 7.0); @@ -1028,6 +1050,7 @@ mod tests { depth: 8, background: "white".into(), background_color: None, + profile: None, unit: "px".into(), resolution_unit: "in".into(), }; From 5418399ddaa3dd251aac21e59ed3cd962d2f5623 Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Sun, 18 Oct 2026 22:24:54 +0300 Subject: [PATCH 0672/1452] perf(compose): blend pixel layers row by row, Normal in 8-pixel vector blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pixel layer with nothing clipped to it is read from its tiles a row at a time and blended straight onto the backdrop, instead of being copied into a tile-sized buffer first (a megabyte per layer per 256² render tile). Normal RGB rows go through a branch-free kernel over blocks of 8 pixels that the compiler vectorizes; few-band renders also run their bands in parallel. Bit for bit the same: the row kernel is tested against the scalar loop (NaN, infinities, subnormals, signed zeros), the row path against the tile copy at 8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity suite's CPU references (1620 renders) are unchanged. P60 on the CPU compositor: 627 -> 530 ms (the render without effects 300 -> 199 ms). --- crates/compose/src/lib.rs | 95 ++++++++++++++++++++++++--------- crates/compose/src/psblend.rs | 57 ++++++++++++++++++++ crates/compose/src/tests.rs | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 25 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..5d5a733e2b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -193,14 +193,15 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer } }); } else { - // Few, wide bands: the tiles of each band in parallel. - for (i, band) in out.px.chunks_mut(band_len).enumerate() { + // Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's + // slowest tile doesn't leave the other cores idle until the next band starts. + out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { let y0 = rect.y0 + i as i32 * tile; let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); for part in &parts { put(band, y0, part); } - } + }); } } #[cfg(target_arch = "wasm32")] @@ -1350,6 +1351,38 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L } } +/// The surface `render_content` reads `layer`'s pixels from, when that is all it draws. +fn pixel_surface(layer: &Layer) -> Option<&Surface> { + if matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) { + return None; + } + layer.surface() +} + +/// A pixel layer (nothing clipped to it) onto `backdrop`, read from its tiles a row at a time: +/// the same arithmetic as `render_content` + `blend_into_fill`, without a tile-sized copy of the +/// layer (a megabyte per 256² render tile). +fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mut Buffer, cx: &Ctx) { + let rect = backdrop.rect; + let w = rect.width() as usize; + if w == 0 { + return; + } + let mv = mask_vals(layer, rect, cx); + let gamma = text_gamma(layer); + let mut row = vec![[0.0f32; 4]; w]; + for (ry, dst) in backdrop.px.chunks_exact_mut(w).enumerate() { + let y = rect.y0 + ry as i32; + surf.read_rgba_into(Rect::new(rect.x0, y, rect.x1, y + 1), &mut row); + if let Some(m) = mv.as_ref().and_then(|m| m.get(ry * w..(ry + 1) * w)) { + for (p, k) in row.iter_mut().zip(m) { + p[3] *= k; + } + } + blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma); + } +} + fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let rect = backdrop.rect; // A TSL-off stroke base that is transparent here can still have visible clipped siblings; @@ -1468,6 +1501,11 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); return; } + if clipped.is_empty() + && let Some(surf) = pixel_surface(layer) + { + return composite_pixel_layer(layer, surf, backdrop, cx); + } let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); @@ -1853,44 +1891,51 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32 /// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for /// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise. fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { - if fill >= 1.0 || !psblend::fill_is_special(mode) { - return blend_into_g(backdrop, src, mode, opacity * fill, gamma); + for_rows(backdrop, src, |dst, s, x0, y| blend_span_fill(dst, s, x0, y, mode, opacity, fill, gamma)); +} + +/// [`blend_into`] mixing coverage in a `gamma` space (type layers). +fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { + for_rows(backdrop, src, |dst, s, x0, y| blend_span_g(dst, s, x0, y, mode, opacity, gamma)); +} + +/// Runs `f(backdrop row, src row, x0, y)` over the rows of two buffers covering the same rect. +fn for_rows(backdrop: &mut Buffer, src: &Buffer, mut f: impl FnMut(&mut [[f32; 4]], &[[f32; 4]], i32, i32)) { + let rect = backdrop.rect; + let w = (rect.width() as usize).max(1); + for (ry, (dst, s)) in backdrop.px.chunks_mut(w).zip(src.px.chunks(w)).enumerate() { + f(dst, s, rect.x0, rect.y0 + ry as i32); } - for (b, s) in backdrop.px.iter_mut().zip(&src.px) { +} + +/// [`blend_into_fill`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +#[allow(clippy::too_many_arguments)] +fn blend_span_fill(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { + if fill >= 1.0 || !psblend::fill_is_special(mode) { + return blend_span_g(dst, src, x0, y, mode, opacity * fill, gamma); + } + for (b, s) in dst.iter_mut().zip(src) { if s[3] > 0.0 { *b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma); } } } -/// [`blend_into`] mixing coverage in a `gamma` space (type layers). -fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { +/// [`blend_into_g`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, gamma: f32) { // The blend space is constant in this loop. Some TLS implementations synchronise even a // boolean read, so select the common Normal kernel once instead of once per pixel. if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) { - for (i, b) in backdrop.px.iter_mut().enumerate() { - let s = src.px[i]; - if s[3] <= 0.0 { - continue; - } - *b = psblend::composite_normal_rgb(*b, s, opacity); - } - return; + return psblend::composite_normal_rgb_span(dst, src, opacity); } - let rect = backdrop.rect; - let w = rect.width() as i32; - for (i, b) in backdrop.px.iter_mut().enumerate() { - let mut s = src.px[i]; + for (x, (b, s)) in (x0..).zip(dst.iter_mut().zip(src)) { + let mut s = *s; if s[3] <= 0.0 { continue; } - let mut mode = mode; if mode == BlendMode::Dissolve { - let x = rect.x0 + (i as i32 % w); - let y = rect.y0 + (i as i32 / w); s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 }; - mode = BlendMode::Normal; - *b = blend::composite(mode, *b, s, 1.0); + *b = blend::composite(BlendMode::Normal, *b, s, 1.0); continue; } *b = blend::composite_gamma(mode, *b, s, opacity, gamma); diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index 4cff6f3082..b5eeaa79d0 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -238,6 +238,63 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity normal_rgb(backdrop, source, as_, ao) } +/// [`composite_normal_rgb`] over a row, bit for bit, in blocks the compiler vectorizes (see +/// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel. +pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) { + let n = dst.len().min(src.len()); + let (dc, dt) = dst[..n].as_chunks_mut::(); + let (sc, st) = src[..n].as_chunks::(); + for (b, s) in dc.iter_mut().zip(sc) { + normal_lanes(b, s, opacity); + } + for (b, s) in dt.iter_mut().zip(st) { + // Not `s[3] > 0`: a NaN alpha is blended (into NaN), as the scalar loop does. + if s[3] <= 0.0 { + continue; + } + *b = composite_normal_rgb(*b, *s, opacity); + } +} + +/// Pixels per block of [`normal_lanes`]: two 128-bit vectors (or one 256-bit) per channel. +const LANES: usize = 8; + +/// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels, +/// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a +/// select per pixel between the backdrop, zero and the mix. Same operations in the same order +/// (Rust never fuses them into multiply-adds) and the same tests, NaN included: bit-identical. +#[inline] +#[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep +fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) { + let lane = |p: &[[f32; 4]; LANES], c: usize| -> [f32; LANES] { std::array::from_fn(|i| p[i][c]) }; + let (b0, b1, b2, ab) = (lane(b, 0), lane(b, 1), lane(b, 2), lane(b, 3)); + let (s0, s1, s2, s3) = (lane(s, 0), lane(s, 1), lane(s, 2), lane(s, 3)); + let mut as_ = [0.0f32; LANES]; + let mut ao = [0.0f32; LANES]; + let mut kb = [0.0f32; LANES]; + let mut inv = [0.0f32; LANES]; + for i in 0..LANES { + as_[i] = s3[i] * opacity; + ao[i] = as_[i] + ab[i] * (1.0 - as_[i]); + kb[i] = ab[i] * (1.0 - as_[i]); + inv[i] = 1.0 / ao[i]; + } + for i in 0..LANES { + let mix = [(kb[i] * b0[i] + as_[i] * s0[i]) * inv[i], (kb[i] * b1[i] + as_[i] * s1[i]) * inv[i], (kb[i] * b2[i] + as_[i] * s2[i]) * inv[i], ao[i]]; + // The scalar path's tests: skipped where the source is empty (`s[3] <= 0`) or adds + // nothing (`as_ <= 0`), zero where nothing is left (`ao <= 0`). + let keep = (s3[i] <= 0.0) | (as_[i] <= 0.0); + let gone = ao[i] <= 0.0; + b[i] = if keep { + b[i] + } else if gone { + [0.0; 4] + } else { + mix + }; + } +} + #[inline] fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] { // B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..4cc6e7925e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -80,6 +80,105 @@ fn normal_rgb_kernel_preserves_scalar_bits() { } } +/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover +/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals +/// and values outside 0..1. +#[test] +fn normal_rgb_span_preserves_scalar_bits() { + let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30]; + let mut seed = 0x9e37_79b9u32; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + let mut value = || { + let r = next(); + if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 } + }; + for len in 0..41 { + for _ in 0..40 { + let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let opacity = value(); + let mut want = backdrop.clone(); + for (b, s) in want.iter_mut().zip(&source) { + if s[3] <= 0.0 { + continue; + } + *b = original_normal_rgb(*b, *s, opacity); + } + let mut got = backdrop; + psblend::composite_normal_rgb_span(&mut got, &source, opacity); + for (i, (g, w)) in got.iter().zip(&want).enumerate() { + assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); + } + } + } +} + +/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy +/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes +/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the +/// layer's pixels. +#[test] +fn pixel_layer_rows_match_tile_copy() { + let rect = Rect::new(-3, 2, 37, 21); + let modes = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Dissolve, + BlendMode::Overlay, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::VividLight, + BlendMode::Difference, + BlendMode::Hue, + ]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut l = Layer::raster("p", fmt); + for y in 0..20 { + for x in 0..30 { + let t = (x * 7 + y * 13) as f32 / 400.0; + let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4]; + l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a])); + } + } + let mut sharp = LayerMask::reveal_all(); + sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]); + let mut feathered = sharp.clone(); + feathered.feather = 2.5; + for mask in [None, Some(sharp), Some(feathered)] { + l.mask = mask; + for mode in modes { + for fill in [1.0, 0.4] { + l.blend = mode; + l.opacity = 0.8; + l.fill_opacity = fill; + let mut doc = doc_white(40, 30); + doc.layers.push(l.clone()); + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(&doc, &patterns); + let layer = &doc.layers[1]; + let backdrop = Buffer { + rect, + px: (0..rect.width() * rect.height()) + .map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]]) + .collect(), + }; + let mut want = backdrop.clone(); + let content = render_content(layer, rect, &cx).unwrap(); + blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); + let mut got = backdrop; + composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx); + assert_buffer_bits(&got, &want); + } + } + } + } +} + fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) { let rect = Rect::new(-3, -2, 5, 0); let mut bottom = Surface::new(fmt); From 33dd5f0103c9b509d5dd80c45477ada380a8f3d2 Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Sun, 18 Oct 2026 22:24:54 +0300 Subject: [PATCH 0673/1452] perf(gpu): blend plain layers several to a pass, P60/P61 111 -> 28 ms - LayerBlend: a plain layer is blended straight from its texture in one pass instead of a Content pass to a scratch chunk and a Blend pass. - fs_layerbatch: runs of up to four plain layers that only feed each other are drawn as one pass, reading and writing the chunk once (the uniform record grows to its 256-byte stride to hold the extra layers). - Drop shadows and outer glows alone: the merge builds the layer from its shape (F_LAYER_SHAPE), and nothing is copied back when the effect region covers the canvas. Such a layer takes 3 passes instead of 6. - fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code alone. Per-pass timestamps showed the batch spent 80% of its time on the register cost of carrying every blend mode, not on the arithmetic; Normal goes through the same function in every shader, so a layer gives the same bits whichever pass draws it. P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms -> 28.2 / 28.1 ms. With the app's default Memory Usage these documents still fall back to the CPU compositor on unified memory, so the budgets stay unenforced. Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to before and the rest differ by at most 1e-4/255, except one pixel one 8-bit step apart where an adjustment rounds at .5; the mean difference from the CPU reference is unchanged. New parity tests cover runs of every mode, Normal runs at 8/16/32-bit and Lab, and exterior effects at any fill. --- crates/gpu/src/compose.wgsl | 120 ++++++++++++++- crates/gpu/src/lib.rs | 229 ++++++++++++++++++++++++++++- crates/gpu/src/plan.rs | 138 ++++++++++++++--- crates/gpu/tests/parity.rs | 142 +++++++++++++++++- crates/gpu/tests/uniform_limits.rs | 9 +- 5 files changed, 596 insertions(+), 42 deletions(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..1b62e2d46d 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -31,6 +31,13 @@ struct Op { map_origin: vec2, map_size: vec2, p4: vec4, + // fs_layerbatch's third extra layer and the rest of its second (see `batch_layer`); zero + // elsewhere. With them the record fills its 256-byte stride. + q0: vec4, + q1: vec4, + q2: vec4, + q3: vec4, + q4: vec4, }; const F_MASK: u32 = 1u; // layer has an enabled mask @@ -53,6 +60,7 @@ const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment res const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3 const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w +const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its colour at fill p0.x inside it × its alpha @group(0) @binding(0) var chunk: Chunk; @group(0) @binding(1) var op: Op; @@ -312,6 +320,9 @@ fn fill_is_special(mode: i32) -> bool { // colour (Hard Mix: (cb - f(1 - cs)) / (1 - f)) at `opacity`, Fill staying coverage over // transparency. Lab documents mix Normal in CIELAB; type layers mix in the text gamma space. fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32, gamma_on: bool) -> vec4 { + // The same code as fs_layerbatch_normal, so a layer gives the same bits whichever pass + // draws it (a render resumed from the prefix cache splits runs differently). + if (mode == M_NORMAL && !gamma_on && (op.flags & F_LAB) == 0u) { return composite_normal_fill(b, s, opacity, fill); } let f = clamp(fill, 0.0, 1.0); let special = f < 1.0 && fill_is_special(mode); let ab = b.a; @@ -340,6 +351,21 @@ fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32 return vec4(text_dec(wb * text_enc(b.rgb, g) + wbs * text_enc(bl, g) + ws * text_enc(s.rgb, g), g), ao); } +// composite_fill for Normal outside Lab documents and without text gamma: Fill is plain coverage +// and the blend is the source. Kept apart so fs_layerbatch_normal doesn't carry every other +// mode's code, which costs registers and so pixels in flight. +fn composite_normal_fill(b: vec4, s: vec4, opacity: f32, fill: f32) -> vec4 { + let ab = b.a; + let over = s.a * opacity * clamp(fill, 0.0, 1.0); + if (over <= 0.0) { return b; } + let ao = ab + (1.0 - ab) * over; + if (ao <= 0.0) { return vec4(0.0); } + let wb = ab * (1.0 - over) / ao; + let wbs = ab * over / ao; + let ws = (1.0 - ab) * over / ao; + return vec4(wb * b.rgb + wbs * s.rgb + ws * s.rgb, ao); +} + // photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB). const D50: vec3 = vec3(0.96422, 1.0, 0.82521); fn lab_f(t: f32) -> f32 { @@ -812,12 +838,9 @@ fn fs_mask(in: VOut) -> @location(0) vec4 { return c; } -// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. -@fragment -fn fs_blend(in: VOut) -> @location(0) vec4 { - let p = local(in.pos); - let b = textureLoad(tex_a, p, 0); - var s = textureLoad(tex_b, p, 0); +// blend_into(backdrop = b, src = s, mode, opacity) at chunk pixel p, including Dissolve. +fn blend_px(p: vec2, b: vec4, src: vec4) -> vec4 { + var s = src; if (s.a <= 0.0) { return b; } if (op.mode == M_DISSOLVE) { s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity * op.fill); @@ -826,6 +849,85 @@ fn fs_blend(in: VOut) -> @location(0) vec4 { return composite_fill(op.mode, b, s, op.opacity, op.fill, (op.flags & F_TEXT_GAMMA) != 0u); } +// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. +@fragment +fn fs_blend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + return blend_px(p, textureLoad(tex_a, p, 0), textureLoad(tex_b, p, 0)); +} + +// fs_content then fs_blend in one pass: the layer's own pixels (times its mask) blended straight +// onto A, without writing them to a chunk texture and reading them back. +@fragment +fn fs_layerblend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + return blend_px(p, textureLoad(tex_a, p, 0), s); +} + +// The pixel at document pixel d of an extra layer of fs_layerbatch: an unmasked plain layer whose +// pixels are in texture `t` (0: tex_b, 1: tex_c, 2: map_tex). Three vec4 of the record describe +// it: geo = (texture origin x, y, size w, h), color = its colour outside the texture, +// prm = (opacity, fill, mode, 1 when it has a texture), all whole numbers stored as floats. +fn batch_texel(t: i32, d: vec2, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let q = d - vec2(geo.xy); + if (prm.w > 0.5 && inside(q, vec2(geo.zw))) { + if (t == 0) { + return textureLoad(tex_b, q, 0); + } else if (t == 1) { + return textureLoad(tex_c, q, 0); + } + return textureLoad(map_tex, q, 0); + } + return color; +} + +// One extra layer of fs_layerbatch (see batch_texel) blended onto b at document pixel d. +fn batch_layer(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_fill(i32(prm.z), b, s, prm.x, prm.y, false); +} + +// batch_layer of a Normal layer outside Lab documents. +fn batch_layer_normal(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_normal_fill(b, s, prm.x, prm.y); +} + +// fs_layerblend of this pass's layer, then `op.kind` (at most 3) more plain layers: consecutive +// LayerBlend passes the executor draws as one, reading and writing the chunk once for all of them. +@fragment +fn fs_layerbatch(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = blend_px(p, textureLoad(tex_a, p, 0), s); + if (op.kind >= 1) { b = batch_layer(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + +// fs_layerbatch for a run of Normal layers without text gamma, outside Lab documents. +@fragment +fn fs_layerbatch_normal(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = textureLoad(tex_a, p, 0); + if (s.a > 0.0) { b = composite_normal_fill(b, s, op.opacity, op.fill); } + if (op.kind >= 1) { b = batch_layer_normal(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer_normal(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer_normal(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + // composite_atop(base = A, src = B): blend as if the base were opaque, keep its alpha. @fragment fn fs_atop(in: VOut) -> @location(0) vec4 { @@ -1168,8 +1270,12 @@ fn mix_premul(a: vec4, b: vec4, k: f32) -> vec4 { fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let p = local(in.pos); var w = textureLoad(tex_a, p, 0); - let l = textureLoad(tex_b, p, 0); + var l = textureLoad(tex_b, p, 0); let c = textureLoad(tex_c, p, 0); + if ((op.flags & F_LAYER_SHAPE) != 0u) { + // fs_fxinit INIT_INSIDE then INIT_ALPHA, on the shape in B. + l = vec4(l.rgb, select(0.0, op.p0.x, l.a > INSIDE_EPS) * min(l.a, 1.0)); + } if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); } let atop = (op.flags & F_ATOP) != 0u; var before = c; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index e99dc53416..468649a108 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -70,7 +70,7 @@ const MAP16: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; pub const CHUNK: u32 = 1024; const STRIDE: u64 = 256; const CHUNK_UNIFORM: u64 = 16; -const OP_UNIFORM: u64 = 176; +const OP_UNIFORM: u64 = 256; /// GPU memory the effect-map cache may hold before evicting layers not drawn this frame. pub const FX_BUDGET: usize = 1536 << 20; /// Largest side of the square pages layer surfaces are stored in on the GPU (smaller when the @@ -139,6 +139,10 @@ pub struct Stats { pub prefix_hits: usize, /// Planned prefix passes skipped, summed over the reused chunks. pub prefix_passes_skipped: usize, + /// Plain layers blended inside another layer's pass (`fs_layerbatch`), summed over chunks. + pub layers_batched: usize, + /// Batched passes drawn with Normal's code alone (`fs_layerbatch_normal`), summed over chunks. + pub normal_batches: usize, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1405,6 +1409,8 @@ impl Compositor { .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); + // Runs of plain layers drawn as one pass each (`fs_layerbatch`). + let runs = batch_runs(plan, checkpoint.map(|c| c.pass_end)); // Chunk pool. while self.pool.len() < plan.slots as usize { @@ -1439,7 +1445,13 @@ impl Compositor { } let (tex, mask) = bound.views[i]; let map = bound.maps[i].as_ref().map(|(_, r)| *r); - let w = op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map); + let run = runs.get(i).copied().unwrap_or(1); + let w = if run > 1 { + let extras: Vec<_> = (i + 1..i + run).filter_map(|j| Some((plan.passes.get(j)?, bound.views.get(j).and_then(|v| v.0).map(|t| t.1)))).collect(); + batch_op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), &extras) + } else { + op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map) + }; let off = (op_base + i as u64 * STRIDE) as usize; data[off..][..w.len()].copy_from_slice(&w); } @@ -1468,6 +1480,13 @@ impl Compositor { let lv = fr.luts.get(i).and_then(Option::as_ref).map_or(dummy, |(_, v)| v); let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v); let pat = fr.patterns.get(i).and_then(Option::as_ref).unwrap_or(dummy); + let run = runs.get(i).copied().unwrap_or(1); + if run > 1 { + // The extra layers' pixels in tex_b, tex_c and map_tex (`batch_layer`). + let extra = |e: usize| bound.views.get(i + e).and_then(|v| v.0).map_or(dummy, |(k, _)| resident_views[k]); + bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), extra(1), tv, mv, lv, extra(2), extra(3), pat]))); + continue; + } bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), slot(p.b), tv, mv, lv, slot(p.c), map, pat]))); } @@ -1481,8 +1500,10 @@ impl Compositor { stats.prefix_hits = stats.prefix_hits.saturating_add(1); stats.prefix_passes_skipped = stats.prefix_passes_skipped.saturating_add(resume); } + // Passes drawn inside the previous `fs_layerbatch` pass. + let mut batched_to = 0; for (i, p) in plan.passes.iter().enumerate() { - if i < resume { + if i < resume || i < batched_to { continue; } // Chunk textures, uniforms and bind groups outlive the submit, and the queue @@ -1542,7 +1563,21 @@ impl Compositor { _ => {} } self.recorded += 1; - let target = &self.pool[p.dst as usize].1; + // A run draws as one pass into the slot its last layer writes. + let run = runs.get(i).copied().unwrap_or(1); + let (kernel, dst) = match plan.passes.get(i + run - 1) { + Some(last) if run > 1 => { + batched_to = i + run; + stats.layers_batched = stats.layers_batched.saturating_add(run - 1); + let kernel = plan.passes.get(i..i + run).map_or(Kernel::LayerBatch, batch_kernel); + if kernel == Kernel::LayerBatchNormal { + stats.normal_batches = stats.normal_batches.saturating_add(1); + } + (kernel, last.dst) + } + _ => (p.kernel, p.dst), + }; + let target = &self.pool[dst as usize].1; let clear = p.kernel == Kernel::Clear; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_compose_pass"), @@ -1563,7 +1598,7 @@ impl Compositor { if clear { continue; } - pass.set_pipeline(&self.kit.pipelines[&(p.kernel, self.acc_format)]); + pass.set_pipeline(&self.kit.pipelines[&(kernel, self.acc_format)]); pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height()); pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]); pass.set_bind_group(1, bg1[i].as_ref(), &[]); @@ -2046,6 +2081,38 @@ fn web_time_now() -> Option { /// The uniform record of a pass. fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { + words(&op_values(p, tex, mask, map)) +} + +/// The uniform record of the `fs_layerbatch` pass that draws `p` (whose texture and mask cover +/// `tex` and `mask`) and then `extras`, each an unmasked plain layer's `LayerBlend` pass with the +/// area its texture covers. Layout: see `batch_layer` in compose.wgsl. +fn batch_op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, extras: &[(&plan::Pass<'_>, Option<[i32; 4]>)]) -> Vec { + let mut v = op_values(p, tex, mask, None); + // Word offsets of the record's vec4s holding the extra layers, three each: p0, p1, p2 | p3, + // q0, q1 | q2, q3, q4. + const AT: [usize; 9] = [20, 24, 28, 32, 44, 48, 52, 56, 60]; + if let Some(kind) = v.get_mut(1) { + *kind = I(extras.len().min(AT.len() / 3) as i32); + } + for (e, (x, area)) in extras.iter().take(AT.len() / 3).enumerate() { + let r = area.unwrap_or([0; 4]); + let geo = [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]; + let prm = [x.opacity, x.fill, plan::mode_index(x.mode) as f32, if area.is_some() { 1.0 } else { 0.0 }]; + for (k, vals) in [geo, x.color, prm].into_iter().enumerate() { + let at = AT[e * 3 + k]; + for (c, f) in vals.into_iter().enumerate() { + if let Some(w) = v.get_mut(at + c) { + *w = F(f); + } + } + } + } + words(&v) +} + +/// The words of a pass's uniform record (`Op` in compose.wgsl). +fn op_values(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { let mut flags = p.flags; let (mut to, mut ts, mut mo, mut ms) = ([0; 2], [0; 2], [0; 2], [0; 2]); if let Some(r) = tex { @@ -2092,7 +2159,67 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m } v.extend([I(mpo[0]), I(mpo[1]), I(mps[0]), I(mps[1])]); v.extend(p.extra.iter().map(|f| F(*f))); - words(&v) + // q0..q4: only `fs_layerbatch` reads them (`batch_op_words`). + v.extend((0..20).map(|_| F(0.0))); + v +} + +/// Most layers one `fs_layerbatch` pass blends: its own and the three more its record holds. +const BATCH: usize = 4; + +/// For each pass, how many passes from it on the executor draws as one `fs_layerbatch` pass +/// (1: the pass alone). A run starts at a `LayerBlend` pass over the whole chunk (no clip) that +/// isn't a gradient (whose parameters use the words the extra layers go in), and continues while +/// the next pass is a `LayerBlend` of an unmasked plain layer (no text gamma, no Dissolve) onto +/// exactly what the previous pass wrote, a slot nothing else reads before it is written again. +/// A run doesn't cross `capture`, the pass where this render captures its prefix checkpoint, and +/// never ends in the slot it reads from. +fn batch_runs(plan: &Plan<'_>, capture: Option) -> Vec { + let passes = &plan.passes; + let n = passes.len(); + let reads = |p: &plan::Pass<'_>, s: plan::Slot| [p.a, p.b, p.c, p.d].contains(&Some(s)); + // feeds_next[i]: what pass i writes is read by pass i + 1 alone. + let feeds_next: Vec = (0..n) + .map(|i| { + let (Some(this), Some(next)) = (passes.get(i), passes.get(i + 1)) else { return false }; + let dst = this.dst; + if next.a != Some(dst) || [next.b, next.c, next.d].contains(&Some(dst)) { + return false; + } + for later in passes.iter().skip(i + 2) { + // A copy into a slot keeps what lies outside its rect: that is a read too. + if reads(later, dst) || (later.dst == dst && later.kernel == Kernel::CopyRect) { + return false; + } + if later.dst == dst { + return true; + } + } + dst != plan.root + }) + .collect(); + let lead = |p: &plan::Pass<'_>| p.kernel == Kernel::LayerBlend && p.clip.is_none() && !p.gradient; + let extra = |p: &plan::Pass<'_>| lead(p) && p.mask.is_none() && p.flags & plan::F_TEXT_GAMMA == 0 && p.mode != photocraft_color::BlendMode::Dissolve; + (0..n) + .map(|i| { + let Some(first) = passes.get(i).filter(|p| lead(p)) else { return 1 }; + let (mut k, mut best) = (1, 1); + while k < BATCH && feeds_next.get(i + k - 1).copied().unwrap_or(false) && passes.get(i + k).is_some_and(extra) && capture != Some(i + k) { + k += 1; + if passes.get(i + k - 1).is_some_and(|p| Some(p.dst) != first.a) { + best = k; + } + } + best + }) + .collect() +} + +/// The pipeline that draws `run` (passes `batch_runs` joined): `fs_layerbatch_normal` when every +/// layer blends Normal without text gamma outside Lab documents, else `fs_layerbatch`. +fn batch_kernel(run: &[plan::Pass<'_>]) -> Kernel { + let normal = |p: &plan::Pass<'_>| p.mode == photocraft_color::BlendMode::Normal && p.flags & (plan::F_TEXT_GAMMA | plan::F_LAB) == 0; + if run.iter().all(normal) { Kernel::LayerBatchNormal } else { Kernel::LayerBatch } } /// Canvas rounded out to whole tiles. @@ -2537,6 +2664,96 @@ pub fn render_to_vec_stats( mod tests { use super::*; + /// A run joins `LayerBlend` passes that only feed each other, at most `BATCH` of them, and + /// never ends in the slot it reads from. + fn check_runs(p: &Plan<'_>, capture: Option) -> Vec { + let runs = batch_runs(p, capture); + assert_eq!(runs.len(), p.passes.len()); + for (i, &k) in runs.iter().enumerate() { + assert!((1..=BATCH).contains(&k), "pass {i}: run {k}"); + if k > 1 { + assert_ne!(Some(p.passes[i + k - 1].dst), p.passes[i].a, "run at {i} renders into its input"); + for j in i + 1..i + k { + assert_eq!(p.passes[j].kernel, Kernel::LayerBlend); + assert_eq!(p.passes[j].a, Some(p.passes[j - 1].dst), "run at {i}: pass {j} doesn't read the one before"); + assert!(p.passes[j].mask.is_none() && p.passes[j].mode != photocraft_color::BlendMode::Dissolve, "run at {i}: pass {j}"); + assert_ne!(capture, Some(j), "run at {i} skips the captured checkpoint"); + } + } + } + runs + } + + #[test] + fn plain_layers_batch_and_runs_stop_where_a_pass_must_stand_alone() { + use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerMask}; + let mut d = Document::with_background("t", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + for i in 0..12 { + let mut l = Layer::raster(format!("l{i}"), d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[0.2, 0.4, 0.6, 0.5]); + d.layers.push(l); + } + let p = plan::plan(&d).unwrap(); + let runs = check_runs(&p, None); + assert!(runs.iter().any(|k| *k >= 3), "{runs:?}"); + // A run stops at the checkpoint this render captures, wherever that is. + for cp in &p.checkpoints { + check_runs(&p, Some(cp.pass_end)); + } + // A masked or Dissolve layer, or a clipped one, never joins a run as an extra layer. + d.layers[3].mask = Some(LayerMask::hide_all()); + d.layers[6].blend = BlendMode::Dissolve; + d.layers[9].clipped = true; + check_runs(&plan::plan(&d).unwrap(), None); + // Nothing to batch: a lone layer. + d.layers.truncate(1); + assert_eq!(check_runs(&plan::plan(&d).unwrap(), None), vec![1; plan::plan(&d).unwrap().passes.len()]); + } + + #[test] + fn batch_records_hold_three_extra_layers_in_the_spare_words() { + let lead = plan::Pass::new(Kernel::LayerBlend, 0); + let mut x = plan::Pass::new(Kernel::LayerBlend, 1); + x.opacity = 0.25; + x.fill = 0.5; + x.color = [0.1, 0.2, 0.3, 0.4]; + let w = batch_op_words(&lead, None, None, &[(&x, Some([1, 2, 3, 4])), (&x, None), (&x, None), (&x, None)]); + assert_eq!(w.len() as u64, OP_UNIFORM); + let word = |i: usize| <[u8; 4]>::try_from(&w[i * 4..i * 4 + 4]).unwrap(); + let f = |i: usize| f32::from_le_bytes(word(i)); + // At most three extras; the first's geometry, colour and parameters in p0, p1, p2. + assert_eq!(i32::from_le_bytes(word(1)), 3); + assert_eq!([f(20), f(21), f(22), f(23)], [1.0, 2.0, 3.0, 4.0]); + assert_eq!([f(24), f(25), f(26), f(27)], [0.1, 0.2, 0.3, 0.4]); + assert_eq!([f(28), f(29), f(31)], [0.25, 0.5, 1.0]); + // The third's parameters in q4; one without pixels says so. + assert_eq!([f(60), f(61), f(63)], [0.25, 0.5, 0.0]); + // Other passes leave the batch words zero. + let plain = op_words(&x, None, None, None); + assert!(plain[44 * 4..].iter().all(|b| *b == 0)); + } + + #[test] + fn runs_of_normal_layers_draw_with_normal_code_alone() { + use photocraft_color::BlendMode; + let pass = |mode: BlendMode, flags: u32| { + let mut p = plan::Pass::new(Kernel::LayerBlend, 0); + p.mode = mode; + p.flags = flags; + p + }; + let normal = pass(BlendMode::Normal, plan::F_HDR); + assert_eq!(batch_kernel(&[normal.clone(), pass(BlendMode::Normal, 0)]), Kernel::LayerBatchNormal); + // Any other mode, text gamma or a Lab document anywhere in the run takes the full shader. + for other in + [pass(BlendMode::Multiply, 0), pass(BlendMode::Dissolve, 0), pass(BlendMode::Normal, plan::F_TEXT_GAMMA), pass(BlendMode::Normal, plan::F_LAB)] + { + assert_eq!(batch_kernel(&[normal.clone(), other.clone()]), Kernel::LayerBatch); + assert_eq!(batch_kernel(&[other, normal.clone()]), Kernel::LayerBatch); + } + } + #[test] fn uniform_buffer_sizes_are_checked_before_allocation() { assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..6cb44b9200 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -34,6 +34,14 @@ pub enum Kernel { Content, Mask, Blend, + /// `Content` then `Blend` in one pass: a plain layer's pixels blended straight onto A. + LayerBlend, + /// A run of consecutive `LayerBlend` passes drawn as one (never planned: the executor draws + /// it in their place, see `batch_runs`). + LayerBatch, + /// `LayerBatch` of a run whose layers all blend Normal without text gamma, outside Lab + /// documents (never planned: the executor picks it, see `batch_kernel`). + LayerBatchNormal, Atop, Adjust, AdjMix, @@ -73,6 +81,9 @@ impl Kernel { Kernel::Content => "fs_content", Kernel::Mask => "fs_mask", Kernel::Blend => "fs_blend", + Kernel::LayerBlend => "fs_layerblend", + Kernel::LayerBatch => "fs_layerbatch", + Kernel::LayerBatchNormal => "fs_layerbatch_normal", Kernel::Atop => "fs_atop", Kernel::Adjust => "fs_adjust", Kernel::AdjMix => "fs_adjmix", @@ -111,10 +122,13 @@ impl Kernel { } /// Every kernel with a pipeline. - pub const DRAWN: [Kernel; 21] = [ + pub const DRAWN: [Kernel; 24] = [ Kernel::Content, Kernel::Mask, Kernel::Blend, + Kernel::LayerBlend, + Kernel::LayerBatch, + Kernel::LayerBatchNormal, Kernel::Atop, Kernel::Adjust, Kernel::AdjMix, @@ -395,6 +409,10 @@ pub const F_ADD_DIFF: u32 = 16384; pub const F_BLEND_IF: u32 = 524288; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// `FxMerge`: B is the layer's shape (its content); the layer is its colour at fill opacity +/// (`p0.x`) inside the shape, times the shape's alpha: `FxInit` `INIT_INSIDE` then +/// `INIT_ALPHA` in the merge, for a layer with no interior effects between them. +pub const F_LAYER_SHAPE: u32 = 1_048_576; /// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as /// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and @@ -764,26 +782,36 @@ impl<'a> Planner<'a> { } return Ok(backdrop); } - let start = self.passes.len(); - let mut content = self.content(layer)?; - for c in visible_clipped { - content = self.atop(c, content)?; - } - if let Some(clip) = clip { - // Everything that built the layer's content only matters inside its bounds. - for p in &mut self.passes[start..] { - if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { - p.clip = Some(clip); + // A layer with nothing clipped to it whose content is one Content pass is blended + // straight from its pixels (`Kernel::LayerBlend`): one pass instead of two, and no + // scratch chunk written and read back. + let mut p = if visible_clipped.is_empty() && single_content_pass(layer) { + let mut p = self.content_pass(layer)?; + p.kernel = Kernel::LayerBlend; + p + } else { + let start = self.passes.len(); + let mut content = self.content(layer)?; + for c in visible_clipped { + content = self.atop(c, content)?; + } + if let Some(clip) = clip { + // Everything that built the layer's content only matters inside its bounds. + for p in &mut self.passes[start..] { + if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { + p.clip = Some(clip); + } } } - } - let mut p = Pass::new(Kernel::Blend, 0); + let mut p = Pass::new(Kernel::Blend, 0); + p.b = Some(content); + p + }; p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop }); - p.b = Some(content); p.mode = layer.blend; p.opacity = layer.opacity; p.fill = layer.fill_opacity; - p.flags = gamma_flag(layer); + p.flags |= gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = clip; let merged = self.emit(p); @@ -804,6 +832,11 @@ impl<'a> Planner<'a> { /// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop); /// returns the new backdrop. Consumes both. fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot { + // A clip over the whole canvas left no pixel of `merged` uncomputed: it is the backdrop. + if clip.contains_rect(&self.cx.canvas) { + self.release(backdrop); + return merged; + } let dst = if self.refs[backdrop as usize] == 1 { backdrop } else { @@ -1129,6 +1162,25 @@ impl<'a> Planner<'a> { } } + // Only exterior effects (drop shadows, outer glows): nothing is painted between the + // layer's colour inside its shape and the shape's alpha, so the merge derives both from + // the shape (`F_LAYER_SHAPE`) instead of two passes building the layer. + let premerge = photocraft_compose::effects::premerges_layer(layer); + if !relative && !premerge && items.iter().all(|e| matches!(e, Effect::DropShadow(_) | Effect::OuterGlow(_))) { + let mut p = Pass::new(Kernel::FxMerge, 0); + p.a = Some(w); + p.b = Some(lay_src); + p.c = Some(self.retain(backdrop)); + p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + p.opacity = layer.opacity; + p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer) | F_LAYER_SHAPE; + p.params[0][0] = layer.fill_opacity; + p.extra[3] = photocraft_compose::text_gamma(layer); + p.clip = Some(clip); + let merged = self.emit(p); + self.release(content); + return Ok(self.write_back(backdrop, merged, clip)); + } // The layer: its colour at fill opacity inside its shape; interior effects are painted // relative to the shape (coverage within it), then the shape's alpha applies. let mut l = if relative { @@ -1139,7 +1191,6 @@ impl<'a> Planner<'a> { }; // Blend Interior Effects as Group off: the layer merges in its mode first and the // interior effects paint over the result (`effects::premerges_layer`). - let premerge = photocraft_compose::effects::premerges_layer(layer); if premerge { let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(self.retain(w)); @@ -1806,6 +1857,16 @@ pub fn mode_index(m: BlendMode) -> i32 { } } +/// Whether `Planner::content` draws `layer` as one `Planner::content_pass`: not a group's stack, +/// an adjustment, or a pattern fill painted in passes. +fn single_content_pass(layer: &Layer) -> bool { + match &layer.content { + LayerContent::Group(_) | LayerContent::Adjustment(_) => false, + LayerContent::Fill(f @ Fill::Pattern { .. }) => layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1946,15 +2007,15 @@ mod tests { d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format())); } let p = plan(&d).unwrap(); - // Empty layers draw nothing. - assert_eq!(p.passes.len(), 1 + 2); + // Empty layers draw nothing: the clear and the background's one fused pass. + assert_eq!(p.passes.len(), 1 + 1); for (i, l) in d.layers.iter_mut().skip(1).enumerate() { l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]); } let p = plan(&d).unwrap(); assert!(p.slots <= 3, "{} slots", p.slots); - // The background, then each small layer over its bounds and copied back. - assert_eq!(p.passes.len(), 1 + 2 + 10 * 3); + // The background, then each small layer blended over its bounds and copied back. + assert_eq!(p.passes.len(), 1 + 1 + 10 * 2); } #[test] @@ -1989,11 +2050,44 @@ mod tests { assert_eq!(p.fx.len(), 1); let m = photocraft_compose::effects::margin(&d.layers[1]); assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m))); - // The effect result lands back in the backdrop slot. + // The effect region covers the whole canvas: the merged result is the backdrop, no copy. + assert!(p.fx[0].region.contains_rect(&d.bounds())); let last = p.passes.last().unwrap(); - assert_eq!(last.kernel, Kernel::CopyRect); + assert_eq!(last.kernel, Kernel::FxMerge); assert_eq!(last.dst, p.root); assert!(p.slots <= 6, "{} slots", p.slots); + // On a larger canvas the result lands back in the backdrop slot over the region only. + let mut big = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); + big.layers.push(d.layers[1].clone()); + let p = plan(&big).unwrap(); + assert!(!p.fx[0].region.contains_rect(&big.bounds())); + let last = p.passes.last().unwrap(); + assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(p.fx[0].region))); + } + + #[test] + fn exterior_effects_merge_from_the_shape() { + // A drop shadow alone: the layer's pixels, the shadow, the merge. With an interior effect + // the layer is built in passes of its own (colour inside the shape, the overlay, alpha). + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut l = Layer::raster("fx", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 0.5]); + l.fill_opacity = 0.7; + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + d.layers.push(l); + let p = plan(&d).unwrap(); + let fx: Vec = p.passes.iter().skip(2).map(|p| p.kernel).collect(); + assert_eq!(fx, [Kernel::Content, Kernel::FxPaint, Kernel::FxMerge], "{:?}", p.passes); + let merge = p.passes.last().unwrap(); + assert!(merge.flags & F_LAYER_SHAPE != 0); + assert_eq!(merge.params[0][0], 0.7); + d.layers[1] + .effects + .items + .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE }); + let p = plan(&d).unwrap(); + assert!(p.passes.iter().all(|p| p.flags & F_LAYER_SHAPE == 0)); + assert!(p.passes.iter().any(|p| p.kernel == Kernel::FxInit)); } #[test] diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..b71d9ca52e 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1220,21 +1220,103 @@ fn small_layers_composite_over_their_bounds() { fn many_layers_submit_inside_one_chunk() { // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is - // invalid"). 700 layers need well over 1000 passes in the document's only chunk. + // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is + // masked, so it is drawn in a pass of its own rather than batched with its neighbours. let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let modes: Vec = BlendMode::layer_modes().collect(); - for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() { + for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() { let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); l.blend = mode; l.opacity = 0.9; + l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); d.layers.push(l); } - let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}")); + let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.layers_batched, 0, "{stats:?}"); assert_eq!(stats.chunks, 1); assert!(stats.flushes > 0, "{stats:?}"); } +#[test] +fn runs_of_plain_layers_blend_in_one_pass() { + // Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at + // varied opacity and fill (the fill-special modes included), solid fills, layers that end + // inside the canvas, and the layers that end a run: masked, Dissolve, small (clipped to their + // bounds) and clipping groups. Then an edit in the middle, so the prefix cache resumes + // part-way up the stack and runs start at its checkpoint. + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(96, 64); + let modes: Vec = BlendMode::layer_modes().collect(); + for (i, mode) in modes.iter().copied().cycle().take(90).enumerate() { + let i32_ = i as i32; + let rect = if i % 5 == 2 { Rect::from_xywh(-8, 10, 70, 40) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("l", PixelFormat::RGBA8, rect, 900 + i as u32, 0.0); + l.blend = mode; + l.opacity = 0.35 + 0.05 * (i % 13) as f32; + l.fill_opacity = if i % 4 == 1 { 0.6 } else { 1.0 }; + if i % 11 == 7 { + l.mask = Some(mask(Rect::from_xywh(i32_ % 30, 0, 50, 64), 70 + i as u32, 0.0)); + } + if i % 23 == 9 { + l.clipped = true; + } + d.layers.push(l); + if i % 9 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.3, 0.6, 0.2, 0.4)))); + f.blend = modes[i % modes.len()]; + d.layers.push(f); + } + if i % 17 == 8 { + d.layers.push(noise_layer("small", PixelFormat::RGBA8, Rect::from_xywh(20, 20, 6, 5), 40 + i as u32, 0.5)); + } + } + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "no layers were batched: {stats:?}"); + let mid = d.layers.len() / 2; + d.layers[mid].opacity = 0.5; + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs, edited in the middle").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "{stats:?}"); +} + +#[test] +fn runs_of_normal_layers_blend_with_normal_code_alone() { + // Runs whose layers all blend Normal draw with `fs_layerbatch_normal`: varied opacity and fill + // over a partly transparent backdrop, masked layers leading runs, solid fills and layers that + // end inside the canvas, at every depth. Lab documents mix Normal in CIELAB, so their runs + // keep the full shader. + let Some(mut g) = gpu() else { return }; + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Lab, SampleType::U8)] + { + let mut d = Document::new("normal", Size::new(96, 64), mode, depth); + let fmt = d.pixel_format(); + d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 96, 64), 5, 0.0)); + for i in 0..40u32 { + let rect = if i % 5 == 3 { Rect::from_xywh(-6, 9, 70, 44) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("n", fmt, rect, 600 + i, if i % 3 == 0 { 0.0 } else { 0.4 }); + l.opacity = 0.2 + 0.07 * (i % 11) as f32; + l.fill_opacity = [1.0, 0.55, 0.0, 0.8][i as usize % 4]; + if i % 7 == 2 { + l.mask = Some(mask(Rect::from_xywh(i as i32 % 25, 0, 60, 64), 50 + i, 0.0)); + } + d.layers.push(l); + if i % 6 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.7, 0.2, 0.5, 0.6)))); + f.opacity = 0.45; + d.layers.push(f); + } + } + let what = format!("normal runs {mode:?} {depth:?}"); + let stats = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}")); + if mode == ColorMode::Lab { + assert!(stats.layers_batched > 0 && stats.normal_batches == 0, "{what}: {stats:?}"); + } else { + assert!(stats.normal_batches > 0, "{what}: {stats:?}"); + } + } +} + fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer { let mut l = Layer::raster(name, fmt); let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32); @@ -1570,6 +1652,60 @@ fn layer_effects_every_kind() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +#[test] +fn exterior_effects_merge_from_the_shape_at_any_fill() { + // Drop shadows and outer glows alone: the merge builds the layer from its shape (fill inside + // it, times its alpha) instead of passes of its own. Fill from 0 to 1, opacity, modes + // (including one that blends with the knocked-out shadow), both depths, a layer covering the + // canvas (no copy back), and the layer clipped onto a base (merged atop it). + let Some(mut g) = gpu() else { return }; + let mut ds = shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0); + let mut ds_through = shadow(BlendMode::Normal, 0.9, 45.0, 4.0, 5.0, 0.2); + ds_through.knocks_out = false; + let og = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge); + ds.use_global_light = true; + let stacks = [ + ("drop shadow", vec![Effect::DropShadow(ds.clone())]), + ("drop shadow, not knocked out", vec![Effect::DropShadow(ds_through)]), + ("outer glow", vec![Effect::OuterGlow(og.clone())]), + ("shadow and glow", vec![Effect::OuterGlow(og), Effect::DropShadow(ds)]), + ]; + for depth in [SampleType::U8, SampleType::U16] { + for (name, fx) in &stacks { + for (opacity, fill, blend) in [ + (1.0, 1.0, BlendMode::Normal), + (0.8, 0.6, BlendMode::Normal), + (1.0, 0.0, BlendMode::Normal), + (0.7, 0.35, BlendMode::Multiply), + (1.0, 0.5, BlendMode::Screen), + ] { + let what = format!("{name} {depth:?} opacity {opacity} fill {fill} {blend:?}"); + let mut d = fx_doc(96, 80, depth); + let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); + l.effects.items = fx.clone(); + (l.opacity, l.fill_opacity, l.blend) = (opacity, fill, blend); + d.layers.push(l.clone()); + fx_check(&mut g, &d, &what); + // The same layer clipped onto a base. + let mut base = blob("base", d.pixel_format(), 40.0, 44.0, 30.0, [0.9, 0.8, 0.3]); + base.effects.items.clear(); + d.layers.pop(); + d.layers.push(base); + l.clipped = true; + d.layers.push(l.clone()); + fx_check(&mut g, &d, &format!("{what}, clipped")); + // A partly transparent layer over the whole canvas. + let mut d = fx_doc(64, 48, depth); + let mut full = noise_layer("full", d.pixel_format(), Rect::from_xywh(0, 0, 64, 48), 7, 0.2); + full.effects.items = fx.clone(); + (full.opacity, full.fill_opacity, full.blend) = (opacity, fill, blend); + d.layers.push(full); + fx_check(&mut g, &d, &format!("{what}, full canvas")); + } + } + } +} + #[test] fn layer_effects_opacity_fill_blend_and_off_canvas() { let Some(mut g) = gpu() else { return }; diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/uniform_limits.rs index aea8fb6b12..994dbd7508 100644 --- a/crates/gpu/tests/uniform_limits.rs +++ b/crates/gpu/tests/uniform_limits.rs @@ -59,7 +59,7 @@ fn a_deep_stack_above_the_device_buffer_limit_returns_an_error_without_panicking let Some((adapter, device, queue)) = constrained_device() else { return }; let mut compositor = compositor(&adapter, &device); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); eprintln!( "uniform-limit reproduction: backend={:?}, buffer limit={}, page={}, passes={}", adapter.get_info().backend, @@ -80,7 +80,7 @@ fn an_oversized_stack_falls_back_without_faulting_the_watched_device() { let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); let fallback = match render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()) { Ok(_) => panic!("the oversized stack must return Unsupported for CPU fallback"), Err(_) => photocraft_compose::flatten(&deep), @@ -104,7 +104,8 @@ fn the_exact_buffer_limit_still_renders_with_cpu_parity() { let health = DeviceHealth::watch(&device); let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); - let boundary = fills(127); + // Every fill layer is one pass (`Kernel::LayerBlend`) after the clear. + let boundary = fills(254); assert_eq!(plan(&boundary).unwrap().passes.len(), 255, "one chunk plus255passes uses exactly65536bytes"); let expected = photocraft_compose::flatten(&boundary); let actual = render_to_vec(&mut compositor, &device, &queue, &boundary, boundary.bounds()).expect("the exact supported buffer boundary is accepted"); @@ -121,7 +122,7 @@ fn later_oversized_page_cells_are_rejected_before_render_or_encode_calls_the_sin let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = constrained_device() else { return }; let health = DeviceHealth::watch(&device); - let doc = fills_size(126, Size::new(4096, 2048)); + let doc = fills_size(252, Size::new(4096, 2048)); let region = photocraft_geom::Rect::new(2047, 0, 4096, 2048); assert_eq!(plan(&doc).unwrap().passes.len(), 253); // Default pages are2048, chunks1024: the narrow first page has2chunks and fits; From 4ea30f3673f21b98004b15c79fedeffd27ebd840 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:43:48 -0400 Subject: [PATCH 0674/1452] Background redraw: a kept composite holds its state weakly Kept held an Arc, so up to KEEP old states (and every tile only they used) stayed allocated outside Memory Usage after History dropped them. It now holds a Weak: a state still in History (undo/redo, the eye toggled back) is found as before, by pointer or by equality with the upgraded state, and an entry whose state is gone is dead and dropped at the next trim. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/background_redraw.rs | 37 ++++++++++++++----- crates/ui-egui/src/background_redraw_tests.rs | 30 +++++++++++---- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs index 9b3837d535..5b8b5d7d86 100644 --- a/crates/ui-egui/src/background_redraw.rs +++ b/crates/ui-egui/src/background_redraw.rs @@ -16,9 +16,9 @@ //! uploads those blocks instead of compositing every layer again. use std::collections::VecDeque; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError}; +use std::sync::{Arc, Weak}; use photocraft_doc::{DocId, Document}; use photocraft_engine::display_color::CanvasDisplay; @@ -86,7 +86,10 @@ impl Drop for Redraw { /// A finished redraw: the canvas texels of every block of `doc`. pub(crate) struct Kept { - doc: Arc, + /// Weak, so a kept composite never keeps a state alive (and its tiles allocated, outside Memory + /// Usage) once History has dropped it; such an entry is dead and goes at the next trim. + doc: Weak, + id: DocId, display: u64, format: eframe::wgpu::TextureFormat, blocks: Vec, @@ -100,7 +103,10 @@ impl Kept { /// else it holds), so a stale composite can't come back; Arc-shared tiles compare without /// reading their pixels. fn matches(&self, doc: &Arc, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool { - self.display == display && self.format == format && self.blocks == blocks && (Arc::ptr_eq(&self.doc, doc) || *self.doc == **doc) + self.display == display + && self.format == format + && self.blocks == blocks + && self.doc.upgrade().is_some_and(|kept| Arc::ptr_eq(&kept, doc) || *kept == **doc) } } @@ -119,15 +125,18 @@ impl KeptComposites { self.entries.front() } - /// Keep `k` (in place of an entry of the same state), within `cap` bytes in all. - fn store(&mut self, k: Kept, cap: u64) { - self.entries.retain(|e| !e.matches(&k.doc, k.display, k.format, &k.blocks)); + /// Keep `k`, the redraw of `doc` (in place of an entry of the same state), within `cap` bytes + /// in all. + fn store(&mut self, k: Kept, doc: &Arc, cap: u64) { + self.entries.retain(|e| !e.matches(doc, k.display, k.format, &k.blocks)); self.entries.push_front(k); self.trim(cap); } - /// Drop the oldest entries until at most [`KEEP`], holding at most `cap` bytes, are left. + /// Drop dead entries (their state is gone), then the oldest until at most [`KEEP`], holding at + /// most `cap` bytes, are left. fn trim(&mut self, cap: u64) { + self.entries.retain(|k| k.doc.strong_count() > 0); while self.entries.len() > KEEP || self.bytes() > cap { self.entries.pop_back(); } @@ -138,7 +147,7 @@ impl KeptComposites { } fn retain(&mut self, live: impl Fn(DocId) -> bool) { - self.entries.retain(|k| live(k.doc.id)); + self.entries.retain(|k| live(k.id)); } } @@ -275,8 +284,16 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { } if r.collected.len() == r.blocks.len() && r.collected.iter().all(Option::is_some) { let texels = std::mem::take(&mut r.collected).into_iter().flatten().collect(); - let k = Kept { doc: r.doc.clone(), display: r.display, format: r.format, blocks: r.blocks.clone(), texels, bytes: r.collected_bytes }; - app.kept.store(k, r.cap); + let k = Kept { + doc: Arc::downgrade(&r.doc), + id: r.doc.id, + display: r.display, + format: r.format, + blocks: r.blocks.clone(), + texels, + bytes: r.collected_bytes, + }; + app.kept.store(k, &r.doc, r.cap); } let ms = crate::gpu_canvas::now_ms() - r.started_ms; app.perf.record("background", r.drawn, ms, 0.0); diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index 1676491a67..72dcf94adc 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -35,7 +35,15 @@ fn visible_blocks_come_first_nearest_the_view_centre() { fn kept(doc: &Arc, bytes: usize) -> Kept { let all = blocks(doc.bounds()); let texels: Vec> = all.iter().map(|_| vec![1u8; bytes]).collect(); - Kept { doc: doc.clone(), display: 7, format: eframe::wgpu::TextureFormat::Rgba8Unorm, bytes: (bytes * all.len()) as u64, blocks: all, texels } + Kept { + doc: Arc::downgrade(doc), + id: doc.id, + display: 7, + format: eframe::wgpu::TextureFormat::Rgba8Unorm, + bytes: (bytes * all.len()) as u64, + blocks: all, + texels, + } } #[test] @@ -55,7 +63,7 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { Arc::new(d) }; let mut cache = KeptComposites::default(); - cache.store(kept(&a, 4), u64::MAX); + cache.store(kept(&a, 4), &a, u64::MAX); // The same state hits, also as an equal document that isn't the same Arc (a restore after a solo). assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); assert!(cache.find(&Arc::new((*a).clone()), 7, Rgba8Unorm, &all).is_some()); @@ -75,14 +83,14 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { assert!(cache.find(&a, 7, Rgba16Float, &all).is_none(), "another texture format"); assert!(cache.find(&a, 7, Rgba8Unorm, &all[1..]).is_none(), "other blocks"); // At most KEEP entries, the least recently used going first. - cache.store(kept(&hidden, 4), u64::MAX); + cache.store(kept(&hidden, 4), &hidden, u64::MAX); assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); - cache.store(kept(&faded, 4), u64::MAX); + cache.store(kept(&faded, 4), &faded, u64::MAX); assert_eq!(cache.entries.len(), KEEP); assert!(cache.find(&hidden, 7, Rgba8Unorm, &all).is_none(), "the least recently used went"); assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, Rgba8Unorm, &all).is_some()); // Storing a state again replaces its entry. - cache.store(kept(&faded, 4), u64::MAX); + cache.store(kept(&faded, 4), &faded, u64::MAX); assert_eq!(cache.entries.len(), 2); // The byte cap: a smaller one drops the oldest, and a composite over it isn't kept. let one = kept(&a, 4).bytes; @@ -90,12 +98,20 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { assert_eq!(cache.entries.len(), 1); assert!(cache.bytes() <= one); cache.trim(0); - cache.store(kept(&a, 4), one - 1); + cache.store(kept(&a, 4), &a, one - 1); assert!(cache.entries.is_empty(), "over the cap"); // A closed document's composites go. - cache.store(kept(&a, 4), u64::MAX); + cache.store(kept(&a, 4), &a, u64::MAX); cache.retain(|id| id != a.id); assert!(cache.entries.is_empty()); + // A state History no longer holds isn't kept alive: its entry is dead and goes. + let gone = changed(&|d| d.layers[0].opacity = 0.25); + cache.store(kept(&gone, 4), &gone, u64::MAX); + let equal = Arc::new((*gone).clone()); + drop(gone); + assert!(cache.find(&equal, 7, Rgba8Unorm, &all).is_none(), "no strong reference to the drawn state"); + cache.trim(u64::MAX); + assert!(cache.entries.is_empty()); } struct Gpu { From 4615a8bc26c78805debbdacb6e8b92c5c3a3585c Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:13:47 -0700 Subject: [PATCH 0675/1452] Survive a lost window GPU device: draw nothing, save unsaved work for recovery, close (#3438, #1215) When the window's own wgpu device was lost (a driver reset, e.g. after sleep), egui-wgpu could not draw another frame: a frame with shapes panicked inside it ("Failed to create staging buffer for index data", #1215, exit 101, unsaved work lost), and a frozen window's close button opened an unsaved-changes prompt nobody could see (#3438). The #243 fallback only covered the canvas. - DeviceHealth::device_lost (wgpu reported the loss, not a recorded fault) and ::probe (a non-blocking poll that keeps running after a recorded fault). - RecoveryStore::flush: finish queued autosaves and wait for them. - gpu_status::check_window, at the end of every frame: once the window's device is lost the frame draws nothing; the first time every unsaved document is written to crash recovery (Services::flush_autosaves) and the app closes without asking. The next launch reopens them. - The app watches the window device on the CPU canvas too; ui.gpu.simulateLoss {destroy: true} destroys the real device to drive the path. Verified on an RX 6900 XT, Vulkan and DX12: destroying the device in the running app exited 101 with the egui-wgpu panic before, and now exits 0 with the document recovered on the next launch. --- apps/photocraft/src/main.rs | 5 +- apps/photocraft/src/services.rs | 3 +- crates/format/src/autosave.rs | 11 ++++ crates/format/tests/autosave.rs | 21 +++++++ crates/gpu/src/health.rs | 35 +++++++++++- crates/gpu/tests/device_loss.rs | 22 ++++++++ crates/ui-egui/src/control.rs | 13 ++++- crates/ui-egui/src/gpu_status.rs | 46 +++++++++++++++ crates/ui-egui/src/lib.rs | 33 +++++++++++ crates/ui-egui/tests/gpu_device_loss.rs | 74 +++++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/development.md | 2 + 12 files changed, 258 insertions(+), 9 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..4fdd549607 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -785,8 +785,9 @@ fn main() -> eframe::Result { { app.set_wgpu(rs); } else { - // The window still draws with wgpu: record its errors instead of panicking. - let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device); + // The window still draws with wgpu: record its errors instead of panicking, + // and notice when the device is lost (#3438). + app.watch_window_device(&rs.device); } } let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 20de0d6be8..c814bc75ca 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -383,12 +383,13 @@ fn with_store(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R fn recovery_services(dir: Option) -> Services { let recover_dir = dir.clone(); let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new))); - let (s1, s2, s4) = (store.clone(), store.clone(), store.clone()); + let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone()); Services { autosave: Some(Box::new(move |doc: &Arc, revision: u64, path: Option<&str>| { with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))? })), autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())), + flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())), discard_autosave: Some(Box::new(move |id: u64| { let _ = with_store(&s2, |s| s.discard(id)); })), diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 06150e60ed..a2fbddb734 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -297,6 +297,17 @@ impl RecoveryStore { self.savers.iter().flat_map(|(&id, saver)| saver.take_completed().into_iter().map(move |(revision, result)| (id, revision, result))).collect() } + /// Finish every queued autosave and wait for it, then return the outcomes as + /// [`take_completed`](Self::take_completed) does. For a session that ends without asking, + /// e.g. because the window's graphics device was lost (#3438). The workers stop, so later + /// autosaves of the same documents report a stopped worker. + pub fn flush(&mut self) -> Vec<(u64, u64, std::result::Result<(), String>)> { + for saver in self.savers.values_mut() { + saver.shutdown(); + } + self.take_completed() + } + /// Document `doc_id` was saved or closed: remove its recovery data, both its own autosaves /// and the entry it adopted. pub fn discard(&mut self, doc_id: u64) -> Result<()> { diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/autosave.rs index 29905d7ac5..fa078dab6c 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/autosave.rs @@ -188,6 +188,27 @@ fn recovered_entries_survive_a_second_crash_until_saved_or_closed() { std::fs::remove_dir_all(dir).unwrap(); } +/// #3438: a session about to end without asking (its window's graphics device was lost) writes +/// everything queued before it returns, so the next launch recovers it. +#[test] +fn flush_writes_every_queued_autosave_before_returning() { + let dir = temp_dir("flush"); + let (a, b) = (version(1, 3, ColorMode::Rgb), version(2, 5, ColorMode::Grayscale)); + let mut store = RecoveryStore::new(&dir); + store.autosave_checked(&Arc::new(a.clone()), 4, None).unwrap(); + store.autosave_checked(&Arc::new(b.clone()), 6, Some("/work/b.pcraft".into())).unwrap(); + let mut done = store.flush(); + done.sort_by_key(|(id, ..)| *id); + assert_eq!(done, vec![(1, 4, Ok(())), (2, 6, Ok(()))]); + // On disk while the store is still alive, not only once it is dropped. + let docs = recovered(&RecoveryStore::new(&dir)); + assert_eq!(docs.len(), 2); + assert!(docs.values().any(|d| *d == same(a.clone())) && docs.values().any(|d| *d == same(b.clone()))); + assert!(store.autosave_checked(&Arc::new(a), 5, None).is_err(), "the workers stopped"); + drop(store); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn new_documents_never_overwrite_surviving_entries() { let dir = temp_dir("collision"); diff --git a/crates/gpu/src/health.rs b/crates/gpu/src/health.rs index cb1a116c4b..61aacf8b58 100644 --- a/crates/gpu/src/health.rs +++ b/crates/gpu/src/health.rs @@ -53,6 +53,8 @@ struct Inner { faulted: AtomicBool, fault: Mutex>, errors: AtomicU64, + /// wgpu itself reported the device lost (see [`DeviceHealth::device_lost`]). + device_lost: AtomicBool, } /// Shared health flag of one wgpu device. Cheap to clone; every clone sees the same state. @@ -84,6 +86,7 @@ impl DeviceHealth { (_, false) => message, }; log::error!("wgpu device lost ({reason:?}): {detail}"); + lost.0.device_lost.store(true, Ordering::Release); lost.mark(Fault::Lost(detail)); }); let errors = health.clone(); @@ -134,6 +137,32 @@ impl DeviceHealth { self.0.errors.load(Ordering::Relaxed) } + /// Whether wgpu itself reported the device lost: its device-lost callback ran, or polling it + /// panicked. Unlike a fault recorded with [`mark`](Self::mark) (a simulated loss, or an + /// uncaptured error that names one), nothing drawn with the device reaches the screen any + /// more, the window's own frames included (#3438). + pub fn device_lost(&self) -> bool { + self.0.device_lost.load(Ordering::Acquire) + } + + /// Non-blocking poll that runs even after a fault was recorded, so a device that is lost + /// later (a driver reset while the app sat idle or the PC slept) is noticed before the next + /// frame is drawn with it. Skipped once [`device_lost`](Self::device_lost). + pub fn probe(&self, device: &wgpu::Device) { + if self.device_lost() { + return; + } + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() { + self.lost_by_poll(); + } + } + + /// `Device::poll` panicked: wgpu found the device lost. + fn lost_by_poll(&self) { + self.0.device_lost.store(true, Ordering::Release); + self.mark(Fault::Lost("the device stopped responding".into())); + } + /// Block until `index` (or all submitted work) finished, unless the device is unusable. /// Returns whether the device is still healthy afterwards. `Device::poll` panics on a lost /// device, so a panic is caught and recorded as a loss (native only; the web has no blocking @@ -147,7 +176,7 @@ impl DeviceHealth { match poll { Ok(Ok(_)) => {} Ok(Err(e)) => log::warn!("wgpu poll: {e}"), - Err(_) => self.mark(Fault::Lost("the device stopped responding".into())), + Err(_) => self.lost_by_poll(), } self.is_ok() } @@ -158,7 +187,7 @@ impl DeviceHealth { return; } if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() { - self.mark(Fault::Lost("the device stopped responding".into())); + self.lost_by_poll(); } } } @@ -190,6 +219,8 @@ mod tests { assert_eq!(h.fault(), Some(Fault::Lost("reset".into()))); c.mark(Fault::Lost("later".into())); assert_eq!(h.fault().map(|f| f.to_string()).as_deref(), Some("GPU device lost: reset")); + // A recorded loss is not wgpu reporting the device lost (#3438). + assert!(!h.device_lost()); } #[test] diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/device_loss.rs index 689a787280..294961ee8d 100644 --- a/crates/gpu/tests/device_loss.rs +++ b/crates/gpu/tests/device_loss.rs @@ -82,6 +82,28 @@ fn health_wait_on_a_destroyed_device_is_safe() { assert!(!health.wait(&device, None)); } +/// #3438: only wgpu reporting the device lost counts as a real loss. A fault recorded by hand (a +/// simulated loss, or an uncaptured error that names one) leaves the device usable, and +/// [`DeviceHealth::probe`] keeps polling it so a later real loss is still noticed. +#[test] +fn only_wgpu_reports_a_real_device_loss() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((_adapter, device, _queue)) = device() else { return }; + let health = DeviceHealth::watch(&device); + health.probe(&device); + assert!(health.is_ok() && !health.device_lost()); + health.mark(Fault::Lost("simulated".into())); + health.probe(&device); + assert!(!health.is_ok()); + assert!(!health.device_lost(), "a recorded fault is not a real loss"); + device.destroy(); + health.probe(&device); + assert!(health.device_lost(), "a destroyed device is lost: {:?}", health.fault()); + // Once lost, probing is a no-op rather than another caught panic. + health.probe(&device); + assert!(health.device_lost()); +} + fn assert_unwatched_destroyed_device(encode_only: bool) { let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = device() else { return }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..2a1a7c3bfb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -20,9 +20,11 @@ //! - The synthetic input methods reject unknown fields, wrong types, unknown enum values and //! out-of-range numbers before acting, naming the parameter (#2449) //! - `ui.resize {width, height}`: resize the main window -//! - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or, with `error: true`, -//! reported an error): the app switches to the CPU renderer for the rest of the session, as on a -//! real loss. For testing the fallback; returns whether a GPU canvas was active +//! - `ui.gpu.simulateLoss {error?, destroy?}`: act as if the wgpu device was lost (or, with +//! `error: true`, reported an error): the app switches to the CPU renderer for the rest of the +//! session, as on a real loss. For testing the fallback; returns whether a GPU canvas was active. +//! `destroy: true` destroys the window's real device instead, as a driver reset does: the app +//! saves its unsaved documents for recovery and closes (#3438); returns `destroyed` //! - `ui.screenshot {path?, focus?}`: capture the main window (PNG). Raises the window first (default) //! because occluded macOS windows stop rendering //! - `ui.focus`: bring the main window to the front @@ -1190,6 +1192,11 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "ui.gpu.simulateLoss" => { + if p.get("destroy").and_then(Value::as_bool).unwrap_or(false) { + let destroyed = app.destroy_window_device(); + ctx.request_repaint(); + return ok(json!({"destroyed": destroyed})); + } let error = p.get("error").and_then(Value::as_bool).unwrap_or(false); let fault = if error { photocraft_gpu::Fault::Error("simulated error (ui.gpu.simulateLoss)".into()) diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 337283bdf8..008e52a6f1 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -38,6 +38,52 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// End-of-frame check of the device the window itself draws with (#3438). Once wgpu reports it +/// lost (a driver reset, for example after the PC slept), egui-wgpu can't draw another frame: one +/// with shapes panics creating its staging buffers (#1215), and one without leaves a frozen window +/// whose close button opens an unsaved-changes prompt nobody can see. So this frame and every later +/// one draw nothing; the first writes every unsaved document to crash recovery, waits for the +/// writes, and closes the app without asking. The next launch reopens the documents. +pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some((device, health)) = app.window_device.as_ref() else { return }; + // A loss while the app sat idle or the PC slept is noticed here, before the next frame. + health.probe(device); + if !health.device_lost() { + return; + } + ctx.graphics_mut(|g| *g = Default::default()); + if app.window_lost { + return; + } + app.window_lost = true; + let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string()); + log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing"); + save_for_recovery(app); + app.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); +} + +/// Queue a crash-recovery autosave of every document with unsaved changes, whatever the autosave +/// timer and preference, and wait until the writes finished (see `Services::flush_autosaves`). +fn save_for_recovery(app: &mut PhotocraftApp) { + let jobs: Vec<_> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| (d.doc.clone(), d.revision, d.path.clone())).collect(); + if let Some(save) = app.services.autosave.as_mut() { + for (doc, revision, path) in &jobs { + if let Err(e) = save(doc, *revision, path.as_deref()) { + log::error!("couldn't save {} for recovery: {e}", doc.name); + } + } + } + if let Some(flush) = app.services.flush_autosaves.as_mut() { + for (id, revision, outcome) in flush() { + match outcome { + Ok(()) => log::info!("document {id} (revision {revision}) saved for recovery"), + Err(e) => log::error!("couldn't save document {id} for recovery: {e}"), + } + } + } +} + /// Drop the GPU canvas after `fault`: free its resources, send every view through the CPU /// path, record why, and tell the user. Documents are untouched. pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..7736de1171 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -523,6 +523,10 @@ pub struct Services { pub autosave: Option, /// None for services that report synchronous success through `autosave`. pub autosave_results: Option, + /// Finish every queued crash-recovery autosave and wait for it, with outcomes like + /// `autosave_results`. For a session that ends without asking: the window's graphics device + /// was lost (see `gpu_status::check_window`). + pub flush_autosaves: Option, pub discard_autosave: Option, pub recover: Option, pub adopt_autosave: Option, @@ -813,6 +817,11 @@ pub struct PhotocraftApp { /// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]). Dropped /// for the rest of the session when the device is lost (see `gpu_status`). gpu: Option, + /// The device the window itself draws with, and its health flag: the GPU canvas's, or one + /// watched for the CPU canvas (see `gpu_status::check_window`). + window_device: Option<(eframe::wgpu::Device, photocraft_gpu::DeviceHealth)>, + /// That device was lost: frames draw nothing and the app is closing (#3438). + window_lost: bool, /// Run once the first frames have rendered (see [`Self::on_started`]). started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). @@ -976,6 +985,8 @@ impl PhotocraftApp { doc_hist: None, hist_job: None, gpu: None, + window_device: None, + window_lost: false, started: None, perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), @@ -1024,10 +1035,12 @@ impl PhotocraftApp { self.perf.gpu_info.canvas = "cpu".into(); self.perf.gpu_info.fallback = Some(detail.clone()); gpu_status::queue_fallback_notice(self, detail); + self.watch_window_device(&rs.device); return; } }; self.perf.gpu_info.canvas = "gpu".into(); + self.window_device = Some((rs.device.clone(), gpu.health().clone())); self.gpu = Some(gpu); self.prefs_rt.gpu_style = None; } @@ -1043,6 +1056,24 @@ impl PhotocraftApp { self.gpu.as_ref().map(|g| g.health().clone()) } + /// Watch the device the window draws with when the GPU canvas doesn't (the CPU canvas), so + /// its loss is handled like the GPU canvas's (see `gpu_status::check_window`). + pub fn watch_window_device(&mut self, device: &eframe::wgpu::Device) { + self.window_device = Some((device.clone(), photocraft_gpu::DeviceHealth::watch(device))); + } + + /// Whether the window's graphics device was lost, so the app saved its documents for + /// recovery and is closing (#3438). + pub fn window_lost(&self) -> bool { + self.window_lost + } + + /// Destroy the window's graphics device, as a driver reset does (`ui.gpu.simulateLoss` + /// with `destroy`, tests). Returns whether there was one. + pub fn destroy_window_device(&mut self) -> bool { + self.window_device.as_ref().map(|(device, _)| device.destroy()).is_some() + } + /// Run `hook` once the app has rendered its first frames (and a document opened at launch /// has drawn): the desktop app clears its crash-safe GPU startup marker there. pub fn on_started(&mut self, hook: impl FnOnce(&mut PhotocraftApp) + 'static) { @@ -2154,6 +2185,8 @@ impl eframe::App for PhotocraftApp { jobs_ui::keep_polling(self, &ctx); // Menus and buttons in this frame may have asked for a file dialog. self.poll_file_dialog(&ctx, Some(frame)); + // Last, after everything drawn this frame: a lost window device can't draw it (#3438). + gpu_status::check_window(self, &ctx); } } diff --git a/crates/ui-egui/tests/gpu_device_loss.rs b/crates/ui-egui/tests/gpu_device_loss.rs index ed9626ee2e..3a84b96cbb 100644 --- a/crates/ui-egui/tests/gpu_device_loss.rs +++ b/crates/ui-egui/tests/gpu_device_loss.rs @@ -219,3 +219,77 @@ fn canvas_over_the_texture_budget_builds_no_textures() { assert!(g.texture_info(key).is_none()); g.release(); } + +/// #3438, #1215: when the window's own device is really lost (a driver reset, for example after +/// the PC slept), egui-wgpu can't draw another frame. One with shapes panicked inside it ("Failed +/// to create staging buffer for index data"), and a frozen window's close button opened an +/// unsaved-changes prompt nobody could see. Now the app draws nothing, writes the unsaved document +/// for recovery and closes. Runs the real frame loop on the harness's own device. +#[test] +fn a_lost_window_device_saves_unsaved_work_for_recovery_and_closes() { + let _gpu = gpu_lock(); + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (autosaves, flushes) = (log.clone(), log.clone()); + let render_state = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let window = render_state.clone(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_max_steps(16).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = photocraft_ui_egui::Services { + autosave: Some(Box::new(move |doc: &std::sync::Arc, revision: u64, _: Option<&str>| { + autosaves.lock().unwrap().push(format!("autosave {} {revision}", doc.id.0)); + Ok(()) + })), + flush_autosaves: Some(Box::new(move || { + flushes.lock().unwrap().push("flush".into()); + Vec::new() + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + *window.lock().unwrap() = Some(rs.clone()); + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }) + })); + // Upload and submit the last frame's shapes the way the window's renderer does each frame: on + // a lost device, egui-wgpu panics here when there is anything to draw (#1215). + let upload = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let rs = render_state.lock().unwrap().clone().expect("the harness renders with wgpu"); + let output = h.output(); + let jobs = h.ctx.tessellate(output.shapes.clone(), output.pixels_per_point); + let screen = eframe::egui_wgpu::ScreenDescriptor { size_in_pixels: [1000, 700], pixels_per_point: output.pixels_per_point }; + let mut encoder = rs.device.create_command_encoder(&Default::default()); + let uploaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let commands = rs.renderer.write().update_buffers(&rs.device, &rs.queue, &mut encoder, &jobs, &screen); + rs.queue.submit(commands.into_iter().chain([encoder.finish()])); + })); + (jobs.len(), uploaded.is_ok()) + }; + let Ok(mut h) = built else { + eprintln!("skipping: no GPU adapter"); + return; + }; + h.run_steps(4); + let (jobs, uploaded) = upload(&h); + assert!(jobs > 0 && uploaded, "draws before the loss"); + let (doc, revision, dirty) = h.state().session.active().map(|d| (d.doc.id.0, d.revision, d.is_dirty())).unwrap(); + assert!(dirty, "the new layer is unsaved"); + assert!(log.lock().unwrap().is_empty(), "no autosave before the loss"); + assert!(h.state_mut().destroy_window_device()); + // wgpu reports a destroyed device lost once the work submitted before finished (slow on a + // software adapter); a driver reset reports it at once. + if let Some(rs) = render_state.lock().unwrap().as_ref() { + let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(30)) }); + } + h.run_steps(2); + assert!(h.state().window_lost(), "the loss was noticed"); + assert_eq!(*log.lock().unwrap(), vec![format!("autosave {doc} {revision}"), "flush".to_string()], "saved for recovery once"); + // The frame on the lost device has nothing to draw, so egui-wgpu has nothing to upload. + assert_eq!(upload(&h), (0, true), "a frame with shapes panics inside egui-wgpu on a lost device"); + assert_eq!(h.state().session.documents().len(), 1, "the document stays until the app closes"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..a1c3193964 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,7 +82,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window -- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback +- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback. With `destroy: true` it destroys the window's real wgpu device instead, as a driver reset does (see `docs/development.md` › device loss): the app saves every unsaved document for recovery and closes. Returns `destroyed` - `ui.screenshot {path?, focus?}`: capture the main window (PNG). With no path the reply contains base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..c0fcdc9475 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,6 +97,8 @@ keep the marker for recovery. If the device is lost while running (#243), every GPU entry point checks the device's health flag first, the canvas switches to the CPU compositor for the rest of the session and a notice says "GPU device was lost; using the CPU renderer." `ui.gpu.simulateLoss` triggers this path from the control channel. +The window itself draws with the same device (or, with the CPU canvas, one it watches), and nothing can draw it once wgpu reports that device lost, for example after a driver reset while the PC slept (#3438). A frame with shapes then panicked inside egui-wgpu ("Failed to create staging buffer", #1215). A frame without any froze the window, and the close button opened an unsaved-changes prompt nobody could see. `gpu_status::check_window` polls the device each frame. Once wgpu reports it lost, frames draw nothing, every unsaved document is written to crash recovery (`Services::flush_autosaves` waits for the writes), and the app closes; the next launch reopens them. `ui.gpu.simulateLoss {destroy: true}` destroys the real device to test this path. + ## Logs The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. From 73123e621ab7d9717409ee5be23df53df4b47c7f Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:06:36 -0700 Subject: [PATCH 0676/1452] GPU fallback: Retry GPU after an error rebuilds the GPU canvas now, no restart (#6577) After an error on a device that still works (out of memory, validation), Retry GPU builds a new GPU canvas on the same render state with a fresh health flag, and the views go back to the GPU. After a loss or a failed GPU start it still schedules the retry for the next launch. The dialog says which. Two new strings, translated in all 20 catalogs. --- crates/ui-egui/src/gpu_status.rs | 84 +++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 8 +++ docs/control-protocol.md | 2 +- 23 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 337283bdf8..48c016c3a5 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -1,6 +1,7 @@ //! GPU health in the shell (#243, #4): when the wgpu device is lost or reports an error, the //! GPU canvas is dropped and every document keeps drawing through the CPU compositor and egui -//! textures for the rest of the session, with a recovery warning. Also runs the desktop app's +//! textures, with a recovery warning. After an error Retry GPU builds a new GPU canvas on the same +//! device (#6577); after a loss the CPU canvas stays for the rest of the session. Also runs the desktop app's //! "started" hook once the first frames have rendered (the crash-safe startup marker), and //! provides the Help › System Info text (including the monitor profile in use). @@ -42,8 +43,9 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { /// path, record why, and tell the user. Documents are untouched. pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { let Some(gpu) = app.gpu.take() else { return }; - log::error!("{fault}; using the CPU renderer for the rest of the session"); + log::error!("{fault}; using the CPU renderer"); gpu.release(); + app.gpu_fault = Some(fault.clone()); // Canvas caches pointed at GPU textures: rebuild them as egui textures. app.canvases.clear(); app.proxy_uploaded = None; @@ -94,7 +96,35 @@ pub fn queue_fallback_notice(app: &mut PhotocraftApp, reason: impl Into) app.ui.gpu_fallback_notice = Some(reason.into()); } -/// Store the next-launch choice without restarting or risking unsaved documents. +/// Whether Retry GPU can build a new GPU canvas now: the canvas was dropped after an error (out +/// of memory, validation) on a device that still works. After a loss, or a GPU start that +/// failed, only a restart can try the GPU again. +fn can_retry_now(app: &PhotocraftApp) -> bool { + app.gpu_render_state.is_some() && app.gpu_fault.as_ref().is_some_and(|f| !f.is_lost()) +} + +/// Build a new GPU canvas on the device the dropped one used, with a fresh health flag, and send +/// the views back through it (#6577). Returns whether the canvas is active again. +fn retry_now(app: &mut PhotocraftApp) -> bool { + let Some(rs) = app.gpu_render_state.clone() else { return false }; + app.set_wgpu(rs); + if !app.gpu_active() { + return false; + } + log::info!("GPU canvas rebuilt after {}", app.gpu_fault.as_ref().map_or_else(String::new, ToString::to_string)); + app.gpu_fault = None; + // The CPU canvas's egui textures: rebuild every view on the GPU. + app.canvases.clear(); + app.proxy_uploaded = None; + app.perf.gpu_fallback = None; + app.perf.gpu_info.lost = None; + app.ui.status = tl!("GPU acceleration resumed.").to_string(); + app.ui.status_error = false; + true +} + +/// Store the next-launch choice without restarting or risking unsaved documents. Retry GPU after +/// an error on a working device also builds a new GPU canvas at once (see [`retry_now`]). fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { app.run( "prefs.set", @@ -106,6 +136,9 @@ fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { )?; crate::prefs_ui::save_preferences(app)?; app.ui.gpu_fallback_notice = None; + if retry && can_retry_now(app) && retry_now(app) { + return Ok(()); + } if retry { crate::notices::post( app, @@ -121,6 +154,7 @@ fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { /// Recovery is an explicit choice; the CPU document renderer stays active while the user works. pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(reason) = app.ui.gpu_fallback_notice.clone() else { return }; + let retry_now = can_retry_now(app); let t = crate::theme::Tokens::get(ctx); let mut choice = None; egui::Window::new(tl!("Switched to CPU rendering")) @@ -135,7 +169,11 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.collapsing(tl!("Details"), |ui| { ui.label(&reason); }); - ui.label(tl!("Retry GPU requires a restart. Save your work first.")); + if retry_now { + ui.label(tl!("Retry GPU tries the graphics processor again now.")); + } else { + ui.label(tl!("Retry GPU requires a restart. Save your work first.")); + } ui.add_space(8.0); ui.horizontal(|ui| { use crate::widgets::{ButtonRole, DialogButton}; @@ -237,4 +275,42 @@ mod tests { crate::prefs_ui::tick(&mut app, &egui::Context::default()); assert_eq!(saves.get(), 2, "retry choice writes once"); } + + /// #6577: after a GPU error on a device that still works (out of memory, validation), Retry + /// GPU builds a new GPU canvas at once, without a restart or closing documents. After a loss + /// the device can't be used again, so the retry still waits for the next launch. + #[test] + fn retry_gpu_after_an_error_rebuilds_the_canvas_without_a_restart() { + let _gpu = photocraft_testkit::gpu_lock(); + let rs = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())); + let Ok(rs) = rs else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let services = crate::Services { save_prefs: Some(Box::new(|_| Ok(()))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.set_wgpu(rs); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ctx = egui::Context::default(); + // An error on a live device: Retry GPU rebuilds the canvas now. + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Out of Memory".into())); + check(&mut app, &ctx); + assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some()); + assert!(can_retry_now(&app)); + choose_recovery(&mut app, true).unwrap(); + assert!(app.gpu_active(), "Retry GPU rebuilt the GPU canvas"); + assert!(app.gpu_health().unwrap().is_ok(), "with a fresh health flag"); + assert_eq!(app.perf.gpu_info.canvas, "gpu"); + assert_eq!((app.perf.gpu_info.lost.as_deref(), app.perf.gpu_fallback.as_deref()), (None, None)); + assert_eq!(app.ui.status, "GPU acceleration resumed."); + assert!(app.ui.gpu_fallback_notice.is_none() && app.ui.notices.is_empty(), "no restart notice"); + assert_eq!(app.session.documents().len(), 1, "the document stayed open"); + // A loss: the device can't be reused, so the retry is scheduled for the next launch. + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Lost("simulated".into())); + check(&mut app, &ctx); + assert!(!can_retry_now(&app)); + choose_recovery(&mut app, true).unwrap(); + assert!(!app.gpu_active()); + assert_eq!(app.ui.notices.len(), 1, "the restart notice"); + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..14d656b82a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2247,6 +2247,8 @@ GPU acceleration could not continue. Your documents are unchanged. Akcelerace GPU nemohla pokračovat. Dokumenty zůstaly nezměněny. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft skládá obraz pomocí CPU. Okno může nadále používat grafický adaptér. Retry GPU requires a restart. Save your work first. Opakování GPU vyžaduje restart. Nejprve uložte práci. + Retry GPU tries the graphics processor again now. Opakování GPU zkusí grafický procesor znovu hned teď. + GPU acceleration resumed. Akcelerace GPU obnovena. Keep Using CPU Pokračovat s CPU Retry GPU Zkusit GPU znovu Advanced Pokročilé diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..281cc9748f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2296,6 +2296,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU-Beschleunigung konnte nicht fortgesetzt werden. Deine Dokumente sind unverändert. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft nutzt den CPU-Bildkompositor. Das Fenster kann weiterhin deinen Grafikadapter verwenden. Retry GPU requires a restart. Save your work first. GPU erneut versuchen erfordert einen Neustart. Speichere zuerst deine Arbeit. + Retry GPU tries the graphics processor again now. GPU erneut versuchen versucht es sofort wieder mit dem Grafikprozessor. + GPU acceleration resumed. GPU-Beschleunigung wieder aktiv. Keep Using CPU CPU weiter verwenden Retry GPU GPU erneut versuchen Advanced Erweitert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..0c381b9cc9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1106,6 +1106,8 @@ GPU acceleration could not continue. Your documents are unchanged. Η επιτάχυνση GPU δεν μπόρεσε να συνεχιστεί. Τα έγγραφά σας δεν έχουν αλλάξει. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. Το PhotoCraft χρησιμοποιεί σύνθεση εικόνας μέσω CPU. Το παράθυρο μπορεί να εξακολουθεί να χρησιμοποιεί την κάρτα γραφικών. Retry GPU requires a restart. Save your work first. Η νέα προσπάθεια χρήσης GPU απαιτεί επανεκκίνηση. Αποθηκεύστε πρώτα την εργασία σας. + Retry GPU tries the graphics processor again now. Η νέα προσπάθεια χρήσης GPU δοκιμάζει ξανά τον επεξεργαστή γραφικών αμέσως. + GPU acceleration resumed. Η επιτάχυνση GPU συνεχίζεται. Keep Using CPU Συνέχεια με CPU Retry GPU Νέα προσπάθεια χρήσης GPU GPU Backend Σύστημα GPU diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..1e69dd5391 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2320,6 +2320,8 @@ GPU acceleration could not continue. Your documents are unchanged. La aceleración GPU no pudo continuar. Los documentos no han cambiado. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft compone las imágenes en la CPU. La ventana aún puede usar el adaptador gráfico. Retry GPU requires a restart. Save your work first. Reintentar la GPU requiere reiniciar. Guarde primero su trabajo. + Retry GPU tries the graphics processor again now. Reintentar la GPU vuelve a probar el procesador gráfico ahora. + GPU acceleration resumed. Se reanudó la aceleración GPU. Keep Using CPU Seguir usando CPU Retry GPU Reintentar GPU Advanced Avanzado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..c9c2d4782e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2672,6 +2672,8 @@ GPU acceleration could not continue. Your documents are unchanged. L'accélération GPU n'a pas pu continuer. Vos documents sont intacts. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft utilise le compositeur d'images CPU. La fenêtre peut encore utiliser votre carte graphique. Retry GPU requires a restart. Save your work first. Réessayer le GPU nécessite un redémarrage. Enregistrez votre travail au préalable. + Retry GPU tries the graphics processor again now. Réessayer le GPU sollicite de nouveau le processeur graphique dès maintenant. + GPU acceleration resumed. Accélération GPU rétablie. Keep Using CPU Continuer avec le CPU Retry GPU Réessayer le GPU GPU Backend Moteur GPU diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..933c2fdf15 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3499,6 +3499,8 @@ cameraRaw Yellow Sárga Restore Előző méret Retry GPU GPU újrapróbálása Retry GPU requires a restart. Save your work first. A GPU újrapróbálásához újraindítás kell. Előbb mentsd a munkádat. + Retry GPU tries the graphics processor again now. A GPU újrapróbálása most újra megpróbálja a grafikus processzort. + GPU acceleration resumed. A GPU-gyorsítás újra működik. Rgb RGB Ridges Hullámgerincek Right Jobbra diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..a1a48bfb14 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2685,6 +2685,8 @@ cameraRaw Blending Pembauran GPU acceleration could not continue. Your documents are unchanged. Akselerasi GPU tidak dapat dilanjutkan. Dokumen Anda tidak berubah. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft menggunakan kompositor gambar CPU. Jendela mungkin masih menggunakan adaptor grafis Anda. Retry GPU requires a restart. Save your work first. Mencoba GPU lagi memerlukan mulai ulang. Simpan pekerjaan Anda terlebih dahulu. + Retry GPU tries the graphics processor again now. Coba GPU Lagi mencoba prosesor grafis lagi sekarang. + GPU acceleration resumed. Akselerasi GPU dilanjutkan. Keep Using CPU Tetap Gunakan CPU Retry GPU Coba GPU Lagi GPU Backend Backend GPU diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..12443e1909 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2319,6 +2319,8 @@ GPU acceleration could not continue. Your documents are unchanged. Accelerazione GPU non ha potuto continuare. I documenti sono invariati. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft sta componendo immagini su CPU. La finestra può ancora usare adattatore grafico. Retry GPU requires a restart. Save your work first. Riprovare GPU richiede riavvio. Salva prima il lavoro. + Retry GPU tries the graphics processor again now. Riprova GPU riprova subito il processore grafico. + GPU acceleration resumed. Accelerazione GPU ripresa. Keep Using CPU Continua su CPU Retry GPU Riprova GPU Advanced Avanzato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..3a42dde8bb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2228,6 +2228,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU アクセラレーションを継続できませんでした。ドキュメントは変更されていません。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft は CPU で画像を合成しています。ウィンドウは引き続きグラフィックスアダプターを使用する場合があります。 Retry GPU requires a restart. Save your work first. GPU の再試行には再起動が必要です。先に作業を保存してください。 + Retry GPU tries the graphics processor again now. GPU を再試行すると、すぐにグラフィックプロセッサーをもう一度試します。 + GPU acceleration resumed. GPU アクセラレーションを再開しました。 Keep Using CPU CPU を使い続ける Retry GPU GPU を再試行 Advanced 詳細設定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a170c96632..f523357abc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2205,6 +2205,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU 가속을 계속할 수 없습니다. 문서는 변경되지 않았습니다. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft가 CPU로 이미지를 합성하고 있습니다. 창 표시에는 그래픽 어댑터를 계속 사용할 수 있습니다. Retry GPU requires a restart. Save your work first. GPU를 다시 사용하려면 다시 시작해야 합니다. 먼저 작업을 저장하세요. + Retry GPU tries the graphics processor again now. GPU 다시 사용을 누르면 바로 그래픽 프로세서를 다시 시도합니다. + GPU acceleration resumed. GPU 가속을 다시 사용합니다. Keep Using CPU CPU 계속 사용 Retry GPU GPU 다시 사용 Advanced 고급 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..1d6ad97b87 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2274,6 +2274,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU-versnelling kon niet doorgaan. Je documenten zijn ongewijzigd. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft gebruikt de CPU-beeldcompositor. Het venster kan nog steeds je grafische adapter gebruiken. Retry GPU requires a restart. Save your work first. GPU opnieuw proberen vereist een herstart. Sla eerst je werk op. + Retry GPU tries the graphics processor again now. GPU opnieuw proberen probeert de grafische processor nu opnieuw. + GPU acceleration resumed. GPU-versnelling hervat. Keep Using CPU CPU blijven gebruiken Retry GPU GPU opnieuw proberen Advanced Geavanceerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..8f994dcf78 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2673,6 +2673,8 @@ GPU acceleration could not continue. Your documents are unchanged. Akceleracja GPU nie mogła być kontynuowana. Twoje dokumenty nie zostały zmienione. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft używa kompozytora obrazu CPU. Okno może nadal korzystać z karty graficznej. Retry GPU requires a restart. Save your work first. Ponowna próba z GPU wymaga restartu. Najpierw zapisz swoją pracę. + Retry GPU tries the graphics processor again now. Spróbuj ponownie z GPU od razu ponawia próbę z procesorem graficznym. + GPU acceleration resumed. Akceleracja GPU wznowiona. Keep Using CPU Nadal używaj CPU Retry GPU Spróbuj ponownie z GPU GPU Backend Backend GPU diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..9fcf4fbb13 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2664,6 +2664,8 @@ GPU acceleration could not continue. Your documents are unchanged. A aceleração por GPU não pôde continuar. Seus documentos não foram alterados. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. O PhotoCraft está usando o compositor de imagens da CPU. A janela ainda pode usar seu adaptador gráfico. Retry GPU requires a restart. Save your work first. Tentar a GPU novamente exige reiniciar. Salve seu trabalho primeiro. + Retry GPU tries the graphics processor again now. Tentar a GPU novamente tenta o processador gráfico de novo agora. + GPU acceleration resumed. Aceleração por GPU retomada. Keep Using CPU Continuar usando a CPU Retry GPU Tentar a GPU novamente GPU Backend Backend da GPU diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..9f967cbf4a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2511,6 +2511,8 @@ GPU acceleration could not continue. Your documents are unchanged. Ускорение GPU остановлено. Документы не изменены. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft обрабатывает изображения на CPU. Окно может продолжать использовать графический адаптер. Retry GPU requires a restart. Save your work first. Для повтора GPU нужен перезапуск. Сначала сохраните работу. + Retry GPU tries the graphics processor again now. Повторить GPU — сразу снова попробовать графический процессор. + GPU acceleration resumed. Ускорение GPU возобновлено. Keep Using CPU Продолжить с CPU Retry GPU Повторить GPU GPU Backend Бэкенд GPU diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..41e0275661 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2650,6 +2650,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo da se nastavi. Vaši dokumenti su nepromenjeni. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slike. Prozor i dalje može da koristi vaš grafički adapter. Retry GPU requires a restart. Save your work first. Ponovni pokušaj sa GPU-om zahteva ponovno pokretanje. Prvo sačuvajte svoj rad. + Retry GPU tries the graphics processor again now. Ponovo pokušaj sa GPU-om odmah ponovo isprobava grafički procesor. + GPU acceleration resumed. GPU ubrzanje je nastavljeno. Keep Using CPU Nastavi sa CPU-om Retry GPU Ponovo pokušaj sa GPU-om GPU Backend GPU pozadinski sistem diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..9f3a5bf371 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2246,6 +2246,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU hızlandırması devam edemedi. Belgeleriniz değişmedi. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü düzenleyicisini kullanıyor. Pencere hala grafik bağdaştırıcınızı kullanıyor olabilir. Retry GPU requires a restart. Save your work first. GPU'yu yeniden dene, yeniden başlatma gerektirir. Öncelikle çalışmanızı kaydedin. + Retry GPU tries the graphics processor again now. GPU'yu yeniden dene, grafik işlemciyi hemen yeniden dener. + GPU acceleration resumed. GPU hızlandırması sürdürüldü. Keep Using CPU CPU'yu Kullanmaya Devam Edin Retry GPU GPU'yu yeniden dene Advanced Gelişmiş diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..a691794791 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2481,6 +2481,8 @@ GPU acceleration could not continue. Your documents are unchanged. Прискорення GPU не може продовжувати роботу. Ваші документи не змінено. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft використовує компонувальник зображень CPU. Вікно може й далі використовувати ваш графічний адаптер. Retry GPU requires a restart. Save your work first. Повторна спроба використати GPU потребує перезапуску. Спочатку збережіть роботу. + Retry GPU tries the graphics processor again now. Повторити спробу з GPU — одразу знову спробувати графічний процесор. + GPU acceleration resumed. Прискорення GPU відновлено. Keep Using CPU Продовжити з CPU Retry GPU Повторити спробу з GPU GPU Backend Графічний бекенд diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..46843f2c89 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1590,6 +1590,8 @@ cameraRaw Shadows Vùng tối PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft đang dùng bộ kết hợp ảnh CPU. Cửa sổ vẫn có thể dùng bộ điều hợp đồ họa của bạn. Retry GPU Thử lại GPU Retry GPU requires a restart. Save your work first. Thử lại GPU cần khởi động lại. Hãy lưu công việc trước. + Retry GPU tries the graphics processor again now. Thử lại GPU sẽ thử lại bộ xử lý đồ họa ngay bây giờ. + GPU acceleration resumed. Đã tiếp tục tăng tốc GPU. Keep Using CPU Tiếp tục dùng CPU # Phase 4.3 — Keyboard Shortcuts and Menus editor diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8928778e47..752008db68 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2292,6 +2292,8 @@ cameraRaw Blending 混合 GPU acceleration could not continue. Your documents are unchanged. GPU 加速无法继续。您的文档未更改。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft 正在使用 CPU 合成图像。窗口仍可能使用您的显卡。 Retry GPU requires a restart. Save your work first. 重试 GPU 需要重新启动。请先保存您的工作。 + Retry GPU tries the graphics processor again now. 重试 GPU 会立即再次尝试使用图形处理器。 + GPU acceleration resumed. 已恢复 GPU 加速。 Keep Using CPU 继续使用 CPU Retry GPU 重试 GPU Advanced 高级 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0d68180573..ff6530d436 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2235,6 +2235,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU 加速無法繼續。您的文件未變更。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft 正使用 CPU 合成影像。視窗仍可能使用您的顯示卡。 Retry GPU requires a restart. Save your work first. 重試 GPU 需要重新啟動。請先儲存您的工作。 + Retry GPU tries the graphics processor again now. 重試 GPU 會立即再次嘗試使用圖形處理器。 + GPU acceleration resumed. 已恢復 GPU 加速。 Keep Using CPU 繼續使用 CPU Retry GPU 重試 GPU Advanced 進階 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..6990ccfd67 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -813,6 +813,11 @@ pub struct PhotocraftApp { /// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]). Dropped /// for the rest of the session when the device is lost (see `gpu_status`). gpu: Option, + /// The render state the GPU canvas was built on, kept after it is dropped so Retry GPU can + /// build a new one on the same device (see `gpu_status::choose_recovery`, #6577). + gpu_render_state: Option, + /// Why the GPU canvas was dropped (see `gpu_status::fall_back`). + gpu_fault: Option, /// Run once the first frames have rendered (see [`Self::on_started`]). started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). @@ -976,6 +981,8 @@ impl PhotocraftApp { doc_hist: None, hist_job: None, gpu: None, + gpu_render_state: None, + gpu_fault: None, started: None, perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), @@ -1013,6 +1020,7 @@ impl PhotocraftApp { let tile = self.session.prefs().performance.cache_tile_size; // Escaped driver/setup panics must leave the session and CPU canvas alive. self.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + self.gpu_render_state = Some(rs.clone()); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, Err(payload) => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..46fbe47fe3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,7 +82,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window -- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback +- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch. Retry GPU saves GPU mode; after an error (`error: true`) it also builds a new GPU canvas on the same device at once (`gpuInfo.canvas` is `gpu` again, #6577), and after a loss it asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback - `ui.screenshot {path?, focus?}`: capture the main window (PNG). With no path the reply contains base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering From 6c74253436372345809e26a2ae08c64904b2707c Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:09:36 +0200 Subject: [PATCH 0677/1452] Camera Raw open: the dialog starts from the demosaic method the open developed with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open job captures the demosaic preference when it starts; the dialog is queued when the job lands and read the preference again, so a `rawDefaults.demosaic` change in between gave `RawOpen::developed_with` the newer method, and Open with the dialog unchanged skipped the re-develop although the document had been developed otherwise (CodeRabbit on #7417). The default open's note already says which method it used ("developed with default settings (ahd demosaic, …)"); `photocraft_io::raw::developed_demosaic(warnings)` reads it back and `queue_open_dialog` takes the method from there, the preference only as a fallback. Tests: `the_developed_note_says_which_demosaic_method_was_used` (io), and `the_demosaic_method_is_chosen_at_open` now changes the preference between the open and the queued dialog. Co-Authored-By: Claude Fable 5.1 --- crates/io/src/raw.rs | 18 ++++++++++++++++++ crates/ui-egui/src/camera_raw_ui.rs | 20 ++++++++++++++++---- crates/ui-egui/src/jobs_ui.rs | 2 +- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index f03c503c90..456669aa49 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -253,6 +253,16 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result Option { + let note = warnings.iter().find(|w| w.contains(DEVELOPED_NOTE))?; + let after = ¬e[note.find(DEVELOPED_NOTE)? + DEVELOPED_NOTE.len()..]; + let id = after.trim_start().strip_prefix('(')?.split(" demosaic").next()?; + Demosaic::from_id(id) +} + /// Develops a raw file with explicit settings. Without `opts.camera_look`, a file without /// colour calibration gets one fitted to its embedded JPEG when that clearly helps. pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Result { @@ -285,6 +295,14 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul mod tests { use super::*; + #[test] + fn the_developed_note_says_which_demosaic_method_was_used() { + let note = format!("NEF: {DEVELOPED_NOTE} (mhc demosaic, as-shot white balance) into 16-bit ProPhoto RGB"); + assert_eq!(developed_demosaic(&["unrelated".to_string(), note]), Some(Demosaic::Mhc)); + assert_eq!(developed_demosaic(&[format!("{DEVELOPED_NOTE} (amaze demosaic)")]), None); + assert_eq!(developed_demosaic(&["opened the embedded preview".to_string()]), None); + } + #[test] fn camera_names_drop_a_repeated_make() { assert_eq!(camera_name(Some("Canon"), Some("Canon EOS 80D")), "Canon EOS 80D"); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 869f4ba2ca..b7b6f1e240 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -223,11 +223,13 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { warnings.iter().filter(|w| !w.contains(photocraft_io::raw::DEVELOPED_NOTE)).cloned().collect() } -/// Queues the open-time dialog for the active document (shown on the next frame). -pub fn queue_open_dialog(app: &mut PhotocraftApp, name: &str, path: Option<&str>, bytes: Option<&[u8]>) { +/// Queues the open-time dialog for the active document (shown on the next frame). `warnings` +/// are the import's: its note says which demosaic method the open developed with. +pub fn queue_open_dialog(app: &mut PhotocraftApp, name: &str, path: Option<&str>, bytes: Option<&[u8]>, warnings: &[String]) { if let Some(st) = app.session.active() { - // The open developed it with the preference's method (`jobs::import_options`). - let method = app.session.prefs().raw_defaults.demosaic.method(); + // The method the open actually used (`jobs::import_options` at the job's start), from its + // note: the preference may have changed while a background open ran. + let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); app.pending_raw_open = Some(RawOpen { document: st.doc.id, name: name.to_string(), @@ -1617,6 +1619,16 @@ mod tests { // Only the open-time dialog has the choice. menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap(); assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "ahd"}})).unwrap().is_err()); + + // The preference may change while a background open runs: the dialog starts from the + // method the open developed with (its note), not from the newer preference. + let warnings = app.open_bytes("IMG_0004.dng", &dng).unwrap(); + app.pending_raw_open.take(); + app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "ahd"})).unwrap(); + queue_open_dialog(&mut app, "IMG_0004.dng", None, Some(&dng), &warnings); + let raw = app.pending_raw_open.take().unwrap(); + use photocraft_io::raw::Demosaic; + assert_eq!((raw.developed_with, raw.demosaic), (Demosaic::Bilinear, Demosaic::Bilinear)); } #[test] diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 29da41d481..41dc41dfd6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -582,7 +582,7 @@ pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str> app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. if crate::camera_raw_ui::wants_open_dialog(app, warnings) { - crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes); + crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes, warnings); notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings)); } else { notices::io_warnings(app, &opened, warnings); From a1c9743eacbeb28a10ecac71f5eb9d00907452b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 13:09:47 -0700 Subject: [PATCH 0678/1452] launch args: the environment overrides fold into launch_options again #6499 moved the argument parsing into parse_launch_args, and main() now destructures the result without mut, so the line that folds the PHOTOCRAFT_* overrides into launch_options fails with E0596. One mut on that field; nothing else in the change moves. --- apps/photocraft/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 8796620d58..d4f47c1b39 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -496,7 +496,7 @@ fn main() -> eframe::Result { unreadable_paths, safe_gpu, new_instance, - launch_options, + mut launch_options, present_mode_arg, frame_latency_arg, stroke_trace_arg, From 5b757332d46d5457f9f7da00cdc599988112e96f Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 23:28:50 +0300 Subject: [PATCH 0679/1452] test(atn): cover numeric layer rejection for rename, label, and arrange commands --- crates/engine/src/atn_cmds/tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 78aa463876..6c139cf363 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1013,6 +1013,23 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let err_transf = s.execute("actions.save", json!({"action": "TransformNumeric"})); assert!(err_transf.is_err(), "strict export must reject numeric layer ID for edit.transform"); + // Same for layer.renameLayer, layer.setLabelColor, and each layer.arrange.* command + for (name, id, params) in [ + ("RenameNumeric", "layer.renameLayer", json!({"layer": 42, "name": "Renamed"})), + ("LabelNumeric", "layer.setLabelColor", json!({"layer": 42, "color": "red"})), + ("BringToFrontNumeric", "layer.arrange.bringToFront", json!({"layer": 42})), + ("SendToBackNumeric", "layer.arrange.sendToBack", json!({"layer": 42})), + ("BringForwardNumeric", "layer.arrange.bringForward", json!({"layer": 42})), + ("SendBackwardNumeric", "layer.arrange.sendBackward", json!({"layer": 42})), + ] { + s.actions.list.push(Action::new(name, vec![(id.into(), params)])); + let before_actions = s.actions.list.clone(); + let before_rev = s.active().unwrap().revision; + assert!(s.execute("actions.save", json!({"action": name})).is_err()); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); + } + // With skipUnsupported: true, numeric layer references are skipped let skipped = s.execute("actions.save", json!({"action": "DeleteNumeric", "skipUnsupported": true})).unwrap(); assert_eq!(skipped["steps"], 0); From 92c4457b16f2c30206b9fde9042d72008348f927 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 14:33:13 -0600 Subject: [PATCH 0680/1452] Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) (#7462) * Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) #5170 made Preferences resizable, but its contents still sized themselves from the app window (width capped at 1040, settings area at 680 tall), and the dialog opened at a 960x900 default taller than that. So it opened with empty space below the buttons, and enlarging it left the settings and the buttons stuck in the top-left corner. The settings now take their width and height from the resizable area, leaving room for the button row, and Preferences opens at 1040x800. The settings area stays a few points short of the space it is given: egui grows the area to fit its contents, so asking for more would make the dialog grow every frame. * Preferences: a rendered test drags the resize grip and checks the buttons stay in the corner (#7420) Opens Preferences in a kittest harness, drags its grip wider and then smaller, and checks the button row stays in the frame's bottom-right corner each time and that the dialog keeps its size afterwards. Fails with the sizing from before the fix. * Preferences resize test: the buttons must also stay inside the frame A button row past the frame's bottom or right edge gave a negative gap and passed; now each gap must be 0 to 24 pt. --- crates/ui-egui/src/dialogs.rs | 96 ++++++++++++++++++++++++++++------ crates/ui-egui/src/prefs_ui.rs | 40 ++++++++++++-- crates/ui-egui/src/widgets.rs | 7 ++- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43b4f3d3f9..81e04e570c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,15 +104,22 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } -fn resizable_dialog(fields: &serde_json::Map) -> bool { - fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields) +/// The size a resizable dialog opens at, or `None` for a dialog sized by its contents. +fn resizable_dialog(fields: &serde_json::Map) -> Option { + if crate::prefs_ui::is_preferences(fields) { + Some(crate::prefs_ui::PREFERENCES_SIZE) + } else if fields.contains_key("__web") { + Some(egui::vec2(960.0, 900.0)) + } else { + None + } } fn show_modal( modal: egui::Modal, ctx: &egui::Context, id: egui::Id, - resizable: bool, + resizable: Option, interactive: bool, contents: impl FnOnce(&mut egui::Ui) -> T, ) -> egui::ModalResponse { @@ -121,14 +128,9 @@ fn show_modal( if !interactive { ui.disable(); } - if resizable { + if let Some(size) = resizable { let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); - egui::Resize::default() - .id(id.with("resize")) - .default_size(egui::vec2(960.0, 900.0).min(max)) - .min_size(egui::vec2(560.0, 400.0)) - .max_size(max) - .show(ui, contents) + egui::Resize::default().id(id.with("resize")).default_size(size.min(max)).min_size(egui::vec2(560.0, 400.0)).max_size(max).show(ui, contents) } else { contents(ui) } @@ -207,7 +209,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let web = fields.contains_key("__web"); let web_popup_open = web && egui::Popup::is_any_open(ctx); - let modal = show_modal(modal, ctx, id, resizable_dialog(&fields), interactive, |ui| { + let resizable = resizable_dialog(&fields); + let modal = show_modal(modal, ctx, id, resizable, interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title @@ -218,7 +221,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if !compact { ui.set_min_width(if curves { 300.0 } else { 380.0 }); } - let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); + // A resizable dialog's contents fill its resizable area (#7420), the others the window. + let room = if resizable.is_some() { available.x } else { (ctx.content_rect().width() - 48.0).max(380.0) }; + let wide = crate::prefs_ui::width(&d.fields, room); if let Some(w) = wide { ui.set_min_width(w.min(460.0)); } @@ -1132,9 +1137,68 @@ mod preference_resize_tests { let ordinary = json!({}); let web = json!({"__web": true}); - assert!(resizable_dialog(prefs.as_object().unwrap())); - assert!(!resizable_dialog(shortcuts.as_object().unwrap())); - assert!(!resizable_dialog(ordinary.as_object().unwrap())); - assert!(resizable_dialog(web.as_object().unwrap())); + assert_eq!(resizable_dialog(prefs.as_object().unwrap()), Some(crate::prefs_ui::PREFERENCES_SIZE)); + assert_eq!(resizable_dialog(shortcuts.as_object().unwrap()), None); + assert_eq!(resizable_dialog(ordinary.as_object().unwrap()), None); + assert_eq!(resizable_dialog(web.as_object().unwrap()), Some(egui::vec2(960.0, 900.0))); + } + + /// Drags the dialog's resize grip (its frame's bottom-right corner) by `by`. + fn drag_corner(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, by: egui::Vec2) { + let corner = frame(h).max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerMoved(corner + by)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner + by, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + } + + fn frame(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Rect { + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap(); + rects[0] + } + + /// The button row sits in the frame's bottom-right corner (its order follows the platform): + /// the contents fill the dialog, no empty space. + fn assert_buttons_in_corner(h: &egui_kittest::Harness<'_, PhotocraftApp>, when: &str) { + use egui_kittest::kittest::Queryable; + let f = frame(h); + let row = ["OK", "Cancel", "Apply"].map(|l| h.get_by_label(l).rect()).into_iter().reduce(|a, b| a.union(b)).unwrap(); + // Inside the frame (not past its edge) and near it. + let near = |gap: f32| (0.0..24.0).contains(&gap); + assert!(near(f.bottom() - row.bottom()) && near(f.right() - row.right()), "{when}: frame {f:?}, buttons {row:?}"); + } + + #[test] + fn preferences_contents_follow_the_dialog_when_it_is_resized() { + // #7420: the dialog resized but its contents kept a size from the window, leaving the + // buttons part-way up a dialog that opened, or was dragged, larger than them. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + crate::prefs_ui::open_preferences(&mut app, "interface"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + assert_buttons_in_corner(&h, "on open"); + let opened = frame(&h); + drag_corner(&mut h, egui::vec2(220.0, 80.0)); + assert!(frame(&h).width() > opened.width() + 150.0 && frame(&h).height() > opened.height() + 50.0, "wider: {:?}", frame(&h)); + assert_buttons_in_corner(&h, "wider"); + drag_corner(&mut h, egui::vec2(-500.0, -300.0)); + let smaller = frame(&h); + assert!(smaller.width() < opened.width() - 200.0 && smaller.height() < opened.height() - 150.0, "smaller: {smaller:?}"); + assert_buttons_in_corner(&h, "smaller"); + // The contents never ask for more than the dialog gives them, so it keeps its size. + h.run_steps(10); + assert_eq!(frame(&h), smaller); } } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6c910865bd..7213407024 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -968,10 +968,22 @@ pub fn apply(app: &mut PhotocraftApp, id: u64) -> Result { Ok(result) } -/// Max dialog width for our dialogs. +/// The size Preferences opens at: room for the widest section and about 30 settings rows. The +/// dialog's resize grip changes it, and its contents follow (#7420). +pub const PREFERENCES_SIZE: egui::Vec2 = egui::vec2(1040.0, 800.0); + +/// The height of the settings area (section list and fields) when `available` is left below the +/// search field: everything but the dialog's button row (`item_spacing` + 8 pt + the buttons) and +/// a little slack, never less than a few rows. +fn settings_height(available: f32, item_spacing: f32, button_height: f32) -> f32 { + (available - (item_spacing + 8.0 + button_height + 4.0)).max(160.0) +} + +/// Max dialog width for our dialogs. Preferences fills the width it is given (`available`, its +/// resizable area). pub fn width(fields: &Map, available: f32) -> Option { match fields.get("__prefsui").and_then(Value::as_str)? { - "prefs" => Some(available.clamp(380.0, 1040.0)), + "prefs" => Some(available.max(380.0)), "shortcuts" => Some(720.0), _ => Some(460.0), } @@ -1246,7 +1258,10 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option= available, "{available}"); + } + assert_eq!(super::settings_height(1400.0, 4.0, 30.0) - super::settings_height(600.0, 4.0, 30.0), 800.0); + assert_eq!(super::settings_height(50.0, 4.0, 30.0), 160.0); + } use super::*; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 533f1ee052..b393c0787d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1276,9 +1276,14 @@ fn button_size(ui: &Ui, label: &str, min_width: f32) -> Vec2 { button_layout(ui, label, min_width, None, Tokens::get(ui.ctx()).text).1 } +/// The height of a primary or secondary button (and so of a dialog's button row). +pub fn button_height(ctx: &egui::Context) -> f32 { + if Tokens::get(ctx).pro { 28.0 } else { 30.0 } +} + fn button_layout(ui: &Ui, label: &str, min_width: f32, height: Option, fg: Color32) -> (std::sync::Arc, Vec2) { let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg); - let h = height.unwrap_or(if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 }); + let h = height.unwrap_or(button_height(ui.ctx())); let size = vec2((galley.size().x + 28.0).max(min_width), h); (galley, size) } From 5dedcd5d5f15051eefc75d297c4340007ea01efa Mon Sep 17 00:00:00 2001 From: seotarek Date: Sun, 18 Oct 2026 23:48:21 +0300 Subject: [PATCH 0681/1452] test(atn): cover showLayers and layer.select in numeric layer rejection --- crates/engine/src/atn_cmds/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 6c139cf363..5433c69713 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1013,10 +1013,12 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let err_transf = s.execute("actions.save", json!({"action": "TransformNumeric"})); assert!(err_transf.is_err(), "strict export must reject numeric layer ID for edit.transform"); - // Same for layer.renameLayer, layer.setLabelColor, and each layer.arrange.* command + // Same for layer.renameLayer, layer.setLabelColor, layer.showLayers, layer.select, and each layer.arrange.* command for (name, id, params) in [ ("RenameNumeric", "layer.renameLayer", json!({"layer": 42, "name": "Renamed"})), ("LabelNumeric", "layer.setLabelColor", json!({"layer": 42, "color": "red"})), + ("ShowNumeric", "layer.showLayers", json!({"layer": 42})), + ("SelectNumeric", "layer.select", json!({"layer": 42})), ("BringToFrontNumeric", "layer.arrange.bringToFront", json!({"layer": 42})), ("SendToBackNumeric", "layer.arrange.sendToBack", json!({"layer": 42})), ("BringForwardNumeric", "layer.arrange.bringForward", json!({"layer": 42})), From 84df1468d89fe9ad4a15e5596eeb41a376ac8217 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 13:55:31 -0700 Subject: [PATCH 0682/1452] launch args: keep the `mut` off macOS builds CodeRabbit on #7242: the LaunchArgs destructuring asked for a mutable launch_options, but the only mutation happens inside the cfg(not(target_os = "macos")) block, so a macOS build warns about an unused mut. The binding is made mutable inside that block instead, where the fold happens. No behaviour change on any platform; upstream CI has no macOS leg, so this is warning hygiene, not a CI fix. --- apps/photocraft/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index d4f47c1b39..4a9a11b9d0 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -496,7 +496,7 @@ fn main() -> eframe::Result { unreadable_paths, safe_gpu, new_instance, - mut launch_options, + launch_options, present_mode_arg, frame_latency_arg, stroke_trace_arg, @@ -525,6 +525,9 @@ fn main() -> eframe::Result { // A variable documented as the equivalent of a per-launch flag counts as that flag: a // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it // keeps its own process instead of handing its files over and being ignored. + // Folded in here, inside the block that only non-macOS builds compile, so the binding + // above keeps its `mut` off macOS, where nothing is folded in. + let mut launch_options = launch_options; launch_options.extend_from_slice(&single_instance::env_launch_options()); let context = single_instance::LaunchContext { per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, From 617264a124b409926755c1766f5049f5889d0d4b Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 18 Oct 2026 13:55:54 -0700 Subject: [PATCH 0683/1452] Tests: the slow-job frame loop test waits for the worker's progress message (#7424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it stepped frames until 400 ms of wall time and 10 frames had passed, and then looked for the job's status message, "Filtering tiles", in the progress dialog. The job's worker reports that message as its first statement, on a thread of its own. The dialog appears after 250 ms whether or not the worker has run, and it reads "Working…" until the message arrives. On a loaded machine the worker can start after the 400 ms window, and the test then fails with "the job's status message". The test now steps at least 10 frames and until the dialog shows "Filtering tiles", through a new helper, frames_until, which fails the test when the condition does not hold within 60 s. It also checks that the job is still running at that point. The helper has the same signature as the frames_until in #3422. --- crates/ui-egui/src/jobs_ui_tests.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..70ce973be0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -84,6 +84,21 @@ fn frames_for_at_least(h: &mut Harness<'_, PhotocraftApp>, ms: u64, min_frames: (n, worst) } +/// Step at least `min` frames and until `ready` holds; returns (frames, slowest frame in ms). +/// The test fails when `ready` still does not hold after 60 s. +fn frames_until(h: &mut Harness<'_, PhotocraftApp>, min: u64, ready: impl Fn(&Harness<'_, PhotocraftApp>) -> bool) -> (u64, f64) { + let t = Instant::now(); + let (mut n, mut worst) = (0, 0.0f64); + while n < min || !ready(h) { + assert!(t.elapsed() < Duration::from_secs(60), "not ready after {n} frames"); + let f = Instant::now(); + h.step(); + worst = worst.max(f.elapsed().as_secs_f64() * 1000.0); + n += 1; + } + (n, worst) +} + #[test] fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let mut h = app_harness(); @@ -91,7 +106,9 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let doc_before = h.state().session.active().unwrap().doc.clone(); let job = slow_job(h.state_mut(), &gate); let frame0 = h.state().frame; - let (n, worst) = frames_for_at_least(&mut h, 400, 10); + // The worker thread reports "Filtering tiles" when it starts, which can be after the dialog appears. + let (n, worst) = frames_until(&mut h, 10, |h| h.query_by_label("Filtering tiles").is_some()); + assert!(h.state().session.job(job).is_some(), "the job is still running"); assert!(h.state().frame - frame0 >= 10, "the UI kept rendering ({n} frames)"); eprintln!("{n} frames during the job, slowest {worst:.1} ms"); // The status bar shows the job, and after the delay the modal progress dialog. From 89b9e72640d92c7d6cb0912c84a1967d4e8c7df4 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 18 Oct 2026 13:55:59 -0700 Subject: [PATCH 0684/1452] Tests: the authentication deadline test writes its first line before the deadline starts (#7428) an_authenticated_connection_outlives_the_authentication_deadline started its 200 ms deadline after it spawned the thread that writes the first line. When the OS started that thread late, the first read failed with TimedOut at crates/automation/src/security.rs:447. On a loaded machine the photocraft-automation lib tests failed 5 of 30 runs this way. The test thread now writes the first line before the deadline starts, and the limit is 1 s. The sender thread writes the second line after the test thread has called authenticated() and 300 ms after the deadline, so the second read still waits past the deadline. The test still fails when authenticated() is not called. The product's AUTH_DEADLINE is unchanged. --- crates/automation/src/security.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 89a760b32a..0735511f20 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -480,16 +480,23 @@ mod tests { #[test] fn an_authenticated_connection_outlives_the_authentication_deadline() { let (server, mut client) = tcp_pair(); + // The test thread writes the first line before the deadline starts. The sender writes the + // second line once the connection has authenticated and the deadline has passed. + client.write_all(b"one\n").unwrap(); + let limit = Duration::from_secs(1); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit)); + let deadline = Instant::now() + limit; + let (tx, rx) = std::sync::mpsc::channel(); let sender = std::thread::spawn(move || { - client.write_all(b"one\n").unwrap(); - std::thread::sleep(Duration::from_millis(500)); + rx.recv().unwrap(); + std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now())); client.write_all(b"two\n").unwrap(); }); - let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); let mut line = String::new(); assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "one\n"); reader.get_mut().authenticated().unwrap(); + tx.send(()).unwrap(); // Sent 300 ms after the deadline: only the idle timeout applies now. assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "two\n"); From 9b4648e1952765b84a6af24178186500e02ad1f4 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:04 +0300 Subject: [PATCH 0685/1452] Font menu: hovering a font no longer grows its row and makes the list jump (#7406) (#7425) * fix(type): font menu rows keep their size when hovered or selected, so the list no longer jumps (#7406) * test(type): hovering a font row moves no row and keeps the menu width (#7406) --- crates/ui-egui/src/type_font_picker_tests.rs | 29 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 6 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 3dfe34dedc..4aa25c750f 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -83,6 +83,35 @@ fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { assert!(h.ctx.data(|d| d.get_temp::(hover_id)).is_none()); } +/// #7406: hovering a font highlights its row without moving anything: the other rows keep +/// their place, the menu its width, and every row (the selected one too) takes one height. +#[test] +fn hovering_a_font_row_moves_no_other_row() { + let mut h = harness(); + // The app's theme: its hovered and selected buttons draw a stroke (egui's default style + // would hide the jump). + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run(); + open(&mut h); + let rows = |h: &Harness<'static, State>| -> Vec { (0..8).map(|i| h.get_by_label(&format!("Family {i:02}")).rect()).collect() }; + let before = rows(&h); + let width = popup_rect(&h).expect("font menu open").width(); + let tops: Vec = before.windows(2).map(|w| w[1].top() - w[0].top()).collect(); + assert!(tops.windows(2).all(|t| (t[0] - t[1]).abs() < 0.01), "rows are evenly spaced, the selected Family 00 included: {tops:?}"); + for hovered in [2, 5] { + h.hover_at(before[hovered].center()); + h.run_steps(2); + let after = rows(&h); + for (i, (b, a)) in before.iter().zip(&after).enumerate() { + if i != hovered { + assert_eq!(b, a, "hovering Family {hovered:02} moved Family {i:02}"); + } + } + assert_eq!(after[hovered].center(), before[hovered].center(), "the hovered row stays centred where it was"); + assert_eq!(popup_rect(&h).map(|r| r.width()), Some(width), "the menu keeps its width"); + } +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d9a4ae9928..29882cd7d6 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1517,7 +1517,11 @@ pub(crate) fn family_picker_in( let mut hovered: Option = None; for (f, label) in rows { - let mut button = egui::Button::selectable(f == current, label).truncate(); + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). + let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); if let Some((size, [_, h])) = preview { button = button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); From a4436074c90350360c23979a32b3c40719a8b97e Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:10 +0300 Subject: [PATCH 0686/1452] =?UTF-8?q?Font=20field:=20=E2=86=91/=E2=86=93?= =?UTF-8?q?=20step=20through=20the=20fonts=20after=20a=20pick,=20as=20in?= =?UTF-8?q?=20Photoshop=20(#7407)=20(#7426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(type): ↑/↓ on the focused font field step through the families after a pick, as in Photoshop (#7407) * test(type): the font field keeps the focus after a pick and ↑/↓ step the families (#7407) --- crates/ui-egui/src/type_font_picker_tests.rs | 82 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 36 +++++++++ 2 files changed, 118 insertions(+) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 4aa25c750f..6f05ed61a5 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -239,6 +239,88 @@ fn arrows_apply_fonts_without_closing_and_enter_accepts() { assert!(popup_rect(&h).is_none()); } +/// #7407: as in Photoshop, the field keeps the focus once a font is picked (click or Enter), +/// and ↑ / ↓ on it step through the families with the menu closed. Escape ends it. +#[test] +fn arrows_on_the_field_step_through_the_families_after_a_pick() { + let mut h = harness(); + open(&mut h); + h.get_by_label("Family 03").click(); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "picking closes the menu"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "the field keeps the focus"); + let press = |h: &mut Harness<'static, State>, key: Key| { + h.key_press(key); + h.run(); + assert!(popup_rect(h).is_none(), "{key:?} leaves the menu closed"); + assert!(h.ctx.memory(|m| m.has_focus(button)), "{key:?} keeps the focus on the field"); + h.state().current.clone() + }; + assert_eq!(press(&mut h, Key::ArrowDown), "Family 04"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 05"); + assert_eq!(press(&mut h, Key::ArrowUp), "Family 04"); + // Enter in the open menu is a pick too. + open(&mut h); + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 05"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 06"); + // Escape takes the focus away; the arrows then leave the font alone. + h.key_press(Key::Escape); + h.run(); + assert!(!h.ctx.memory(|m| m.has_focus(button))); + h.key_press(Key::ArrowDown); + h.run(); + assert_eq!(h.state().current, "Family 06"); +} + +/// The first arrow right after a pick (in the very next frame) steps the font and keeps the +/// focus on the field, also between focusable widgets: the field's lock filter only holds from +/// the frame after it got the focus, and egui would read that arrow as a move to "Below". +#[test] +fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { + for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { + let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); + let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, s: &mut State| { + let _ = ui.button("Above"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); + let fams = s.families.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let _ = ui.button("Below"); + }, + state, + ); + h.run(); + open(&mut h); + if enter { + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + } else { + h.get_by_label("Family 05").click(); + } + // Frame by frame up to the pick, then the arrow in the next frame. + for _ in 0..8 { + if h.state().current == picked && popup_rect(&h).is_none() { + break; + } + h.step(); + } + assert_eq!(h.state().current, picked, "enter: {enter}"); + h.key_press(Key::ArrowDown); + h.step(); + h.run(); + assert_eq!(h.state().current, stepped, "enter: {enter}"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "enter: {enter}: the arrow moved the focus off the field"); + } +} + #[test] fn arrows_follow_the_filtered_list_and_handle_no_matches() { let mut h = harness(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 29882cd7d6..325e5122b4 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1451,6 +1451,8 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, ) -> bool { let mut changed = false; + // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. + let mut picked = false; let search_id = ui.id().with(("font-search", salt)); let focus_id = search_id.with("focus"); // The list opens on the press, so a press-drag-release picks a font too (#2735). @@ -1508,6 +1510,7 @@ pub(crate) fn family_picker_in( } } let accept = enter && rows.iter().any(|(f, _)| *f == current); + picked |= accept; if accept { ui.data_mut(|d| d.remove::(search_id)); ui.close(); @@ -1543,6 +1546,7 @@ pub(crate) fn family_picker_in( // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); changed = true; + picked = true; ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); @@ -1565,6 +1569,38 @@ pub(crate) fn family_picker_in( } let hovered_font = response.inner.unwrap_or_default(); + // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the + // focused, closed field step to the previous / next family, applying each (#7407). The lock + // filter keeps egui from using those arrows to move the focus to another widget. + let field = response.response.id; + if picked { + ui.memory_mut(|m| m.request_focus(field)); + } + let open = egui::ComboBox::is_open(ui.ctx(), field); + if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); + let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); + if down || up { + // The lock filter only holds from the frame after the field got the focus: an arrow + // right after a pick was already read by egui as a focus move, applied at the end of + // the frame. Cancel it, so the arrow only steps the font. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + // The wheel's step: a negative notch is the next family. + let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); + let rows = font_menu_rows(families, labels, ""); + if let Some(i) = rows.iter().position(|(f, _)| *f == current) + && let Some(n) = wheel_family_index(i, rows.len(), notches) + && let Some((f, _)) = rows.get(n) + && **f != *current + { + current.clone_from(f); + photocraft_text::served::request(f); + changed = true; + } + } + // The wheel over the closed button steps through the families, a notch each, like Photoshop's // options-bar font menu: no need to open the list (#3350). if !egui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { From ecc1a9e47bdf6ffe7d6ef3c03861dfa07979a351 Mon Sep 17 00:00:00 2001 From: driftee <48539371+driftee@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:56:15 +0800 Subject: [PATCH 0687/1452] fix(transform): detach clipped preview ancestors (#7438) --- crates/ui-egui/src/transform_tool.rs | 62 +++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d14046db9d..45ef0b1528 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -792,11 +792,14 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - show_only(&mut solo.layers, &[id]); - // Its clipping base is hidden here, which would clip it away entirely (#5239). - // Clear the target itself, not only the top-level group that contains it. - if let Some(l) = solo.layer_mut(id) { - l.clipped = false; + if layer.is_some() { + show_preview_target(&mut solo.layers, id); + } else { + // Keep the previous fallback for an invalid id: render the document, with only + // top-level clipping disabled. Production callers get ids from this document. + for l in &mut solo.layers { + l.clipped = false; + } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { l.effects.enabled = false; @@ -986,6 +989,24 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { } } +/// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path +/// is detached from its clipping base because the base is hidden in this isolated render (#5239). +fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { + for l in layers { + if l.id == id { + l.clipped = false; + continue; + } + match &mut l.content { + LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { + l.clipped = false; + show_preview_target(&mut g.children, id); + } + _ => l.visible = false, + } + } +} + fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } @@ -4768,6 +4789,37 @@ mod tests { ); } + #[test] + fn nested_group_preview_detaches_every_clipped_ancestor() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + let mut middle = Layer::group("middle clipped ancestor", vec![target]); + middle.clipped = true; + let mut inner_base = Layer::raster("hidden inner clipping base", PixelFormat::RGBA8); + inner_base.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + let mut outer = Layer::group("outer clipped ancestor", vec![inner_base, middle]); + outer.clipped = true; + doc.layers.push(outer); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); + } + + #[test] + fn unknown_preview_target_keeps_the_previous_document_fallback() { + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active().unwrap(); + let (image, _) = preview_image(&st.doc, LayerId::fresh(), None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0)); + } + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); From 502d055c6c8cb5d86850d02bee32a2ff1da1e400 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 02:26:21 +0530 Subject: [PATCH 0688/1452] file.saveACopy refuses the document's own file without "overwrite" (#7437) (#7456) * file.saveACopy refuses the document's own file without "overwrite" (#7437) Save a Copy through the engine (control channel, MCP, serve, headless action playback) silently replaced the open document's own file when given its path, so a lossy re-encode could not be undone. It now returns BadParams when `path` is the active document's file (same text, ./ or .. spelling, hard link, case on Windows via same_file) unless the params pass "overwrite": true, and nothing is written. The desktop dialog writes through the shell after the OS confirms a replace, so it is unchanged. Co-Authored-By: Claude Opus 5.5 * same_file: compare file identity on Windows, so a hard link is the same file On Windows `same_file` compared canonical path strings, so a hard link to the input (another name for the same file) passed the new convert guard without --in-place. It now compares the volume serial number and file index (the `same-file` crate, already in the dependency tree; std's file_index is unstable), as unix compares dev and inode, falling back to the case-insensitive canonical path when a file won't open. The engine's same_file test now checks hard links on every platform. (Review on #7454.) Co-Authored-By: Claude Opus 5.5 * saveACopy own-file test: hard link on every platform, doc state unchanged after a refusal Each refused copy (same path, ./ spelling, hard link) now also checks the document keeps its path, revision and unsaved state. The hard link is no longer unix-only: same_file compares file identity on Windows too. (Review on #7456.) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 5 ++++ crates/engine/src/file_cmds.rs | 18 +++++++++++-- crates/engine/src/file_cmds/tests.rs | 38 ++++++++++++++++++++++++++++ crates/engine/src/web_cmds/tests.rs | 7 ++--- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26920a968e..132863df32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,6 +4812,7 @@ dependencies = [ "photocraft-vector", "rayon", "roxmltree", + "same-file", "serde", "serde_json", "tempfile", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 65dd1e16e0..fb3bc3fbec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" + # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] photocraft-ipp = { workspace = true } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f99aefcff0..1fb3fcf300 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -234,7 +234,12 @@ pub fn same_file(a: &str, b: &str) -> bool { } #[cfg(windows)] { - // NTFS and FAT compare names without case. + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: NTFS and FAT compare names without case. if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); } @@ -481,6 +486,15 @@ fn revert(s: &mut Session) -> Result { fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; + // A copy over the document's own file replaces it, and a flat or lossy format can't be + // undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the + // shell, after the OS has confirmed the replace. + if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) { + return Err(EngineError::BadParams { + cmd: "file.saveACopy".into(), + msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(), + }); + } let mut doc = (*d.doc).clone(); if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. @@ -1639,7 +1653,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 624edfdd3f..3ab50c4a77 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -135,6 +135,44 @@ fn save_a_copy_keeps_path_and_dirty_state() { assert!(s.execute("file.saveACopy", json!({})).is_err()); } +/// A copy over the document's own file, however the path is spelt, would replace it (a lossy +/// re-encode can't be undone), so it is refused unless `overwrite` is passed (#7437). +#[test] +fn save_a_copy_refuses_the_documents_own_file() { + let dir = tmp("copy-own"); + let path = png(&dir, "own.png", 6, 4, "#ff0000"); + let bytes = std::fs::read(&path).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let link = join(&dir, "link.png"); + std::fs::hard_link(&path, &link).unwrap(); + let rev = s.active().unwrap().revision; + for p in [path.clone(), format!("{dir}/./own.png"), link] { + let r = s.execute("file.saveACopy", json!({"path": p})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("overwrite")), "{p}: {r:?}"); + assert!(s.execute("file.saveACopy", json!({"path": p, "overwrite": false})).is_err(), "{p}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{p}"); + let st = s.active().unwrap(); + assert_eq!(st.path.as_deref(), Some(path.as_str()), "{p}"); + assert_eq!(st.revision, rev, "{p}"); + assert!(st.is_dirty(), "{p}"); + } + let other = join(&dir, "other.png"); + s.execute("file.saveACopy", json!({"path": other})).unwrap(); + assert!(std::fs::metadata(&other).unwrap().len() > 0); + s.execute("file.saveACopy", json!({"path": path, "overwrite": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(s.active().unwrap().path.as_deref(), Some(path.as_str())); + assert!(s.active().unwrap().is_dirty()); + + // A document that was never saved has no file of its own: it copies anywhere, as before. + let mut fresh = session(4, 4, 8); + fresh.execute("file.saveACopy", json!({"path": path})).unwrap(); + assert_eq!(fresh.active().unwrap().path, None); +} + /// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an /// alpha channel is written as 32-bit, with the channel as the alpha. #[test] diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 594fe8d496..bd55a549cb 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -476,11 +476,8 @@ fn same_file_sees_through_other_names_for_one_file() { assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); - #[cfg(unix)] - { - std::fs::hard_link(&a, &b).unwrap(); - assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); - } + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); } From 140230b1414ed723bda32a330c87ebcf6fe06845 Mon Sep 17 00:00:00 2001 From: Hodd Date: Sun, 18 Oct 2026 22:56:26 +0200 Subject: [PATCH 0689/1452] Replacing selected type keeps its size, as in Photoshop (#7457) Typing or pasting over a selection now takes the style of the first selected character instead of the one before it, so retyping a smaller second line no longer takes the first line's size from the line break. Fixes #7413. --- crates/engine/src/type_cmds.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 5854be5cbf..712cc3c89d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -504,8 +504,9 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } -/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the -/// character before it (or after it at the start). +/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its +/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting +/// at a caret takes the style of the character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); @@ -521,7 +522,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) + .position(|(s, e)| { + if a == 0 { + e > 0 || s == 0 + } else if a < b { + a >= s && a < e + } else { + a > s && a <= e + } + }) .unwrap_or(0) }; for (i, (len, st)) in runs.into_iter().enumerate() { @@ -1547,6 +1556,25 @@ mod tests { assert_eq!(t.runs[0].style.size_pt, 40.0); } + /// Replacing a selected smaller second line (by typing or pasting) keeps its size, not the + /// first line's, whose line break precedes the selection. + #[test] + fn replacing_a_selection_keeps_its_first_characters_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "BIG\nsmall", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 4, "end": 9, "size": 12}]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 9, "text": "AIR"}})).unwrap(); + let sizes = |s: &Session| text_layer(s, id).char_runs().iter().map(|r| (r.len, r.style.size_pt)).collect::>(); + assert_eq!(text_layer(&s, id).text, "BIG\nAIR"); + assert_eq!(sizes(&s), [(4, 40.0), (3, 12.0)]); + // A mixed selection takes its first character's style. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 6, "text": "xy"}})).unwrap(); + assert_eq!(sizes(&s), [(4, 40.0), (1, 12.0)]); + // Inserting at a caret still follows the character before it. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 4, "text": "!"}})).unwrap(); + assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]); + } + fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> { let t = text_layer(s, id); let mut out = Vec::new(); From 8ea77bdfd6495542efe0a7db4206b214c5e25c4c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:33 +0700 Subject: [PATCH 0690/1452] Fade: restore uniformly inverted layer masks (#7383) (#7463) * fix(fade): detect uniform mask changes and blend full canvas * test(fade): cover uniform mask inversion and Fade across bit depths * style(fade): keep default-pixel comparison readable * Format edit_menu_cmds.rs with rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/edit_menu_cmds.rs | 6 +++-- crates/engine/src/edit_menu_cmds/tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index c5f312e2cb..6f86413d13 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -68,7 +68,7 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { let now = st.doc.layer(layer); let was = prev.layer(layer); let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), (None, None) => true, _ => false, }; @@ -214,7 +214,9 @@ fn fade(s: &mut Session, p: &Value) -> Result { if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } - let area = before.content_bounds().union(&now.content_bounds()); + // A uniform mask can change every pixel without storing a single tile. In that case + // the content bounds are empty, but Fade must still blend across the canvas. + let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index ecbb8a7cda..873061e462 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -57,6 +57,38 @@ fn fade_with_blend_mode_and_invalidation() { assert!(s.execute("edit.fade", json!({"mode": "nonsense"})).is_err()); } +#[test] +fn fade_uniform_layer_mask_invert_at_all_depths() { + for depth in [8, 16, 32] { + for (opacity, expected) in [(0, 1.0), (50, 0.5), (100, 0.0)] { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask_pixel = |s: &Session| { + let layer = active(s); + let mask = &layer.mask.as_ref().unwrap().surface; + mask.pixel(5, 5)[0] + }; + assert!((mask_pixel(&s) - 1.0).abs() < 0.01); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| f.mask), "depth {depth}: Fade must target the mask"); + let history_before_fade = s.active().unwrap().history.past_len(); + + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let pixel = active(&s).mask.as_ref().unwrap().surface.pixel(x, y)[0]; + assert!((pixel - expected).abs() < 0.02, "depth {depth}, opacity {opacity}, ({x}, {y}): {pixel}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history_before_fade + 1); + assert!(s.undo()); + assert!(mask_pixel(&s) < 0.01, "undo restores inverted mask"); + assert!(s.redo()); + assert!((mask_pixel(&s) - expected).abs() < 0.02, "redo restores faded mask"); + } + } +} + #[test] fn purge_commands() { let mut s = session(8); From fdd4ca7cbcf33fdb2c20044134453fb24fdac015 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:39 +0700 Subject: [PATCH 0691/1452] Remove: cancel queued strokes when the paint target changes (#7380) (#7464) * fix(remove): cancel waiting strokes when their target changes * fix(remove): store pending target identity with waiting strokes * test(remove): cover queued strokes across layer and channel switches * Tests: format retouch_ui.rs and drop the unreachable! arm clippy denies Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +-- crates/ui-egui/src/retouch_ui.rs | 81 ++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..5948bd4edf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -605,9 +605,9 @@ pub struct PhotocraftApp { /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the - /// document they belong to, and their tint on the canvas (`retouch_ui`). + /// document, layer and channel they belong to, and their tint on the canvas (`retouch_ui`). pub(crate) remove_pending: Vec>, - pub(crate) remove_pending_doc: Option, + pub(crate) remove_pending_target: Option, pub(crate) remove_trail: Option, /// The open menus' items, reused between frames (`menus::ItemCache`). pub(crate) menu_cache: Option, @@ -879,7 +879,7 @@ impl PhotocraftApp { committed_stroke: None, trail: None, remove_pending: Vec::new(), - remove_pending_doc: None, + remove_pending_target: None, remove_trail: None, menu_cache: None, move_preview: None, diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 417df92b6e..d79a5385e3 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -166,15 +166,31 @@ fn remove_params(o: &crate::state::ToolOptions) -> Value { json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) } +/// The selection a queued Remove stroke was drawn against. A later selection must not +/// redirect the queued strokes to a different layer or colour component. +#[derive(Clone, PartialEq)] +pub(crate) struct RemoveTarget { + doc: photocraft_doc::DocId, + layer: Option, + channel: photocraft_engine::channel_cmds::ChannelTarget, + paint: Value, +} + +fn remove_target(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(RemoveTarget { doc: st.doc.id, layer: st.active_layer, channel: st.channel_view.target, paint: crate::canvas::paint_target(app) }) +} + /// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas, /// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them). pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let Some(target) = remove_target(app) else { return }; let Some(st) = app.session.active() else { return }; - let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]); - if app.remove_pending_doc != Some(doc) { + let size = [st.doc.size.width, st.doc.size.height]; + if app.remove_pending_target.as_ref() != Some(&target) { app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = Some(doc); + app.remove_pending_target = Some(target); } let diameter = app.session.tools.brush.size; app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed_stroke(points, diameter); @@ -185,12 +201,13 @@ pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { /// Apply the waiting strokes as one removal (one job, one undo step). False when none wait. pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { - if app.remove_pending.is_empty() { + // Enter can arrive before the next canvas frame checks pending-stroke lifetime. + if !remove_pending_live(app) { return false; } let strokes = std::mem::take(&mut app.remove_pending); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; let mut p = remove_params(&app.ui.tool_options); p["strokes"] = json!(strokes); p["target"] = crate::canvas::paint_target(app); @@ -209,7 +226,7 @@ pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool { } app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; app.ui.status.clear(); app.ui.status_error = false; true @@ -221,8 +238,7 @@ pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool { if app.remove_pending.is_empty() { return false; } - let doc = app.session.active().map(|st| st.doc.id); - if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc { + if app.ui.tool == Tool::Remove && remove_target(app).as_ref() == app.remove_pending_target.as_ref() { return true; } remove_pending_cancel(app); @@ -1181,6 +1197,55 @@ mod tests { assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); } + #[test] + fn queued_remove_cannot_commit_after_layer_or_channel_target_changes() { + for change in ["layer", "color", "mask"] { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + if change == "color" { + app.run("channel.target", json!({"channel": "red"})).unwrap(); + } + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1); + assert!(remove_pending_live(&mut app)); + + match change { + "layer" => { + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + } + "color" => { + app.run("channel.target", json!({"channel": "blue"})).unwrap(); + } + _ => { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + } + } + + let journal = app.session.journal.len(); + assert!(!remove_pending_commit(&mut app), "{change}: Enter must not retarget a queued stroke"); + assert_eq!(app.session.journal.len(), journal, "{change}: no Remove command ran"); + assert!(app.remove_pending.is_empty() && app.remove_trail.is_none(), "{change}: queue cleared"); + assert!(app.remove_pending_target.is_none()); + } + } + + #[test] + fn queued_remove_starts_a_new_queue_after_target_switch() { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + remove_pending_add(&mut app, &[[40.0, 10.0, 1.0], [44.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1, "strokes from different layers must not be combined"); + assert_eq!(app.remove_pending[0][0][0], 40.0); + assert!(remove_pending_live(&mut app)); + } + #[test] fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { let mut app = app(); From 59c8bd3b263fea478a8eeae921dd4aabb858ede7 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:44 +0700 Subject: [PATCH 0692/1452] Fix/7381 action worker failure 20261018t2254jkt (#7465) * fix(actions): stop resumed playback when a dialog's worker fails * test(actions): reject suffix after failing background dialog filter * fix(actions): propagate the failed worker result through dialog confirmation * fix(dialogs): return action worker failures to callers * Collapse the nested if in actions::resume (clippy::collapsible_if) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/actions.rs | 53 ++++++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index a5c2f15e9b..cdffc378dd 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -435,21 +435,29 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let Some(r) = fields.get(RESUME) else { return }; +pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(r) = fields.get(RESUME) else { return Ok(()) }; // The dialog's command may run as a background job; the next step needs its result. if let Some(doc) = app.session.active().map(|d| d.doc.id) && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) { - let _ = app.session.wait_job(job.id); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); } let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); match play(app, ¶ms) { - Ok(v) => report_play(app, &v), + Ok(v) => { + report_play(app, &v); + Ok(()) + } Err(e) => { let template = "Action playback failed: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); app.ui.status_error = true; + Err(e) } } } @@ -1543,6 +1551,43 @@ mod tests { assert_eq!(app.session.actions.list[0].steps.len(), 3); } + #[test] + fn dialog_worker_failure_stops_action_before_following_steps() { + for (background, locked) in [(true, true), (false, true), (true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + let mut action = Action::new( + "Worker Result", + vec![ + ("layer.setProps".into(), json!({"name": "Prefix", "locks": {"pixels": locked}})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), + ("layer.new.layer".into(), json!({"name": "Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.background_jobs = background; + let paused = play(&mut app, &json!({"action": "Worker Result"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let result = crate::dialogs::confirm(&mut app, dialog); + let suffix = app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Suffix"); + + assert_eq!(suffix, !locked, "background={background}, locked={locked}: {result:?}"); + if locked && background { + assert!(result.as_ref().is_err_and(|e| e.contains("locked")), "failed worker must fail the dialog confirmation: {result:?}"); + assert!(app.ui.status_error, "the action must report the worker failure"); + assert!(app.ui.status.contains("locked"), "{}", app.ui.status); + assert_eq!(app.session.jobs_with_recent()[0].state, "failed"); + } else if locked { + assert!(result.is_err(), "the foreground command rejects the locked layer"); + } else { + assert!(result.is_ok(), "the successful worker lets the action continue: {result:?}"); + } + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + } + } + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 81e04e570c..2e951e2ece 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -664,7 +664,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { if result.is_ok() { crate::filter_dialog::remember(app, &cmd, &d.fields); // A step dialog of a playing action: the action continues after it. - crate::actions::resume(app, &d.fields); + crate::actions::resume(app, &d.fields)?; } result } From 0807441e52ad3476573c134349ec5d57d99750c5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:56:48 +0200 Subject: [PATCH 0693/1452] Make .pcraft reload f64 coordinates bit-exact (#7498) Found while testing crates/format/Cargo.toml:19 (serde_json features), used for the manifest in crates/format/src/manifest.rs and store.rs Co-authored-by: Claude Haiku 5.5 --- crates/format/Cargo.toml | 2 +- crates/format/tests/float_roundtrip.rs | 27 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 crates/format/tests/float_roundtrip.rs diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..1d272d1462 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,7 +16,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true, features = ["unbounded_depth"] } +serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/float_roundtrip.rs new file mode 100644 index 0000000000..da6e7626d1 --- /dev/null +++ b/crates/format/tests/float_roundtrip.rs @@ -0,0 +1,27 @@ +//! `.pcraft` is lossless for f64: every coordinate reloads bit-for-bit. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; + +#[test] +fn f64_coordinates_reload_bit_exact() { + let x = 11.183221215612905_f64; + let y = 0.1 + 0.2; + let mut doc = Document::new("Floats", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Shape", + LayerContent::Shape(photocraft_doc::ShapeLayer { + path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(x, y), (40.0, 10.0), (25.0, 40.0)])]), + ..Default::default() + }), + )); + doc.guides.horizontal.push(x); + doc.guides.vertical.push(y); + doc.ruler_origin = [x, y]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.guides.horizontal[0].to_bits(), x.to_bits()); + assert_eq!(back.guides.vertical[0].to_bits(), y.to_bits()); + assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); + assert_eq!(back, doc); +} From 5095babf89216f0cc9f5fa7e0e048859cd2b6a31 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 02:26:53 +0530 Subject: [PATCH 0694/1452] perf(canvas): composite and upload an inline filter preview once (#7490) (#7501) The synchronous filter-preview path (web, and sessions with background jobs off) flattened each changed preview, uploaded it and recorded it, then flattened it again for publish_filter_preview, which uploaded and recorded it a second time. Flatten once, as the background worker does, and let publish_filter_preview do the upload and the perf record. This removes one full composite and one texture upload per changed preview, and the frame's refresh time no longer counts the preview twice. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a5f1452dd1..0d5fbe1b81 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1882,12 +1882,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } // Web and explicitly inline sessions keep the deterministic synchronous path (#1676): // computed inline, uploaded when a GPU texture exists, still inspectable without one. + // Flattened once, as the background worker does; `publish_filter_preview` uploads it (#7490). let _ = ctx; if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( + let computed = crate::filter_dialog::preview_document_for_target( &doc, request.active, &request.command, @@ -1898,19 +1899,11 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e &documents, &app.session.color.settings, ) - .map(std::sync::Arc::new); - if let Some(r) = &result { - let buf = photocraft_compose::flatten(r); - let t1 = crate::gpu_canvas::now_ms(); - // The preview's own profile: Assign and Convert to Profile change it (see `publish_filter_preview`). - let (display, _) = canvas_display(app, r, None); - if let Some(gpu) = app.gpu.as_ref() { - gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); - } - app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); - } - let buffer = result.as_ref().map(|r| photocraft_compose::flatten(r)); - publish_filter_preview(app, idx, request, result.zip(buffer), crate::gpu_canvas::now_ms() - t0); + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + publish_filter_preview(app, idx, request, computed, crate::gpu_canvas::now_ms() - t0); } let preview = app.filter_preview.as_ref()?; let result = preview.result.as_ref()?; @@ -6206,6 +6199,26 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #7490: the inline (web) preview path composites and uploads each changed preview once, as + /// the background worker does. It used to flatten and upload it twice and record it twice, + /// so the frame's refresh time counted the preview twice. + #[test] + fn inline_filter_preview_is_composited_and_recorded_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + app.perf.frame_refresh_ms = 0.0; + let (_, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + assert_eq!(size, [320, 240]); + assert_eq!(app.perf.last_refresh, "filter-preview"); + assert_eq!(app.perf.last_refresh_px, 320 * 240); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); + // Unchanged request: nothing is recomputed or recorded again. + ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("the same preview"); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); + } + /// #6326: a filter's live preview is never coarser than the screen. On a document big enough /// for a reduced proxy, the view at 100 % previews at full resolution, and only a view zoomed /// out far enough uses the proxy. Through `ensure_filter_preview`, so the factor the canvas From 449b99a74e6cda89de5a57ff30186cd627f8b348 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 18 Oct 2026 22:56:58 +0200 Subject: [PATCH 0695/1452] fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY (#7521) * fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY The positional match for , and . (so Shift+Cmd+. works on Nordic/German layouts) applied to every shortcut. On AZERTY, ; sits on the US comma key, so ; fired the command bound to , (brush hardness up/down did the same thing). Only shortcuts with Cmd now follow the key position. * contributors: credit Byxis by their GitHub profile name --- contributors/people.toml | 3 +++ crates/ui-egui/src/shortcut_dispatch.rs | 6 ++++-- crates/ui-egui/src/shortcut_dispatch/tests.rs | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 3948e2992b..102c69e66c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -169,3 +169,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Byxis] +sync_github_name = true diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 4336315dba..ffc1a7f6e5 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -215,7 +215,9 @@ fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bo } /// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout, -/// but types `:` on Nordic and German ones, so its character would never match. +/// but types `:` on Nordic and German ones, so its character would never match. Only ⌘ shortcuts +/// follow the position: a plain `,` or `;` binding (brush hardness on AZERTY, where `;` sits on the +/// US comma key) must match the character typed. const POSITIONAL: [Key; 2] = [Key::Period, Key::Comma]; /// Dispatch this frame's shortcut presses in the order they arrived (⌘Z then ⌘S undoes, then @@ -236,7 +238,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) - .and_then(|p| table.iter().find(|(_, sc)| sc.logical_key == p && key_matches(sc, p, *modifiers))); + .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) } _ => None, diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 2827671a84..00f1a120a3 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -804,3 +804,23 @@ fn type_size_keys_follow_key_position_and_the_typed_selection() { assert_eq!(logged(&h), ["type.decreaseSize"]); assert_eq!(sizes(&h), [(2, base), (3, base + 2.0)], "only the selected \"He\" shrinks"); } + +/// AZERTY: `;` sits on the US comma key and `,` on the US M key. Plain `,` / `;` bindings (brush +/// hardness) must match the character typed, not the key's position, or `;` fires `,`'s command. +#[test] +fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("tools.decreaseBrushHardness".into(), ",".into()); + p.shortcuts.insert("tools.increaseBrushHardness".into(), ";".into()); + }); + let tap = |h: &mut Harness<'_, PhotocraftApp>, key, physical| { + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + tap(&mut h, egui::Key::Semicolon, egui::Key::Comma); + assert_eq!(logged(&h), ["tools.increaseBrushHardness"]); + tap(&mut h, egui::Key::Comma, egui::Key::M); + assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); +} From d77a989ef410914bf62dcc7f8ef0e5cd7a74f976 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 22:57:03 +0200 Subject: [PATCH 0696/1452] image.crop: accept whole-number float targetWidth/targetHeight (#7551) (#7552) The target size was read with as_u64 only, so 100.0 (json.dumps of a float, many agent clients) was refused as not a whole number, while the crop rectangle and image.imageSize accept it. Fractional, negative, zero and out-of-range values are still refused. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index c5c85aec89..92bbdd7463 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -549,8 +549,10 @@ fn crop_target(p: &Value) -> Result { let side = |key: &str| -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), + // Whole-number floats (`100.0`) count too, as for the crop rectangle. Some(v) => v .as_u64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=f64::from(MAX_CROP_SIDE)).contains(f)).map(|f| f as u64)) .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) .map(Some) @@ -1418,6 +1420,14 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + #[test] + fn crop_target_accepts_whole_number_floats() { + // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. + let mut s = session(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 20, "height": 10, "targetWidth": 40.0, "targetHeight": 20.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(40, 20)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -1427,6 +1437,10 @@ mod tests { json!({"targetWidth": 0, "targetHeight": 10}), json!({"targetWidth": -5, "targetHeight": 10}), json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": -1.0, "targetHeight": 10}), + json!({"targetWidth": 0.0, "targetHeight": 10}), + json!({"targetWidth": 1e39, "targetHeight": 10}), + json!({"targetWidth": 300_001.0, "targetHeight": 10}), json!({"targetWidth": "10", "targetHeight": 10}), json!({"targetWidth": 300_001, "targetHeight": 10}), json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), From b48a916b138c370079f801818bbf26f38d8e72af Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:57:08 -0400 Subject: [PATCH 0697/1452] fix(algo): normalize nonpositive filter tile sizes (#7554) --- crates/algo/src/lib.rs | 3 +++ crates/algo/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index c2ff6d2e6c..5a034e4561 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -972,6 +972,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). +/// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( surface: &Surface, params: &FilterParams, @@ -1014,6 +1015,8 @@ fn apply_tiled_with_impl( ctl: &photocraft_raster::Interrupt, prepare_fast_paths: bool, ) -> Option { + // Every dispatch path needs a positive step, including Motion Blur's fallback. + let tile = tile.max(1); if ctl.cancelled() { return None; } diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 148a0723ec..06beabebbe 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() { assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 }); } +#[test] +fn nonpositive_tile_sizes_match_one_pixel_tiles() { + let area = Rect::new(0, 0, 4, 3); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = pattern(depth, area); + for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) { + let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area)); + for tile in [0, -64, i32::MIN] { + let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area)); + assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE) + .expect("a nonpositive tile size is normalized, not rejected"); + assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none()); + } + } + assert_eq!(source.read_region(area), pattern(depth, area).read_region(area)); + } +} + #[test] fn every_filter_is_tile_independent() { let s = pattern(SampleType::U8, R); From 5225b592fe98e1243906b5ae896826a4157cf725 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 22:57:14 +0200 Subject: [PATCH 0698/1452] Perspective Crop: draw the crop quad's side of the horizon (#7557) (#7558) When the crop quad's horizon crossed the layer and most of the layer (or its centre) lay beyond it, warp picked the source's majority side and skipped every output pixel, so the crop came out blank. A clip wholly on one side of the inverse horizon only ever samples that side, so draw it; a straddling clip keeps the majority rule (#4863). Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 23 +++++++++++++++++++++- crates/engine/src/perspective_crop_cmds.rs | 13 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 3e67f1cdee..91260bd285 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -333,7 +333,12 @@ fn warp( // A source straddling the horizon draws only its front side: the inverse w at a pixel has the // sign of the forward w at the source point it samples, and the other side would show as a // mirrored copy wrapped round infinity (#4863). - let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])); + // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn + // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557). + let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64))); + let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0))); + let front = + side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]))); let bounds = corner_bounds(&corners); let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. @@ -713,6 +718,22 @@ mod tests { assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum()); } + /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across + /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the + /// quad, so its side is the one drawn, not the source's majority. + #[test] + fn a_clip_wholly_on_one_side_draws_that_side() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]]; + let h = quad_to_rect(&q, 50.0, 39.0).unwrap(); + assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side"); + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap(); + assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 776b2b4d07..0cd1f01604 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -311,6 +311,19 @@ mod tests { assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); } + /// #7557: a quad low on a strongly converging photo (its horizon crosses the canvas, with the + /// canvas centre beyond it) is cropped, not left blank in the background colour. + #[test] + fn a_quad_below_a_horizon_across_the_canvas_keeps_its_content() { + let mut doc = Document::with_background("red", Size::new(100, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute(CMD, json!({"corners": [[40, 70], [60, 70], [90, 95], [10, 95]], "width": 50, "height": 39})).unwrap(); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(25, 20); + assert_eq!(px, [1.0, 0.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { From 92721dd8c4d71007a8266ea388d42805e6fbdd0b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:57:19 +0200 Subject: [PATCH 0699/1452] Refuse strict .atn export of plain events whose target is not the layer pixels (#7564) Fixes #7272 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 9 ++++++++- crates/engine/src/atn_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index a32256920a..d5c688f0a6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -881,11 +881,18 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| fields_to(p, $fields, desc("null")), + to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None }, } }; } +/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's +/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such +/// a step can't be exported faithfully. +fn pixel_target(p: &Value) -> bool { + p.get("target").is_none_or(|t| t.as_str() == Some("pixels")) +} + const INTERPOLATION: &[(&str, &str)] = &[ ("Bcbc", "bicubic"), ("Blnr", "bilinear"), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 335a3cbead..c4fe00eb95 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1068,3 +1068,24 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } + +/// A filter recorded against a layer mask must not export as a plain filter that edits the +/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. +#[test] +fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { + let masked = Action { + name: "Probe".into(), + steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], + ..Default::default() + }; + assert!(export("Audit", &[&masked], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); + for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { + let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); + } +} From f8e4a1344fc9923dd0ca4cbc9bc01a83107de650 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:24 +0300 Subject: [PATCH 0700/1452] Print: the last print's settings survive a relaunch, as in Photoshop (#7410) (#7526) * fix(print): the last print's settings are kept in the preferences, so Print opens with them after a relaunch (#7410) * test(print): the last print's settings survive a relaunch for Print and Print One Copy (#7410) --- crates/engine/src/print_cmds.rs | 36 +++++++++++++++++++++-- crates/engine/src/print_cmds/tests.rs | 42 +++++++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 22 ++++++++++++-- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 3cc99aa276..0a1701a166 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -654,7 +654,11 @@ fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { && page.label.as_deref().is_none_or(str::is_empty) } -/// Print One Copy repeats these. +/// Where the settings of the last print are kept in Preferences' remembered dialog settings. +pub const REMEMBERED: &str = "file.print"; + +/// Print One Copy repeats these, and Print opens with them. They are kept in the preferences as +/// well, so they survive a relaunch, as Photoshop's do (#7410). fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); if let Some(o) = remembered.as_object_mut() { @@ -663,9 +667,31 @@ fn remember_print(s: &mut Session, p: &Value) { // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); } + let kept = remembered.clone(); + s.prefs.edit(|prefs| { + prefs.dialogs.insert(REMEMBERED.into(), kept); + }); s.file_menu.last_print = Some(remembered); } +/// Drops the `printer` of the settings `q` (and its driver settings) when the print server lists +/// printers but not that one, so the job goes to the default printer. An empty list can't tell. +fn drop_vanished_printer(q: &mut Value, listed: &[&str]) { + let Some(name) = q.get("printer").and_then(Value::as_str).filter(|n| !n.is_empty()) else { return }; + if listed.is_empty() || listed.iter().any(|l| l.eq_ignore_ascii_case(name)) { + return; + } + if let Some(o) = q.as_object_mut() { + o.remove("printer"); + o.remove("printerSettings"); + } +} + +/// The settings of the last print: this session's, else those remembered from an earlier one. +pub fn last_print(s: &Session) -> Option { + s.file_menu.last_print.clone().or_else(|| s.prefs().dialogs.get(REMEMBERED).filter(|v| v.is_object()).cloned()) +} + /// The `printer` parameter, or the system's default printer. fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { @@ -788,7 +814,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } fn print_one_copy(s: &mut Session, p: &Value) -> Result { - let mut q = s.file_menu.last_print.clone().unwrap_or_else(|| json!({})); + let mut q = last_print(s).unwrap_or_else(|| json!({})); + // A printer remembered from an earlier session may be gone: then the default takes the job, + // as the Print dialog falls back (#7410). A list that can't be read decides nothing. + if let Ok(listed) = system_printers(s, &q) { + let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + drop_vanished_printer(&mut q, &names); + } if let (Some(o), Some(extra)) = (q.as_object_mut(), p.as_object()) { for (k, v) in extra { o.insert(k.clone(), v.clone()); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index ddd1a65a16..c54dddf6f8 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -226,6 +226,48 @@ fn printer_profile_can_be_an_installed_icc_file() { let _ = std::fs::remove_dir_all(&dir); } +/// #7410: the settings of the last print survive a relaunch, as Photoshop's do. They are kept in +/// the preferences (without the output, dry-run and send choices), and a new session with those +/// preferences, before printing anything, repeats them with Print One Copy. +#[test] +fn the_last_print_settings_survive_a_relaunch() { + let mut s = session("rgb", 8); + assert!(last_print(&s).is_none()); + s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50, "intent": "perceptual"})).unwrap(); + let kept = s.prefs().dialogs.get(REMEMBERED).cloned().expect("kept in the preferences"); + assert_eq!((&kept["paper"], &kept["scale"], &kept["intent"]), (&json!("a5"), &json!(50), &json!("perceptual"))); + assert!(kept.get("dryRun").is_none() && kept.get("output").is_none() && kept.get("send").is_none(), "{kept}"); + // A relaunch: a new session with these preferences, nothing printed in it yet. + let mut t = session("rgb", 8); + t.prefs.edit(|p| { + p.dialogs.insert(REMEMBERED.into(), kept.clone()); + }); + assert_eq!(last_print(&t), Some(kept)); + let r = t.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + assert_eq!(r["copies"], 1); + assert_eq!(r["paper"], json!([419.53, 595.28])); + assert_eq!(r["scale"], 50.0); +} + +/// #7410: a printer remembered from an earlier session that the print server no longer lists is +/// dropped before Print One Copy repeats the settings (with its driver settings), so the default +/// printer takes the job; a listed one (in any case) stays, and an empty list can't tell. +#[test] +fn a_vanished_remembered_printer_falls_back_to_the_default() { + let settings = || json!({"printer": "Office", "printerSettings": "00ff", "paper": "a5"}); + let mut q = settings(); + drop_vanished_printer(&mut q, &["Studio", "Lab"]); + assert_eq!(q, json!({"paper": "a5"}), "gone: the default printer takes it"); + for listed in [&["Studio", "office"][..], &[][..]] { + let mut q = settings(); + drop_vanished_printer(&mut q, listed); + assert_eq!(q, settings(), "{listed:?}"); + } + let mut q = json!({"printer": "", "paper": "a5"}); + drop_vanished_printer(&mut q, &["Studio"]); + assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); +} + #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..d1b2f0e551 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -76,7 +76,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V app.session.active()?; json!({"dialog": open_print(app)}) } - "file.printOneCopy" if app.session.file_menu.last_print.is_none() => { + "file.printOneCopy" if photocraft_engine::print_cmds::last_print(&app.session).is_none() => { app.session.active()?; json!({"dialog": open_print(app)}) } @@ -729,7 +729,8 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { // Asked once per opening: the Printer menu lists what the spooler knows now. let printers = app.session.execute("file.printers", json!({})).unwrap_or_default(); f.insert("__systemPrinters".into(), printers["printers"].clone()); - if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { + // The settings of the last print, also from an earlier session (#7410). + if let Some(Value::Object(m)) = photocraft_engine::print_cmds::last_print(&app.session) { f.extend(m); } for (k, v) in [ @@ -1452,6 +1453,23 @@ mod tests { assert!(params(f).get("__printers").is_none()); } + /// #7410: File › Print opens with the settings of the last print, also those remembered from + /// an earlier session (the preferences), before anything was printed in this one. + #[test] + fn print_dialog_opens_with_the_settings_remembered_from_an_earlier_session() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + photocraft_engine::print_cmds::REMEMBERED.into(), + json!({"paper": "a5", "scale": 50, "orientation": "landscape", "intent": "perceptual"}), + ); + }); + let id = open_print(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((&f["paper"], &f["scale"], &f["orientation"], &f["intent"]), (&json!("a5"), &json!(50), &json!("landscape"), &json!("perceptual"))); + assert_eq!(f["copies"], 1, "what wasn't remembered keeps its default"); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { From 3c24fec507bf4b862c136a229adec30baef29f34 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 21:57:49 +0100 Subject: [PATCH 0701/1452] fix(tiff): leave alpha channels to #7415, keep the tiffCompression option #7415 writes and reads every TIFF alpha and spot channel with Photoshop's channel resources, so this branch's single-alpha-channel handling would only make the two rework the same decode and encode paths. The codec, io, test and doc changes for it go back to main's versions; the tiffCompression save parameter, the CLI --tiff-compression flag and their test stay, and the format-parity TIFF row names the option. Co-Authored-By: Claude Opus 5.5 --- book/src/formats/raster-formats.md | 2 +- crates/codecs/src/codecs/tiff.rs | 29 ++------ crates/codecs/src/image.rs | 11 --- crates/codecs/tests/tiff_pages.rs | 30 +++----- crates/codecs/tests/tiff_photoshop.rs | 22 ------ crates/io/src/flat.rs | 94 ++++---------------------- crates/io/src/psd_import.rs | 2 +- crates/io/src/tiff_layers.rs | 10 +-- crates/io/tests/tiff_alpha_channels.rs | 80 ---------------------- crates/io/tests/tiff_layers.rs | 38 +++++------ docs/file-format-parity.md | 6 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 13 files changed, 56 insertions(+), 272 deletions(-) delete mode 100644 crates/io/tests/tiff_alpha_channels.rs diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7afb2b9bbf..2e65d51550 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. A TIFF holds one extra sample: the transparency, or, in a document without transparency, the first alpha channel. Photoshop writes an alpha channel as an *unspecified* extra sample (ExtraSamples 0); PhotoCraft opens one as a named alpha channel over an opaque image, flat or layered, and writes the first alpha channel back that way. Further alpha channels are not written, and the save names them. Lab documents are saved flat. TIFFs are written uncompressed unless a script asks: `"tiffCompression": "lzw"` or `"zip"` in the save, batch and export commands (`--tiff-compression` on the CLI). Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 7fceb00656..9528fec9c8 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,9 +249,7 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; - let alpha_is_channel = img.meta.alpha_is_channel; img.meta = metadata(&file, &dir, &mut img.icc); - img.meta.alpha_is_channel = alpha_is_channel; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -550,16 +548,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 4, _ => 1, }; - // With no extra sample marked as transparency, the first unspecified one (ExtraSamples 0) is - // decoded as the alpha and flagged as an alpha channel: Photoshop writes a document's alpha - // channel that way, and dropping it lost the channel (#5301). - let marked = extra.iter().position(|&e| e == 1 || e == 2); - let paletted = palette.is_some(); - let channel_alpha = marked.is_none() && !paletted && extra.first() == Some(&0); - let alpha_at = marked.or(channel_alpha.then_some(0)).filter(|&a| (color + a as u64) < spp); - // The `tiff` crate returns an RGB, CMYK or Lab alpha only when the first extra sample is marked - // as one, so an alpha channel (unspecified) is read through every sample too. - let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0 || channel_alpha); + let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -703,16 +693,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } - // Extra samples past the colour and the one decoded as the alpha are not imported. - let kept = out_layout.channels() as u64; - let skipped = if paletted { 0 } else { spp.saturating_sub(kept) }; - if skipped > 0 { - img.warnings.push(DecodeWarning::ExtraSamplesSkipped { count: u32::try_from(skipped).unwrap_or(u32::MAX) }); - } Ok(img) } @@ -1097,9 +1080,6 @@ struct TagSet<'a> { dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, alpha: bool, - /// The alpha is an alpha channel ([`Metadata::alpha_is_channel`]): written as an unspecified - /// extra sample (0) rather than unassociated alpha (2). - alpha_is_channel: bool, photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1130,8 +1110,8 @@ where } let d = im.encoder(); if tags.alpha { - // 2 = unassociated alpha (transparency); 0 = unspecified, Photoshop's alpha channel. - d.write_tag(Tag::ExtraSamples, &[if tags.alpha_is_channel { 0u16 } else { 2 }][..])?; + // 2 = unassociated alpha + d.write_tag(Tag::ExtraSamples, &[2u16][..])?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1253,7 +1233,6 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) } else { None }, text, alpha: img.layout().has_alpha(), - alpha_is_channel: img.layout().has_alpha() && img.meta.alpha_is_channel, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 47994d2fa4..5a03e7d283 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -145,11 +145,6 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, - /// The image's alpha is an alpha channel (a saved selection or texture mask), not - /// transparency: TIFF's "unspecified" extra sample (ExtraSamples 0), which Photoshop writes for - /// a document's alpha channel (#5301). Decoders set it; the TIFF encoder writes such an alpha as - /// an unspecified extra sample instead of as transparency. - pub alpha_is_channel: bool, } impl Metadata { @@ -160,7 +155,6 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() - && !self.alpha_is_channel } } @@ -187,9 +181,6 @@ pub enum DecodeWarning { /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, /// 255 when undefined). The code values are imported as stored, without a colour transform. DpxEncoding { transfer: u8, colorimetric: u8 }, - /// A TIFF's extra samples beyond the one decoded as the alpha (more alpha channels, or data - /// the file doesn't describe) were not imported. - ExtraSamplesSkipped { count: u32 }, } /// The SMPTE 268M name of a transfer characteristic or colorimetric code. @@ -240,8 +231,6 @@ impl fmt::Display for DecodeWarning { dpx_code_name(*transfer), dpx_code_name(*colorimetric) ), - DecodeWarning::ExtraSamplesSkipped { count: 1 } => write!(f, "one more extra channel in the TIFF was not imported"), - DecodeWarning::ExtraSamplesSkipped { count } => write!(f, "{count} more extra channels in the TIFF were not imported"), } } } diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index b1f87a94cd..60885e527d 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,16 +128,13 @@ fn planar_strips_at_every_depth() { } } -/// #5301: with no extra sample marked as transparency, the first unspecified one is decoded as -/// the alpha and flagged as an alpha channel (Photoshop's alpha channel). Extra samples beyond -/// the colour and the alpha are dropped, with a warning. #[test] -fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_dropped() { +fn extra_samples_that_are_not_alpha_are_dropped() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB and an alpha channel. + // RGB + one unspecified extra sample: RGB. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -145,10 +142,9 @@ fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_droppe d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, true), "planar {planar}"); - assert_eq!(img.data(), &rgb_of(4, 4)[..], "planar {planar}"); - assert_eq!(img.warnings, [], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA (transparency), the extra dropped. + assert_eq!(img.layout(), ChannelLayout::Rgb); + assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -156,23 +152,20 @@ fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_droppe d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(false, true, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, false), "planar {planar}"); + assert_eq!(img.layout(), ChannelLayout::Rgba); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "planar {planar}"); } - // Gray + alpha + extra: gray and alpha, the extra dropped; gray + an unspecified extra: gray - // and an alpha channel. + // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, false)); + assert_eq!(img.layout(), ChannelLayout::GrayA); assert_eq!(img.data(), &rgb_of(3, 2)[..]); - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }]); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, true)); - assert_eq!(img.data(), &rgb_of(2, 2)[..]); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.data(), &rgb_of(2, 1)[..]); } #[test] @@ -200,8 +193,7 @@ fn alpha_after_an_unspecified_extra_sample_is_kept() { let what = format!("photometric {photometric} little {little} big {big} bits {bits} planar {planar}"); assert_eq!((img.layout(), img.sample_type()), (layout, sample), "{what}"); assert_eq!(img.data(), &want[..], "{what}"); - // The unspecified sample before the alpha is the one left out. - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "{what}"); + assert_eq!(img.warnings, [], "{what}"); } } } diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 1daebcc9f4..400b72eb0c 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -172,25 +172,3 @@ fn malformed_tags_read_as_absent_without_panicking() { f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes()); assert_eq!(tiff_photoshop_tags(&f).1, None); } - -/// #5301: an alpha flagged as an alpha channel is written as an unspecified extra sample -/// (ExtraSamples 0, as Photoshop writes a document's alpha channel) and decodes back flagged, in -/// every layout with an alpha and both orders of decoding (chunky and the all-samples path); -/// transparency stays unassociated alpha (2). -#[test] -fn an_alpha_channel_round_trips_as_an_unspecified_extra_sample() { - for layout in [ChannelLayout::Rgba, ChannelLayout::GrayA, ChannelLayout::CmykA] { - for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] { - for channel in [true, false] { - let mut img = synth(13, 11, layout, SampleType::U8, 7, 0.4); - img.meta.alpha_is_channel = channel; - let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); - let back = decode(&bytes).unwrap(); - assert_eq!(back.layout(), layout, "{layout:?} {compression:?} channel {channel}"); - assert_eq!(back.meta.alpha_is_channel, channel, "{layout:?} {compression:?}"); - assert_eq!(back.data(), img.data(), "{layout:?} {compression:?} channel {channel}"); - assert!(back.warnings.is_empty(), "{:?}", back.warnings); - } - } - } -} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 163852b541..773e692729 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -38,7 +38,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } r.document.file_encoding = file_encoding_as(format, bytes); if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { - alpha_to_channel(&mut r.document, "Alpha 1")?; + tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear @@ -110,13 +110,11 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { } } -/// Moves a just-opened single layer's alpha into an alpha channel named `name`, leaving an -/// opaque Background. Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" -/// channel holding the fourth byte, even when the header declares no alpha bits, and keeps the -/// stored colours (#2225; measured on Photoshop 27.11). Layer transparency would hide those -/// colours and lose the channel on the next save, where only an alpha channel becomes the -/// Targa's alpha. A TIFF's unspecified extra sample is Photoshop's alpha channel too (#5301). -fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { +/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the +/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; +/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel +/// on the next save, where only an alpha channel becomes the Targa's alpha. +fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { let canvas = doc.bounds(); let depth = doc.depth; let Some(layer) = doc.layers.first_mut() else { return Ok(()) }; @@ -142,11 +140,11 @@ fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { layer.name = "Background".into(); layer.locks.transparency = true; layer.locks.position = true; - doc.channels.push(photocraft_doc::AlphaChannel::new(name, alpha)); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", alpha)); Ok(()) } -/// Undoes [`alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel +/// Undoes [`tga_alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel /// becomes the layer's transparency again. For image sequences, which treat a frame's alpha as /// transparency, as video footage does (Photoshop's Interpret Footage). Anything else is left /// alone. @@ -196,27 +194,7 @@ pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result } return Ok(r); } - // An unspecified extra sample is an alpha channel, not transparency (#5301): named as the - // TIFF's Photoshop resources name it (Unicode, else Pascal names), else "Alpha 1". - let channel_name = img.meta.alpha_is_channel.then(|| tiff_alpha_name(img.meta.photoshop_resources.as_deref())); - let mut r = image_to_document(name, &img.oriented(orientation)?)?; - if let Some(channel_name) = channel_name { - alpha_to_channel(&mut r.document, &channel_name)?; - } - Ok(r) -} - -/// The first alpha channel's name in a TIFF's Photoshop resources (1045 Unicode names, else -/// 1006 Pascal names), else "Alpha 1". -fn tiff_alpha_name(resources: Option<&[u8]>) -> String { - let resources = resources.and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()).map(|(r, _)| r).unwrap_or_default(); - let unicode = resources.iter().find(|r| r.id == 1045).and_then(|r| crate::psd_import::unicode_names(&r.data).into_iter().next()); - let pascal = || { - let data = &resources.iter().find(|r| r.id == 1006)?.data; - let n = usize::from(*data.first()?); - Some(String::from_utf8_lossy(data.get(1..1 + n)?).into_owned()) - }; - unicode.or_else(pascal).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Alpha 1".into()) + image_to_document(name, &img.oriented(orientation)?) } /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a @@ -601,39 +579,9 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti if format == Format::Tga { img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; } - if format == Format::Tiff { - img = tiff_image(doc, img, &mut warnings)?; - } encode_image(&img, format, opts, warnings) } -/// A flat TIFF's image with the document's alpha channel (#5301). Photoshop writes an alpha -/// channel as an unspecified extra sample; this writer holds one extra sample, so the first -/// alpha channel (spot channels don't count) is written when the document has no transparency, -/// named in the Photoshop resources so it opens with its name. With transparency, that is the -/// extra sample and the alpha channels are left out, as before; either way the ones left out are -/// named in a warning. -fn tiff_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { - let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); - let names = |cs: &[&AlphaChannel]| cs.iter().map(|c| format!("\"{}\"", c.name)).collect::>().join(", "); - let Some((first, rest)) = alphas.split_first() else { return Ok(img) }; - if img.layout().has_alpha() { - warnings.push(format!("the TIFF's one extra channel holds the transparency; alpha channels not saved: {}", names(&alphas))); - return Ok(img); - } - if !rest.is_empty() { - warnings.push(format!("the TIFF holds one alpha channel; not saved: {}", names(rest))); - } - let mut out = append_alpha_channel(doc, &img, first)?; - out.meta.alpha_is_channel = true; - let resources = [ - photocraft_psd::ImageResource::new(1006, crate::psd_export::pascal_names_resource(&[first.name.as_str()])), - photocraft_psd::ImageResource::new(1045, crate::psd_export::unicode_names_resource(&[first.name.as_str()])), - ]; - out.meta.photoshop_resources = photocraft_psd::tiff::resources_to_bytes(&resources).ok(); - Ok(out) -} - /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an /// alpha channel (spot channels don't count), else 24. pub fn tga_default_bits(doc: &Document) -> TgaBits { @@ -691,18 +639,6 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa } else { img }; - append_alpha_channel(doc, &img, channel) -} - -/// `img` (without alpha: RGB, gray or CMYK) with `channel`'s values added as its alpha. -fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> Result { - let out_layout = match img.layout() { - ChannelLayout::Rgb => ChannelLayout::Rgba, - ChannelLayout::Gray => ChannelLayout::GrayA, - ChannelLayout::Cmyk => ChannelLayout::CmykA, - other => return Err(IoError::Unsupported(format!("an alpha channel can't be added to a {other:?} image"))), - }; - let colors = img.layout().channels(); let (w, h) = img.dimensions(); let canvas = doc.bounds(); if canvas.width() != w || canvas.height() != h { @@ -712,22 +648,22 @@ fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> let row = img.data().len() / (h.max(1) as usize); let band = (BAND_BYTES / row.max(1)).max(1); let stride = channel.surface.channels().max(1); - let mut data = try_buffer(img.pixel_count(), out_layout.channels() * sample.bytes())?; + let mut data = try_buffer(img.pixel_count(), ChannelLayout::Rgba.channels() * sample.bytes())?; let mut y0 = canvas.y0; for rows in img.data().chunks(row.max(1) * band) { let n = (rows.len() / row.max(1)) as u32; let y1 = y0.saturating_add(i32::try_from(n).unwrap_or(i32::MAX)); let alpha = channel.surface.read_region(Rect::new(canvas.x0, y0, canvas.x1, y1)); - let color = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); - let mut vals = Vec::with_capacity(color.len() / colors.max(1) * (colors + 1)); - for (px, a) in color.chunks_exact(colors.max(1)).zip(alpha.chunks_exact(stride)) { + let rgb = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); + let mut vals = Vec::with_capacity(rgb.len() / 3 * 4); + for (px, a) in rgb.as_chunks::<3>().0.iter().zip(alpha.chunks_exact(stride)) { vals.extend_from_slice(px); vals.push(a.first().copied().unwrap_or(1.0)); } - data.extend_from_slice(Image::from_normalized(w, n, out_layout, sample, &vals)?.data()); + data.extend_from_slice(Image::from_normalized(w, n, ChannelLayout::Rgba, sample, &vals)?.data()); y0 = y1; } - Ok(Image::from_raw(w, h, out_layout, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) + Ok(Image::from_raw(w, h, ChannelLayout::Rgba, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) } /// Encodes a flat codec image as `format`: the fidelity warnings, then the codec. The end of diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 8a6449d6fa..c224659c64 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -603,7 +603,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -pub(crate) fn unicode_names(data: &[u8]) -> Vec { +fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 1cf67c5f45..628517b6f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -227,13 +227,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); - // Without transparency, the first alpha channel is the TIFF's extra sample, written as an - // unspecified one (Photoshop's alpha channel) and named by the resources above (#5301). - let channel_alpha = !has_alpha && usize::from(file.header.channels) > cc; - let channels = cc + usize::from(has_alpha || channel_alpha); + let channels = cc + usize::from(has_alpha); let extra = usize::from(file.header.channels).saturating_sub(channels); if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one extra channel only)")); + warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); } let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); @@ -241,7 +238,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha || channel_alpha); + let layout = layout_for(fmt.mode, has_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -254,7 +251,6 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), - alpha_is_channel: channel_alpha, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) diff --git a/crates/io/tests/tiff_alpha_channels.rs b/crates/io/tests/tiff_alpha_channels.rs deleted file mode 100644 index d44efc17c6..0000000000 --- a/crates/io/tests/tiff_alpha_channels.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! #5301: a TIFF's alpha channel. Photoshop writes a document's alpha channel as an -//! "unspecified" extra sample (ExtraSamples 0); it opens as an alpha channel over an opaque -//! image, not as transparency, and a document's alpha channel is written back that way. - -mod common; -use common::*; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{AlphaChannel, Document}; -use photocraft_geom::{Rect, Size}; -use photocraft_io::*; - -/// An opaque one-layer document of `mode` with the alpha channels `names`. -fn doc_with_channels(mode: ColorMode, names: &[&str]) -> Document { - let (w, h) = (40, 30); - let mut d = Document::new("t", Size::new(w, h), mode, SampleType::U8); - let canvas = Rect::new(0, 0, w as i32, h as i32); - d.layers.push(raster("Background", PixelFormat::new(mode, SampleType::U8, true), canvas, 3, false)); - for (i, name) in names.iter().enumerate() { - d.channels.push(AlphaChannel::new(*name, mask_surface(SampleType::U8, canvas, 0.0, 10 + i as u32))); - } - d -} - -fn channel_values(d: &Document, i: usize) -> Vec { - d.channels[i].surface.read_region(d.bounds()) -} - -fn tiff(doc: &Document, layers: bool) -> ExportResult { - export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") -} - -#[test] -fn a_flat_tiff_keeps_one_alpha_channel_with_its_name() { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { - let d = doc_with_channels(mode, &["Letters"]); - let r = tiff(&d, false); - assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{mode:?}: {:?}", r.warnings); - let img = photocraft_codecs::decode(&r.bytes).unwrap(); - assert!(img.meta.alpha_is_channel, "{mode:?}: written as an unspecified extra sample"); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.len(), 1, "{mode:?}"); - assert_eq!(back.channels[0].name, "Letters"); - assert_eq!(channel_values(&back, 0), channel_values(&d, 0), "{mode:?}: the channel's values"); - assert_eq!(back.layers.len(), 1); - assert_eq!(back.layers[0].name, "Background", "{mode:?}: opaque, not transparency"); - } -} - -#[test] -fn a_flat_tiff_names_the_alpha_channels_it_leaves_out() { - let d = doc_with_channels(ColorMode::Rgb, &["Kept", "Second", "Third"]); - let r = tiff(&d, false); - assert!(r.warnings.iter().any(|w| w.contains("one alpha channel") && w.contains("\"Second\", \"Third\"")), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Kept"]); -} - -#[test] -fn transparency_stays_the_extra_sample_and_the_channels_are_named_as_left_out() { - let mut d = doc_with_channels(ColorMode::Rgb, &["Letters"]); - d.layers[0] = raster("Layer 0", PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), Rect::new(5, 5, 20, 20), 3, true); - let r = tiff(&d, false); - assert!(r.warnings.iter().any(|w| w.contains("holds the transparency") && w.contains("\"Letters\"")), "{:?}", r.warnings); - let img = photocraft_codecs::decode(&r.bytes).unwrap(); - assert!(img.layout().has_alpha() && !img.meta.alpha_is_channel); - let back = import("x.tif", &r.bytes).unwrap().document; - assert!(back.channels.is_empty()); -} - -#[test] -fn a_layered_tiff_keeps_one_alpha_channel_with_its_name() { - let d = doc_with_channels(ColorMode::Rgb, &["Letters"]); - let r = tiff(&d, true); - assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Letters"]); - assert_eq!(channel_values(&back, 0), channel_values(&d, 0)); - // Another reader sees an opaque image with an alpha channel, as Photoshop wrote it. - assert!(photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel); -} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index d1dd39b8c4..56e51bf9f9 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -20,9 +20,7 @@ fn tags(bytes: &[u8]) -> (Option>, Option>) { (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) } -/// `doc` through a layered TIFF, and whether the TIFF kept its first alpha channel (it does -/// when the document has no transparency for the one extra sample, #5301). -fn roundtrip(doc: &Document) -> (Document, bool) { +fn roundtrip(doc: &Document) -> Document { let r = tiff(doc, true); let (resources, layers) = tags(&r.bytes); let layers = layers.expect("layer data"); @@ -36,19 +34,17 @@ fn roundtrip(doc: &Document) -> (Document, bool) { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one extra sample: the transparency, or else the first alpha channel. The - // other alpha channels (and a Quick Mask) are not kept, and the export says so. - let kept = flat.meta.alpha_is_channel; + // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not + // kept, and the export says so. let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > usize::from(kept), "{:?}", r.warnings); - (import("x.tif", &r.bytes).expect("import").document, kept) + assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); + import("x.tif", &r.bytes).expect("import").document } -/// `doc` as a layered TIFF round trip returns it: with its first alpha channel when the TIFF -/// kept it (`kept`), without the others or a Quick Mask. -fn expected(doc: &Document, kept: bool) -> Document { +/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. +fn expected(doc: &Document) -> Document { let mut d = doc.clone(); - d.channels.truncate(usize::from(kept)); + d.channels.clear(); d.quick_mask = None; d } @@ -58,8 +54,8 @@ macro_rules! rt_case { #[test] fn $name() { let d = gen_doc($mode, $depth, $f); - let (back, kept) = roundtrip(&d); - assert_docs_eq(&expected(&d, kept), &back); + let back = roundtrip(&d); + assert_docs_eq(&expected(&d), &back); } }; } @@ -82,8 +78,8 @@ fn tiff_and_psd_open_as_the_same_document() { let d = gen_doc(mode, depth, Features::ALL); let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); let from_psd = import("x.psd", &psd.bytes).unwrap().document; - let (from_tif, kept) = roundtrip(&d); - assert_docs_eq(&expected(&from_psd, kept), &from_tif); + let from_tif = roundtrip(&d); + assert_docs_eq(&expected(&from_psd), &from_tif); // The composite the TIFF carries is what the document looks like. let tif = tiff(&d, true); let flat = import( @@ -265,14 +261,12 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel, which a re-save keeps when the document has no - // transparency for the one extra sample (#5301). Re-saving keeps everything else; the - // file is then in the encoder's byte order. + // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps + // everything else; the file is then in the encoder's byte order. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); - let kept = photocraft_codecs::decode(&out.bytes).unwrap().meta.alpha_is_channel; let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d, kept), &back); + assert_docs_eq(&expected(d), &back); } } @@ -319,7 +313,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); - assert_docs_eq(&expected(&d, photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel), &back); + assert_docs_eq(&expected(&d), &back); } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63a92f6e81..3c3722a5e6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (TIFF alpha channels open and save, LZW/ZIP for scripted saves, #5301; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (TIFF compression for scripted saves, `tiffCompression`, #5301; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; one alpha channel opens and saves (an unspecified extra sample, #5301), LZW/ZIP by `tiffCompression` (FILE-215-7: alpha channels beyond the first, or with transparency, not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; LZW/ZIP for scripted saves by `tiffCompression` (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,7 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | A TIFF's alpha channel (Photoshop's unspecified extra sample) opens as a named alpha channel and the first alpha channel saves back; scripted saves take `tiffCompression` (#5301) | +| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 8bdbe86fe4..b320028936 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -106,7 +106,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 0777293cd7..d7e82c9aea 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -52,7 +52,7 @@ id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" status = "partial" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" [[item]] id = "FILE-215-8" From 8c6166849ae13734be40bf1e1fc92bcddc021d44 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:04:38 +0300 Subject: [PATCH 0702/1452] fix(menus): Alt held through a shortcut no longer puts the keyboard on the menu bar, so Enter reaches the dialog (#7481) --- crates/ui-egui/src/menu_keys.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 83fe21a550..d033d7aec6 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -208,7 +208,12 @@ impl Keys { } let events = ctx.input(|i| i.events.clone()); let mut tap = false; - for e in &events { + // The tap is judged on every event of the frame, also those another handler consumed + // before the bar's turn: the shortcuts run first, so Ctrl+Alt+I (Image Size) left no + // trace in `events`, and releasing Alt counted as a tap that put the keyboard on the bar; + // Enter then opened File instead of confirming the dialog (#7481). + let raw = ctx.input(|i| i.raw.events.clone()); + for e in &raw { match e { Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => { self.alt_down = true; From 2a9a35f038408cd3f57c06c755db8aedee040ea7 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:04:38 +0300 Subject: [PATCH 0703/1452] test(menus): Ctrl+Alt+I then Alt up leaves the bar alone and Enter confirms Image Size (#7481) --- crates/ui-egui/src/menu_keys_tests.rs | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 4954053578..36b3c58641 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -248,3 +248,54 @@ fn every_menu_row_starts_its_label_after_one_checkmark_column() { }); out.textures_delta.clear(); } + +/// The whole app (shortcuts, dialogs and the menu bar), for keys other handlers see first. +fn whole_app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(16).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app() + }); + h.run_steps(4); + h +} + +/// `key` pressed and released with `mods` held, as the platform reports a chord. +fn chord(h: &mut Harness<'static, PhotocraftApp>, key: egui::Key, mods: egui::Modifiers) { + h.event_modifiers(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }, mods); + h.event_modifiers(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: mods }, mods); + h.run_steps(2); +} + +fn alt_down(h: &mut Harness<'static, PhotocraftApp>, mods: egui::Modifiers) { + h.event_modifiers(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: mods }, mods); + h.run_steps(2); +} + +fn alt_up(h: &mut Harness<'static, PhotocraftApp>) { + h.event_modifiers( + egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Modifiers::NONE, + ); + h.run_steps(3); +} + +/// #7481: Alt held through a shortcut is no tap, even when the shortcut consumed its key before +/// the bar saw it. Ctrl+Alt+I opens Image Size; releasing Alt leaves the keyboard where it was, +/// so Enter confirms the dialog instead of opening File. A lone tap still puts it on the bar. +#[test] +fn alt_held_through_a_shortcut_does_not_put_the_keyboard_on_the_bar() { + let mut h = whole_app(); + alt_down(&mut h, egui::Modifiers::ALT); + chord(&mut h, egui::Key::I, egui::Modifiers::CTRL | egui::Modifiers::ALT | egui::Modifiers::COMMAND); + alt_up(&mut h); + assert_eq!(h.state().ui.dialogs.len(), 1, "Image Size opened"); + assert_eq!(Keys::current(&h.ctx).focus, None, "the bar did not take the keyboard"); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert!(!is_open(&h.ctx), "Enter opened no menu"); + assert!(h.state().ui.dialogs.is_empty(), "Enter confirmed Image Size"); + + alt_down(&mut h, egui::Modifiers::ALT); + alt_up(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "a lone tap still focuses the bar"); +} From 7e89c8e8164d5dae3065fbeac42378bb1f3f403a Mon Sep 17 00:00:00 2001 From: seotarek Date: Mon, 19 Oct 2026 00:06:39 +0300 Subject: [PATCH 0704/1452] fix(atn): carry pre-job layer selection and test explicit-null export --- crates/engine/src/atn_cmds/tests.rs | 5 +++++ crates/engine/src/jobs.rs | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 5433c69713..eb49e08363 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -972,6 +972,11 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let saved_target = s.execute("actions.save", json!({"action": "DeleteTarget"})).unwrap(); assert_eq!(saved_target["steps"], 1); + // Explicit null layer reference also exports successfully as target selection + s.actions.list.push(Action::new("DeleteNull", vec![("layer.delete".into(), json!({"layer": null}))])); + let saved_null = s.execute("actions.save", json!({"action": "DeleteNull"})).unwrap(); + assert_eq!(saved_null["steps"], 1); + // Explicit numeric ID reference cannot be silently converted to current target in strict export s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); let before_actions = s.actions.list.clone(); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 534e6941de..4f5b4833bd 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -212,6 +212,7 @@ struct Running { label: String, params: Value, journal: bool, + pre_selected: Option>, doc: Option, /// The document as the job saw it: applying fails if it was edited meanwhile. snapshot: Option>, @@ -376,6 +377,7 @@ pub fn run( label: label.to_string(), params: Value::Null, journal: true, + pre_selected: None, doc, snapshot, ctx, @@ -753,6 +755,7 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; + job.pre_selected = pre_selected.clone(); let jid = job.id; self.jobs.running.push(job); Ok(Started::Job(jid)) @@ -820,7 +823,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, pre_selected, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -855,7 +858,7 @@ impl Session { // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. if r.is_ok() { - self.after_command(command, params.clone(), *journal, Some(nested_from), None); + self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref()); } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. From e795cabc7981c1c76cd9b1705139b447ea972904 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 22:16:37 +0100 Subject: [PATCH 0705/1452] test(format): skip the bundle-lock test only where file locks are unsupported Co-Authored-By: Claude Opus 5.5 --- crates/format/tests/roundtrip.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 3f3275589f..6f70661591 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -738,7 +738,13 @@ fn directory_save_waits_for_the_bundle_lock() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("lock"); let other = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("save.lock")).unwrap(); - other.lock().unwrap(); + // A file system without file locks saves unlocked (`lock_bundle`), so there's nothing to test. + match other.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::Error(e)) if e.kind() == std::io::ErrorKind::Unsupported => return, + Err(std::fs::TryLockError::Error(e)) => panic!("locking save.lock failed: {e}"), + Err(std::fs::TryLockError::WouldBlock) => panic!("save.lock is already locked"), + } let mut w = PcraftWriter::new(); w.set_lock_wait(std::time::Duration::from_millis(100)); let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); From 9a167b2e8804b960e9c0232c54d7b286cf145b04 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 22:19:29 +0100 Subject: [PATCH 0706/1452] test(engine): a refused tiffCompression leaves the document as it was Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds/tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index eaf1f9ce24..bf6b3387e2 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1315,10 +1315,13 @@ fn save_a_copy_writes_the_tiff_compression_asked_for() { assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); } let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { let out = join(&dir, "bad.tif"); let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); } } From 987753e76eefd32c281dddf123508326b48755c7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 13:37:47 -0600 Subject: [PATCH 0707/1452] Move tool: moving selected pixels shrinks the selection to them, as in Photoshop (#7538) A marquee drawn loosely around an object on a transparent layer kept its rectangle when the Move tool dragged the pixels. Photoshop shrinks the marching ants to the pixels being moved as the drag starts: the selection times the layer's transparency. - Engine: the floating piece keeps its outline (`CutParts::outline`, the piece's alpha), and `select.drop` makes it the new selection. A mask, which has no transparency, and an empty piece keep the selection's shape. - Canvas: a floating piece's ants follow that outline during the drag. - Snapping and the move box use the moved pixels' bounds. Tests at 8, 16 and 32 bit, Alt-drag copies, soft selections over half-transparent pixels, the Background, a mask target and an empty piece. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/float_cmds.rs | 31 +++++++-- crates/engine/src/float_cmds/tests.rs | 90 +++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 14 ++++- crates/ui-egui/src/move_ui.rs | 22 +++++++ crates/ui-egui/src/snap_ui.rs | 38 ++++++++++- docs/ui-parity.md | 4 +- 6 files changed, 188 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index e5450b2339..8de2c3a7ff 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -12,11 +12,12 @@ //! which puts it back and then returns to the chosen state. //! //! While floating, the document is unchanged; [`displayed`] gives the document with the piece at -//! its offset, for the canvas. The selection stays where it was until the drop. +//! its offset, for the canvas. The selection stays where it was until the drop, when it becomes +//! the piece's outline ([`CutParts::outline`]); the canvas shows that outline from the start. use std::sync::Arc; -use photocraft_color::PixelFormat; +use photocraft_color::{ColorMode, PixelFormat}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -32,6 +33,10 @@ pub struct CutParts { pub layer: LayerId, rest: Surface, piece: Surface, + /// The selection the moved pixels take, unmoved: the selection limited to the layer's + /// transparency (Photoshop shrinks the marching ants to the piece as the drag starts, #7538). + /// `None` keeps the selection: a mask has no transparency, and an empty piece no outline. + outline: Option, /// The pieces are the layer's mask (`"target":"mask"`), not its pixels. mask: bool, } @@ -59,6 +64,7 @@ impl CutParts { // A mask has no alpha: it keeps its format, and the cut leaves background colour. let mut rest = if mask { surf.clone() } else { full.clone() }; let mut piece = Surface::new(with_alpha); + let mut outline = Surface::new(PixelFormat::new(ColorMode::Grayscale, sel.format().sample, false)); let fill = !copy && (mask || doc.is_background(layer)); // A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas. let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() }); @@ -86,10 +92,21 @@ impl CutParts { crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?; } rest.prune(); + if !mask { + // The piece's alpha is the selection times the layer's transparency. + outline.write_region(b, &pp.chunks_exact(n).map(|p| p[a]).collect::>()); + outline.prune(); + } piece.write_region(b, &pp); piece.prune(); } - Ok(Self { layer, rest, piece, mask }) + let outline = (!outline.content_bounds().is_empty()).then_some(outline); + Ok(Self { layer, rest, piece, outline, mask }) + } + + /// The selection the moved pixels take, where they were cut (`None`: the selection stays). + pub fn outline(&self) -> Option<&Surface> { + self.outline.as_ref() } /// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer. @@ -184,7 +201,8 @@ fn float(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": f.layer.0, "offset": [f.offset.0, f.offset.1]})) } -/// Drop the floating piece into its layer and move the selection with it (one history step). +/// Drop the floating piece into its layer and move the selection with it, shrunk to the piece's +/// outline (one history step). pub fn drop_floating(s: &mut Session) -> Result { let Some(st) = s.active_mut() else { return Ok(json!({"dropped": false})) }; let Some(f) = floating(st).cloned() else { @@ -198,7 +216,8 @@ pub fn drop_floating(s: &mut Session) -> Result { } s.edit("Move", |doc, _| { f.parts.place(doc, dx, dy)?; - doc.selection = doc.selection.as_ref().map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); + let sel = f.parts.outline().or(doc.selection.as_ref()); + doc.selection = sel.map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); Ok(()) })?; Ok(json!({"dropped": true, "offset": [dx, dy]})) @@ -251,7 +270,7 @@ pub fn specs() -> Vec { label: "Drop Floating Selection", menu: &[], shortcut: None, - params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it: one history step)"##, + params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it, shrunk to the moved pixels: the selection times the layer's transparency, as in Photoshop; a mask or an empty piece keeps the selection's shape; one history step)"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document".into()), journal: true, run: |s, _| drop_floating(s), diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index bb2ba5b91c..bb75eccf4e 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -189,3 +189,93 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour"); assert_eq!(mask_at(&s, 40, 12), 0.0); } + +/// Coverage of `d`'s selection at (x, y). +fn selected(d: &Document, x: i32, y: i32) -> f32 { + d.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) +} + +/// #7538: a marquee drawn loosely around an object on a transparent layer shrinks to the object +/// once its pixels move, as in Photoshop, at every depth and when moving a copy. +#[test] +fn moving_shrinks_the_selection_to_the_moved_pixels_at_every_depth() { + for depth in [8, 16, 32] { + for copy in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + // A marquee with empty space all round the red square. + s.execute("select.rect", json!({"x": 4, "y": 6, "width": 34, "height": 30})).unwrap(); + s.execute("select.float", json!({"dx": 20, "dy": 5, "copy": copy})).unwrap(); + let st = s.active().unwrap(); + let outline = floating(st).unwrap().parts.outline().unwrap(); + assert_eq!(outline.content_bounds(), Rect::new(10, 10, 30, 30), "{depth}-bit, copy {copy}: the outline while floating"); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), Rect::new(4, 6, 38, 36), "the document's is untouched"); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(30, 15, 50, 35), "{depth}-bit, copy {copy}"); + assert_eq!((selected(doc, 40, 25), selected(doc, 26, 13)), (1.0, 0.0), "{depth}-bit, copy {copy}: the moved square only"); + // Undo brings back the marquee as it was drawn. + s.undo(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(4, 6, 38, 36), "{depth}-bit, copy {copy}"); + } + } +} + +/// The new selection is the old one times the layer's transparency: a soft selection over +/// half-transparent pixels keeps both (Photoshop's selection ∩ transparency). +#[test] +fn the_outline_keeps_soft_edges_and_partial_transparency() { + let (mut s, id) = session(); + s.edit("soften", |doc, _| { + // Half-transparent red over (30..40, 10..30), beside the opaque (10..30)² square. + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(30, 10, 40, 30), &[1.0, 0.0, 0.0, 0.5]); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 60, 50), &[0.5]); + sel.fill_rect(Rect::new(10, 10, 20, 30), &[1.0]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let near = |x: i32, y: i32, want: f32| assert!((selected(doc, x, y) - want).abs() < 0.01, "({x}, {y}): {} for {want}", selected(doc, x, y)); + near(25, 20, 1.0); // fully selected, opaque + near(35, 20, 0.5); // half selected, opaque + near(45, 20, 0.25); // half selected, half transparent + near(5, 5, 0.0); // selected, but nothing there + assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 10, 50, 30)); +} + +/// Where the selection is the moved piece already (the Background, which has no transparency) +/// or the piece has no outline (a mask, or only transparent pixels), the selection just moves. +#[test] +fn the_selection_just_moves_without_transparency_to_shrink_to() { + // The Background. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 5, 30, 15), "the Background"); + // Only transparent pixels selected. + let (mut s, _) = session(); + s.execute("select.rect", json!({"x": 40, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + assert!(floating(s.active().unwrap()).unwrap().parts.outline().is_none()); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(45, 5, 55, 15), "an empty piece"); + // The mask: it has no transparency of its own. + let (mut s, id) = session(); + s.edit("mask", |doc, _| { + doc.layer_mut(id).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all()); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 40, "height": 40})).unwrap(); + s.execute("select.float", json!({"dx": 5, "dy": 0, "target": "mask"})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(10, 5, 50, 45), "the mask"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d5fbe1b81..d5501fbc5c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3224,8 +3224,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Select and Mask outlines its refined selection (on its preview proxy, reduced by `k`) in // the Marching Ants view and hides the ants in the others. let refined = crate::select_mask_view::ants(app, idx); + // A floating piece's ants hug the pixels being moved. + let floated = floating_outline(app, idx); let (outlined, k) = match &refined { - None => (doc.selection.as_ref(), 1), + None => (floated.as_ref().or(doc.selection.as_ref()), 1), Some(None) => (None, 1), Some(Some((proxy, k))) => (proxy.selection.as_ref(), (*k).max(1)), }; @@ -5760,6 +5762,16 @@ pub(crate) fn selection_drag_delta(app: &PhotocraftApp) -> Option<(i32, i32)> { Some(((end[0] - d.start[0]).round().clamp(-1e7, 1e7) as i32, (end[1] - d.start[1]).round().clamp(-1e7, 1e7) as i32)) } +/// The selection a floating piece on document `idx` shows its ants for, before its offset: the +/// moved pixels' outline, as in Photoshop (#7538); `None` outlines the document's selection. The +/// surface's tiles are shared, so the clone is cheap. +pub(crate) fn floating_outline(app: &PhotocraftApp, idx: usize) -> Option { + if app.session.active_index() != Some(idx) { + return None; + } + photocraft_engine::float_cmds::floating(app.session.active()?)?.parts.outline().cloned() +} + /// Where the selection is drawn relative to where it is: a floating piece's offset plus a drag /// in progress. fn selection_shown_offset(app: &PhotocraftApp) -> Option<(i32, i32)> { diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index fb49b02474..347151660b 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -478,6 +478,28 @@ mod tests { } } + /// #7538: a loose marquee round the red square shrinks to it as the drag starts: the canvas + /// outlines the moved pixels while they float, and the dropped selection is theirs. Alt-drag + /// (a copy) too. + #[test] + fn dragging_selected_pixels_shrinks_the_ants_to_them() { + for depth in [8, 16, 32] { + for m in [egui::Modifiers::NONE, egui::Modifiers::ALT] { + let (mut app, _) = selected_pixels(depth); + app.session.execute("select.rect", json!({"x": 2, "y": 2, "width": 30, "height": 30})).unwrap(); + drag(&mut app, [16.0, 16.0], [26.0, 21.0], m); + assert_eq!(offset(&app), Some((10, 5)), "{depth}-bit, {m:?}: floating"); + let ants = crate::canvas::floating_outline(&app, 0).map(|s| s.content_bounds()); + assert_eq!(ants, Some(Rect::new(8, 8, 24, 24)), "{depth}-bit, {m:?}: the ants hug the red square"); + assert!(crate::canvas::floating_outline(&app, 1).is_none(), "only the active document's"); + app.run("select.drop", json!({})).unwrap(); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert_eq!(sel, Some(Rect::new(18, 13, 34, 29)), "{depth}-bit, {m:?}: dropped"); + assert!(crate::canvas::floating_outline(&app, 0).is_none(), "nothing floats"); + } + } + } + /// ⌘-drag with a painting tool drags the selected pixels as the Move tool does, also when ⌘ /// comes with the event (control channel, MCP) rather than as the held-key override. #[test] diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..f8d9c0bbf6 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -101,11 +101,26 @@ fn moving_rect(app: &PhotocraftApp, tool: Tool) -> Option<[f64; 4]> { union(st.selected_layers().into_iter().filter_map(|id| layer_rect(&st.doc, id))) } -/// The selection's bounds where it is shown: moved by its floating piece's offset. +/// The bounds of the pixels a selection moves, where they are shown (#7538): its floating piece's +/// outline at its offset. Before a drag first cuts the piece, the selection's bounds over the +/// active layer's pixels, which hold the outline (exactly when the pixels lie in the selection). +/// A targeted mask, an opaque layer or no pixels selected: the selection's own bounds. fn selection_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { let st = app.session.active()?; - let (dx, dy) = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset); - let r = st.doc.selection.as_ref()?.content_bounds().translate(dx, dy); + let sel = st.doc.selection.as_ref()?; + let r = match photocraft_engine::float_cmds::floating(st) { + Some(f) => f.parts.outline().unwrap_or(sel).content_bounds().translate(f.offset.0, f.offset.1), + None => { + let b = sel.content_bounds(); + let pixels = st + .active_layer + .filter(|&id| !st.doc.is_background(id) && crate::canvas::paint_target(app) == "pixels") + .and_then(|id| st.doc.layer(id)?.surface()) + .filter(|s| s.format().alpha) + .map(|s| b.intersect(&s.content_bounds())); + pixels.filter(|p| !p.is_empty()).unwrap_or(b) + } + }; (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from)) } @@ -555,6 +570,23 @@ mod tests { app } + /// #7538: moving selected pixels snaps (and frames) the pixels, not a loose marquee round them: + /// the box hugs the moved pixels before the first drag cuts them and while they float. + #[test] + fn moving_selected_pixels_snaps_their_bounds_not_the_marquee() { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + assert_eq!(moving_rect(&app, Tool::Move), Some([40.0, 40.0, 90.0, 80.0]), "before the cut"); + app.run("select.float", json!({"dx": 10, "dy": 5})).unwrap(); + assert_eq!(moving_rect(&app, Tool::Move), Some([50.0, 45.0, 100.0, 85.0]), "floating"); + app.run("select.drop", json!({})).unwrap(); + // The Background has no transparency to shrink to. + app.run("layer.select", json!({"name": "Background"})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + assert_eq!(moving_rect(&app, Tool::Move), Some([20.0, 20.0, 120.0, 110.0]), "the Background"); + } + fn mover_bounds(app: &PhotocraftApp) -> photocraft_geom::Rect { let st = app.session.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..b5f960e50c 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Move tool: dragged selected pixels take their own outline as the selection, #7538; 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) · **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, @@ -94,6 +94,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Arrow nudge of path points with Direct Selection | missing | no arrow handling in `direct_select.rs` | | ⌘-arrow nudges the layer from any tool | missing | #2474 | | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | +| Dragging selected pixels shrinks the selection to them (the selection times the layer's transparency) as the drag starts, also with ⌥; the move box and snapping use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline`, `snap_ui.rs` `selection_rect`, engine `float_cmds.rs` `CutParts::outline` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | | Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | @@ -257,3 +258,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 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) | From 5c7ca36f0775aae0dc5b5c3443617f20ee510951 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 14:17:29 -0600 Subject: [PATCH 0708/1452] Move tool: transform controls and snapping frame the moved pixels too (#7538) After a drag of selected pixels, Show Transform Controls still framed the selection they were cut with, shifted by the drag, while the ants hugged the pixels. Photoshop frames the pixels. - The box frames a floating piece's outline (`transform_controls_rect`). - Snapping starts before the press cuts the pixels, so the cut now retargets the move's box to their outline (`snap_ui::follow_cut`), for the Move tool and a selection tool's Cmd-drag. This replaces an estimate from the layer's bounds, which failed when the layer held other shapes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 30 ++++++++++++++++ crates/ui-egui/src/move_ui.rs | 5 ++- crates/ui-egui/src/snap_ui.rs | 67 +++++++++++++++++++++++------------ docs/ui-parity.md | 2 +- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d5501fbc5c..d2a1b0a4e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4838,6 +4838,9 @@ fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option .map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)) .filter(|b| !b.is_empty()) .reduce(|a, b| a.union(&b))?; + // Floating selected pixels are framed by their outline, as their ants are (#7538), not by the + // selection they were cut with. + let b = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds()); let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -5478,6 +5481,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) app.ui.status_error = true; return; } + crate::snap_ui::follow_cut(app); let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false); d.sel_move = Some(cut); app.drag = Some(d); @@ -8380,6 +8384,32 @@ mod transform_controls_tests { use super::*; use serde_json::json; + /// #7538: as in Photoshop, the box frames a loose selection until its pixels are moved, then + /// the pixels themselves, while they float and once dropped. + #[test] + fn transform_controls_frame_the_moved_pixels_not_the_marquee() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + app.session.execute("select.rect", json!({"x": 40, "y": 40, "width": 50, "height": 40})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let frame = |app: &PhotocraftApp| { + let r = transform_controls_rect(app, &xf).unwrap(); + let (lt, rb) = (xf.to_doc(r.left_top()), xf.to_doc(r.right_bottom())); + [lt[0], lt[1], rb[0], rb[1]].map(|v| v.round() as i32) + }; + assert_eq!(frame(&app), [20, 20, 120, 110], "the selection, before anything moves"); + app.run("select.float", json!({"dx": 10, "dy": 5})).unwrap(); + assert_eq!(frame(&app), [50, 45, 100, 85], "the floating pixels"); + app.run("select.drop", json!({})).unwrap(); + assert_eq!(frame(&app), [50, 45, 100, 85], "dropped"); + } + #[test] fn several_selected_layers_share_one_frame_without_handles() { // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 347151660b..86e5675214 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -39,7 +39,10 @@ pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); let dropped = if copy && floating { app.run("select.drop", json!({})) } else { Ok(json!(null)) }; match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy}))) { - Ok(_) => true, + Ok(_) => { + crate::snap_ui::follow_cut(app); + true + } Err(e) => { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index f8d9c0bbf6..6c3717960a 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -101,29 +101,30 @@ fn moving_rect(app: &PhotocraftApp, tool: Tool) -> Option<[f64; 4]> { union(st.selected_layers().into_iter().filter_map(|id| layer_rect(&st.doc, id))) } -/// The bounds of the pixels a selection moves, where they are shown (#7538): its floating piece's -/// outline at its offset. Before a drag first cuts the piece, the selection's bounds over the -/// active layer's pixels, which hold the outline (exactly when the pixels lie in the selection). -/// A targeted mask, an opaque layer or no pixels selected: the selection's own bounds. +/// The bounds of the pixels a selection moves, where they are shown: its floating piece's outline +/// at its offset (#7538), else (nothing cut yet, or a mask or empty piece) the selection's bounds. fn selection_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { let st = app.session.active()?; let sel = st.doc.selection.as_ref()?; let r = match photocraft_engine::float_cmds::floating(st) { Some(f) => f.parts.outline().unwrap_or(sel).content_bounds().translate(f.offset.0, f.offset.1), - None => { - let b = sel.content_bounds(); - let pixels = st - .active_layer - .filter(|&id| !st.doc.is_background(id) && crate::canvas::paint_target(app) == "pixels") - .and_then(|id| st.doc.layer(id)?.surface()) - .filter(|s| s.format().alpha) - .map(|s| b.intersect(&s.content_bounds())); - pixels.filter(|p| !p.is_empty()).unwrap_or(b) - } + None => sel.content_bounds(), }; (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from)) } +/// A press cut the selected pixels after snapping began (`filter_event` runs before the tools): +/// the move now snaps the cut piece's outline, not the selection it was cut with (#7538). +pub(crate) fn follow_cut(app: &mut PhotocraftApp) { + let Some(rect) = selection_rect(app) else { return }; + if let Some(snap) = app.prefs_rt.snap.as_mut() { + match &mut snap.gesture { + Gesture::Move { rect: r } | Gesture::SelectionMove { rect: r } => *r = rect, + _ => {} + } + } +} + fn is_point_tool(t: Tool) -> bool { matches!( t, @@ -570,21 +571,41 @@ mod tests { app } - /// #7538: moving selected pixels snaps (and frames) the pixels, not a loose marquee round them: - /// the box hugs the moved pixels before the first drag cuts them and while they float. + /// #7538: moving selected pixels snaps (and frames) the pixels, not a loose marquee round them. + /// Snapping starts before the press cuts them, so the cut retargets it to their outline. #[test] fn moving_selected_pixels_snaps_their_bounds_not_the_marquee() { + for alt in [false, true] { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + let m = egui::Modifiers { alt, ..Default::default() }; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [40.0, 40.0, 90.0, 80.0] }), "alt {alt}: the blue box, not the marquee"); + // 263 px right puts the box's left edge 3 px from the target's (the marquee's 20 px off). + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 323.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + app.run("select.drop", json!({})).unwrap(); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds().x0); + assert_eq!(sel, Some(300), "alt {alt}: snapped to the target's edge"); + } + // A marquee's ⌘-drag cuts them the same way. + let mut app = app_with_box(); + app.ui.tool = Tool::RectMarquee; + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + let cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, cmd); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::SelectionMove { rect: [40.0, 40.0, 90.0, 80.0] }), "⌘-drag with the marquee"); + // The Background has no transparency to shrink to. let mut app = app_with_box(); app.ui.tool = Tool::Move; - app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); - assert_eq!(moving_rect(&app, Tool::Move), Some([40.0, 40.0, 90.0, 80.0]), "before the cut"); - app.run("select.float", json!({"dx": 10, "dy": 5})).unwrap(); - assert_eq!(moving_rect(&app, Tool::Move), Some([50.0, 45.0, 100.0, 85.0]), "floating"); - app.run("select.drop", json!({})).unwrap(); - // The Background has no transparency to shrink to. app.run("layer.select", json!({"name": "Background"})).unwrap(); app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); - assert_eq!(moving_rect(&app, Tool::Move), Some([20.0, 20.0, 120.0, 110.0]), "the Background"); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, egui::Modifiers::NONE); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 110.0] }), "the Background"); } fn mover_bounds(app: &PhotocraftApp) -> photocraft_geom::Rect { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b5f960e50c..1068cc8125 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -94,7 +94,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Arrow nudge of path points with Direct Selection | missing | no arrow handling in `direct_select.rs` | | ⌘-arrow nudges the layer from any tool | missing | #2474 | | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | -| Dragging selected pixels shrinks the selection to them (the selection times the layer's transparency) as the drag starts, also with ⌥; the move box and snapping use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline`, `snap_ui.rs` `selection_rect`, engine `float_cmds.rs` `CutParts::outline` | +| Dragging selected pixels shrinks the selection to them (the selection times the layer's transparency) as the drag starts, also with ⌥; the move box, snapping and Show Transform Controls use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline` and `transform_controls_rect`, `snap_ui.rs` `follow_cut`, engine `float_cmds.rs` `CutParts::outline` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | | Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | From cb85f77f5a15245fc59c9894aafbbca61f6a56b0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 14:40:27 -0600 Subject: [PATCH 0709/1452] Move tool: moving pixels again leaves no faint outline behind (#7538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shrunk selection took the moved pixels' alpha as its coverage, so anti-aliased edges were only partly selected. Once the move was dropped (changing layers, say), moving them again lifted a × a of each edge pixel and left a × (1 − a) behind: up to 25 %, a faint outline where the shape had been. The selection now keeps its own strength wherever the layer has pixels, so a later move lifts edge pixels whole. Tested at 8, 16 and 32 bit: nothing is left behind and the shape's total alpha is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/float_cmds.rs | 18 ++++++++---- crates/engine/src/float_cmds/tests.rs | 42 ++++++++++++++++++++++++--- docs/ui-parity.md | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 8de2c3a7ff..323c0bee97 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -33,9 +33,9 @@ pub struct CutParts { pub layer: LayerId, rest: Surface, piece: Surface, - /// The selection the moved pixels take, unmoved: the selection limited to the layer's - /// transparency (Photoshop shrinks the marching ants to the piece as the drag starts, #7538). - /// `None` keeps the selection: a mask has no transparency, and an empty piece no outline. + /// The selection the moved pixels take, unmoved: the selection limited to where the layer has + /// pixels (Photoshop shrinks the marching ants to the piece as the drag starts, #7538). `None` + /// keeps the selection: a mask has no transparency, and an empty piece no outline. outline: Option, /// The pieces are the layer's mask (`"target":"mask"`), not its pixels. mask: bool, @@ -74,8 +74,15 @@ impl CutParts { let mut rp = full.read_region(b); let mut pp = rp.clone(); let w = b.width() as usize; + let mut cover = Vec::with_capacity(if mask { 0 } else { w * b.height() as usize }); for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0); + if !mask { + // Where the layer has pixels, the selection keeps its strength: shrinking it to + // their alpha would leave half-transparent edges partly behind on the next + // move, a faint outline (#7538). + cover.push(if p[a] > 0.0 { k.max(0.0) } else { 0.0 }); + } if k <= 0.0 { p.fill(0.0); } else { @@ -93,8 +100,7 @@ impl CutParts { } rest.prune(); if !mask { - // The piece's alpha is the selection times the layer's transparency. - outline.write_region(b, &pp.chunks_exact(n).map(|p| p[a]).collect::>()); + outline.write_region(b, &cover); outline.prune(); } piece.write_region(b, &pp); @@ -270,7 +276,7 @@ pub fn specs() -> Vec { label: "Drop Floating Selection", menu: &[], shortcut: None, - params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it, shrunk to the moved pixels: the selection times the layer's transparency, as in Photoshop; a mask or an empty piece keeps the selection's shape; one history step)"##, + params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it, shrunk to the moved pixels: the selection limited to where the layer has pixels, as in Photoshop; a mask or an empty piece keeps the selection's shape; one history step)"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document".into()), journal: true, run: |s, _| drop_floating(s), diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index bb75eccf4e..f9a7e85728 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -223,10 +223,10 @@ fn moving_shrinks_the_selection_to_the_moved_pixels_at_every_depth() { } } -/// The new selection is the old one times the layer's transparency: a soft selection over -/// half-transparent pixels keeps both (Photoshop's selection ∩ transparency). +/// The new selection is the old one where the layer has pixels: a soft selection keeps its soft +/// edge, and half-transparent pixels stay as selected as they were, so they move whole next time. #[test] -fn the_outline_keeps_soft_edges_and_partial_transparency() { +fn the_outline_keeps_soft_edges_and_selects_half_transparent_pixels_as_before() { let (mut s, id) = session(); s.edit("soften", |doc, _| { // Half-transparent red over (30..40, 10..30), beside the opaque (10..30)² square. @@ -244,7 +244,7 @@ fn the_outline_keeps_soft_edges_and_partial_transparency() { let near = |x: i32, y: i32, want: f32| assert!((selected(doc, x, y) - want).abs() < 0.01, "({x}, {y}): {} for {want}", selected(doc, x, y)); near(25, 20, 1.0); // fully selected, opaque near(35, 20, 0.5); // half selected, opaque - near(45, 20, 0.25); // half selected, half transparent + near(45, 20, 0.5); // half selected, half transparent: as selected as before near(5, 5, 0.0); // selected, but nothing there assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 10, 50, 30)); } @@ -279,3 +279,37 @@ fn the_selection_just_moves_without_transparency_to_shrink_to() { s.execute("select.drop", json!({})).unwrap(); assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(10, 5, 50, 45), "the mask"); } + +/// #7538: an anti-aliased shape moved, dropped, then moved again from its shrunk selection leaves +/// nothing behind. With the selection shrunk to the pixels' alpha, the second move left +/// a × (1 − a) of each edge pixel (up to 25 %), a faint outline where the shape had been. +#[test] +fn moving_again_from_the_shrunk_selection_leaves_no_edge_behind() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 120, "height": 100, "background": "transparent", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 50, "height": 40, "ellipse": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let total = |doc: &Document| (0..100).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| alpha(doc, id, x, y)).sum::(); + let (before, soft) = { + let doc = &s.active().unwrap().doc; + (total(doc), (0..100).flat_map(|y| (0..120).map(move |x| (x, y))).filter(|&(x, y)| (0.01..0.99).contains(&alpha(doc, id, x, y))).count()) + }; + assert!(soft > 20, "{depth}-bit: the ellipse has anti-aliased edges ({soft})"); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 70, "height": 60})).unwrap(); + s.execute("select.float", json!({"dx": 40, "dy": 30})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + // The ellipse now sits at (60..110, 50..90); move it back where it started. + s.execute("select.float", json!({"dx": -40, "dy": -30})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let leftover = (50..100) + .flat_map(|y| (55..115).map(move |x| (x, y))) + .filter(|&(x, y)| x >= 72 || y >= 62) + .map(|(x, y)| alpha(doc, id, x, y)) + .fold(0.0f32, f32::max); + assert_eq!(leftover, 0.0, "{depth}-bit: nothing left where it was moved from"); + assert!((total(doc) - before).abs() < 0.05, "{depth}-bit: every edge pixel came back whole ({} vs {before})", total(doc)); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1068cc8125..0082044004 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -94,7 +94,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Arrow nudge of path points with Direct Selection | missing | no arrow handling in `direct_select.rs` | | ⌘-arrow nudges the layer from any tool | missing | #2474 | | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | -| Dragging selected pixels shrinks the selection to them (the selection times the layer's transparency) as the drag starts, also with ⌥; the move box, snapping and Show Transform Controls use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline` and `transform_controls_rect`, `snap_ui.rs` `follow_cut`, engine `float_cmds.rs` `CutParts::outline` | +| Dragging selected pixels shrinks the selection to them (the selection limited to where the layer has pixels, so moving again leaves no edge behind) as the drag starts, also with ⌥; the move box, snapping and Show Transform Controls use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline` and `transform_controls_rect`, `snap_ui.rs` `follow_cut`, engine `float_cmds.rs` `CutParts::outline` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | | Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | From bbfeae98e4a4370f9e7f50e0a1a447a3b687ce52 Mon Sep 17 00:00:00 2001 From: Julien <15385135+jlange91@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:05 +0200 Subject: [PATCH 0710/1452] Type: right-click on committed text opens the type layer menu (#7324) (#7430) * Type: right-click on committed text opens the type layer menu (#7324) With the Type tool, a right-click only opened a menu while text was being edited; over committed type nothing opened. The menu was keyed on the edit session alone (`editing_text`). Over committed type, a Type tool (not a Type Mask tool) now opens TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows (Undo, Cut, Copy, Paste, Select All), which would act on the document, and without Faux Bold/Italic, which act on the characters being edited. Type outside the selected layers becomes the target first, as a click with the Type tool makes it. The control channel's right button takes the same path. Co-Authored-By: Claude Opus 5.5 * Type layer menu: the clicked type becomes the active layer in a multiselection (#7324) The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the paragraph/point conversions) act on the active layer. When the right-clicked type was already selected but another selected type layer was active, they acted on that other layer. Make the clicked type the active layer while keeping the selection (layer_multi_cmds::set_selection), so the formatting rows still act on every selected type layer. Found by CodeRabbit on #7430. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas_menu_tests.rs | 16 ++ crates/ui-egui/src/canvas_tool_menu.rs | 183 ++++++++++++++++++++- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/transform_type_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 2 +- docs/context-menu-parity.md | 5 +- docs/control-protocol.md | 2 +- docs/scorecard.md | 2 +- scorecard/ui.toml | 2 +- 9 files changed, 204 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index f1bbbe8d0c..839dc1f765 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -248,6 +248,22 @@ fn control_channel_opening_uses_the_same_dismissal_guard() { } } +/// #7324: an agent's right-click on committed type opens the type layer menu, as on the canvas. +#[test] +fn control_channel_right_click_on_committed_type_opens_the_type_layer_menu() { + let mut h = harness(Tool::Type, false, 1.0); + h.state_mut().run("type.create", json!({"text": "Hello", "size": 72, "x": 100, "y": 180})).unwrap(); + h.run_steps(3); + let at = fractional_point(&h, 1.0); + let before = document_state(&h); + control_tap(&mut h, at, Popup::Tool); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.type_layer && !menu.text); + assert!(h.state().ui.text_edit.is_none()); + // The new type is already the active layer, so the menu selects nothing either. + assert!(document_state(&h) == before, "opening the menu changes nothing"); +} + #[test] fn brush_picker_taps_preserve_pixels_selection_and_history_after_reopening() { for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index a02859c5cd..f7f9892d2f 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,6 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free -//! Transform box, text being edited with a Type tool, the pasteboard (outside the image) and a guide. +//! Transform box, text being edited with a Type tool, committed type under a Type tool, the +//! pasteboard (outside the image) and a guide. use egui::Context; use photocraft_doc::LayerContent; @@ -31,6 +32,9 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// Opened with a Type tool over committed type (no edit running): the type layer rows. + #[serde(default)] + pub type_layer: bool, /// Opened on a guide: the guide menu (`guide_select`) instead of the tool's actions. #[serde(default)] pub guide: bool, @@ -137,6 +141,33 @@ pub const TYPE_EDIT_MENU: &[Row] = &[ Some(("Warp Text…", "type.warpText")), ]; +/// Right-click with a Type tool over type that isn't being edited (#7324): the type edit menu's +/// layer rows. Its edit rows would act on the document instead of the text, and Faux Bold/Italic +/// on characters being edited, so they are left out. +pub const TYPE_LAYER_MENU: &[Row] = &[ + Some(("Check Spelling…", "edit.checkSpelling")), + Some(("Find and Replace Text…", "edit.findAndReplaceText")), + None, + Some(("Rasterize Type", "type.rasterizeTypeLayer")), + Some(("Create Work Path", "type.createWorkPath")), + Some(("Convert to Shape", "type.convertToShape")), + None, + Some(("Horizontal", "type.orientation.horizontal")), + Some(("Vertical", "type.orientation.vertical")), + None, + Some(("None", "type.antiAlias.none")), + Some(("Sharp", "type.antiAlias.sharp")), + Some(("Crisp", "type.antiAlias.crisp")), + Some(("Strong", "type.antiAlias.strong")), + Some(("Smooth", "type.antiAlias.smooth")), + Some(("Windows LCD", "type.antiAlias.windowsLcd")), + Some(("Windows", "type.antiAlias.windows")), + None, + Some(("Convert to Paragraph Text", "type.convertToParagraphText")), + Some(("Convert to Point Text", "type.convertToPointText")), + Some(("Warp Text…", "type.warpText")), +]; + /// The type edit menu's Faux Bold and Faux Italic rows. pub const FAUX_BOLD: &str = "type.fauxBold"; pub const FAUX_ITALIC: &str = "type.fauxItalic"; @@ -316,6 +347,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU + } else if menu.type_layer { + TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU } else { @@ -338,7 +371,7 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); } - if menu.text { + if menu.text || menu.type_layer { return type_checked(app, command); } menu.transform @@ -364,6 +397,32 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The committed type layer under document point `doc` when a right-click there with a Type tool +/// opens the type layer menu (#7324). The Type Mask tools are left out: to them existing type is +/// just pixels. +pub fn type_layer_at(app: &mut PhotocraftApp, tool: Tool, doc: [f64; 2]) -> Option { + if !tool.is_type() || tool.is_type_mask() || app.ui.text_edit.is_some() || !doc.iter().all(|v| v.is_finite()) { + return None; + } + crate::type_tool::hit_layer(app, doc[0], doc[1]) +} + +/// Make the right-clicked type layer `id` the active layer, which the structural rows (Rasterize +/// Type, Convert to Shape…) act on and the checks show. Inside a multiselection the selection stays, +/// so the formatting rows still act on every selected type layer, as the Type menu's do; outside it +/// the type replaces the selection, as a click with the Type tool does. +fn target_type_layer(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) -> bool { + let Some(st) = app.session.active() else { return false }; + let selected = st.selected_layers(); + if st.active_layer == Some(id) { + true + } else if selected.contains(&id) { + photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, selected, Some(id), Some(id)).is_ok() + } else { + app.session.select_layer(id).is_ok() + } +} + /// The misspelled word of the edited text under document point `doc`, with its suggestions. fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { let layer = app.ui.text_edit.as_ref()?.layer; @@ -424,6 +483,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -497,6 +557,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -525,6 +586,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui pasteboard: false, anchor: None, text: false, + type_layer: false, guide: true, spelling: None, }); @@ -578,6 +640,9 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } + if menu.type_layer { + return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -621,10 +686,15 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - } /// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being -/// edited, the menu starts with its spelling suggestions. +/// edited, the menu starts with its spelling suggestions; over committed type with a Type tool, the +/// type layer menu. pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); - if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { + if !pos.iter().all(|v| v.is_finite()) { + return false; + } + let type_layer = !text && doc.and_then(|d| type_layer_at(app, tool, d)).is_some_and(|id| target_type_layer(app, id)); + if !(applies(tool) || text || type_layer) { return false; } app.ui.brush_picker = None; @@ -633,8 +703,20 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; - app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, guide: false, spelling }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection, + has_path, + path_name, + transform: false, + pasteboard: false, + anchor: None, + text, + type_layer, + guide: false, + spelling, + }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -657,6 +739,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + type_layer: false, guide: false, spelling: None, }); @@ -703,7 +786,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_view(app, ctx, &menu, command) } else if menu.text && is_spelling(&menu, command) { choose_spelling(app, &menu, command) - } else if menu.text { + } else if menu.text || menu.type_layer { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) @@ -840,6 +923,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) || (menu.text && app.ui.text_edit.is_none()) + || (menu.type_layer && app.ui.text_edit.is_some()) { app.ui.canvas_tool_menu = None; return; @@ -1055,6 +1139,91 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + /// A committed type layer reading "Hello" over the middle of the canvas, no edit running. + fn committed_type(app: &mut PhotocraftApp) -> u64 { + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Hello", "size": 14, "x": 1, "y": 22})).unwrap()["layer"].as_u64().unwrap(); + assert!(app.ui.text_edit.is_none()); + id + } + + #[test] + fn right_click_on_committed_type_opens_the_type_layer_menu() { + use photocraft_doc::text::Orientation; + let mut app = app(); + let id = committed_type(&mut app); + assert_eq!(crate::type_tool::hit_layer(&mut app, 16.0, 16.0).map(|l| l.0), Some(id), "the canvas centre is on the type"); + let mut h = harness(app); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on committed type opens a menu (#7324)"); + assert!(menu.type_layer && !menu.text && !menu.pasteboard); + assert_eq!(rows(&menu), TYPE_LAYER_MENU); + assert!(h.state().ui.text_edit.is_none(), "the right-click doesn't start an edit"); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu stays open without an edit"); + // An edit started some other way (here, as an agent's Type › Edit Text would) closes the + // menu, which has no text edit rows. + crate::menus::invoke(h.state_mut(), &Context::default(), "type.editText", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_none(), "an edit closes the type layer menu"); + crate::type_tool::commit(h.state_mut()); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("committed again, the menu opens again"); + assert!(menu.type_layer && mode_checked(h.state(), &menu, "type.orientation.horizontal")); + choose(h.state_mut(), &Context::default(), "type.orientation.vertical"); + assert_eq!(text_of(h.state(), id).unwrap().orientation, Orientation::Vertical); + } + + #[test] + fn type_layer_menu_targets_the_type_under_the_pointer() { + let mut app = app(); + let id = committed_type(&mut app); + app.run("layer.new.layer", json!({})).unwrap(); + let ctx = Context::default(); + let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.map(|l| l.0); + assert_ne!(active(&app), Some(id)); + // Away from the type, or with a Type Mask tool, nothing opens and nothing is selected. + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([30.0, 2.0]))); + assert!(!open_at(&mut app, &ctx, Tool::HorizontalTypeMask, [10.0, 10.0], Some([16.0, 16.0]))); + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([f64::NAN, 16.0]))); + assert_ne!(active(&app), Some(id)); + // Over the type: its layer becomes the target, and the menu has no text edit rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([16.0, 16.0]))); + assert_eq!(active(&app), Some(id)); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + for edit_row in ["edit.undo", "edit.cut", "edit.copy", "edit.paste", "select.all", FAUX_BOLD, FAUX_ITALIC] { + assert!(!available(&app, &menu, edit_row), "{edit_row} belongs to the edit menu only"); + } + assert!(entry_enabled(&app, &menu, "type.convertToParagraphText") && !entry_enabled(&app, &menu, "type.convertToPointText")); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, id).is_none(), "the type layer is pixels now"); + assert!(app.ui.text_edit.is_none()); + } + + /// Two selected type layers: the one right-clicked becomes active, and the selection stays. + #[test] + fn type_layer_menu_in_a_multiselection_acts_on_the_clicked_type() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Type; + let top = app.run("type.create", json!({"text": "Top", "size": 30, "x": 10, "y": 40})).unwrap()["layer"].as_u64().unwrap(); + let bottom = app.run("type.create", json!({"text": "Low", "size": 30, "x": 10, "y": 90})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": bottom})).unwrap(); + app.run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + let at = [25.0, 80.0]; + assert_eq!(crate::type_tool::hit_layer(&mut app, at[0], at[1]).map(|l| l.0), Some(bottom)); + assert_eq!(app.session.active().unwrap().active_layer.map(|l| l.0), Some(top)); + let ctx = Context::default(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some(at))); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer.map(|l| l.0), Some(bottom), "the clicked type is the target"); + assert_eq!(st.selected_layers().len(), 2, "the multiselection stays"); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, bottom).is_none(), "the clicked type is rasterized"); + assert!(text_of(&app, top).is_some(), "not the other selected type"); + } + #[test] fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..05540339a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1093,7 +1093,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } - if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { + if crate::canvas_tool_menu::applies(app.ui.tool) + || crate::canvas_tool_menu::editing_text(app, app.ui.tool) + || crate::canvas_tool_menu::type_layer_at(app, app.ui.tool, [x, y]).is_some() + { if down { crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index 0ed51de669..457c03c1cb 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }; diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 325e5122b4..d07b8bb161 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { /// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text /// contains the document point. -fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { +pub(crate) fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); // A Type Mask session's text is a hidden layer; it is still the text being edited. diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 1e8abf9a67..53b6d99bd9 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (Path Selection canvas menu, #1056) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370). Over committed type (no edit running, `TYPE_LAYER_MENU`, #7324): the same rows without Undo, Cut, Copy, Paste, Select All and Faux Bold/Italic; the clicked type becomes the active layer first (a layer multiselection stays). The Type Mask tools get no menu. Ignore / Ignore All remain open | P1; editing and nonediting states are separate; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Type tool menu over committed type (#7324) | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..c62e9046d2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing diff --git a/docs/scorecard.md b/docs/scorecard.md index 3b36e7c611..a02e8ed210 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -192,7 +192,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | -| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | +| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..908efc6b32 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" +note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9" From 0eb0387d53446f5341d46e708f58ebf13711dfb5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:10 +0200 Subject: [PATCH 0711/1452] Save for Web and Quick Export: "All" keeps the photo's EXIF (fixes #7436) (#7471) --- crates/codecs/src/web.rs | 26 ++- crates/engine/examples/bench_web_palette.rs | 4 +- crates/engine/src/web_cmds.rs | 65 +++++-- .../engine/src/web_cmds/color_table_tests.rs | 8 +- crates/engine/src/web_cmds/tests.rs | 169 +++++++++++++++++- crates/ui-egui/src/file_ui.rs | 2 +- crates/ui-egui/src/file_ui/web_preview.rs | 6 +- 7 files changed, 245 insertions(+), 35 deletions(-) diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 584ceb9d6e..dfd0cd9b90 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { } /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and -/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. +/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8( optimized: bool, icc: Option<&[u8]>, dpi: Option, + exif: Option<&[u8]>, xmp: Option<&str>, ) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); @@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8( let v = d.round().min(65535.0) as u16; enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v }); } + let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d)); + // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it. + if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { + enc.add_app_segment(1, &seg).map_err(e)?; + } if let Some(x) = xmp { // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))); + let x = crate::resolution::export_xmp(x, ppi); let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); seg.extend_from_slice(x.as_bytes()); enc.add_app_segment(1, &seg).map_err(e)?; @@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8( Ok(out) } +/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment). +pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { + crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -359,7 +371,7 @@ mod tests { let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect(); for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] { for progressive in [false, true] { - let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap(); assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}"); } } @@ -428,8 +440,8 @@ mod tests { #[test] fn progressive_jpeg_decodes() { let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect(); - let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("")).unwrap(); - let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap(); + let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("")).unwrap(); + let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap(); assert_ne!(base, prog); assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("")); // SOF2 marks a progressive frame. @@ -442,7 +454,7 @@ mod tests { fn web_jpeg_xmp_resolution_follows_the_density() { let rgb = vec![100u8; 8 * 8 * 3]; let xmp = r#""#; - let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap(); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap(); let img = crate::decode(&out).unwrap(); assert_eq!(img.meta.dpi, Some((72.0, 72.0))); assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0))); @@ -461,7 +473,7 @@ mod subsampling_tests { let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect(); for q in [30, 60, 95] { for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] { - let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap(); let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8); let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap(); assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}"); diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs index 5ed1c3a3b0..4b8b87616f 100644 --- a/crates/engine/examples/bench_web_palette.rs +++ b/crates/engine/examples/bench_web_palette.rs @@ -18,13 +18,13 @@ fn main() -> Result<(), Box> { let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; let rect = Rect::new(0, 0, w as i32, h as i32); let started = Instant::now(); - let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + let original = optimize(&pixels, w, rect, &settings, None, None, None, 72.0, false)?; println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); let mut edited = settings; edited.color_table = Some(original.color_table); edited.color_table_edits.insert(0, [0, 255, 0]); let started = Instant::now(); - let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + let result = optimize(&pixels, w, rect, &edited, None, None, None, 72.0, false)?; println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); Ok(()) } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 9f0e25574d..29237fd5ad 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -70,6 +70,9 @@ pub enum WebMetadata { None, Copyright, CopyrightAndContact, + /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). + AllExceptCameraInfo, + /// All the XMP and the photo's EXIF. All, } @@ -203,8 +206,9 @@ impl WebSettings { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, - "all" | "allExceptCameraInfo" => WebMetadata::All, - m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), + "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, + "all" => WebMetadata::All, + m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { @@ -262,6 +266,7 @@ impl WebSettings { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", + WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); @@ -387,7 +392,7 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, - WebMetadata::All => Some(xmp.to_string()), + WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); @@ -405,10 +410,33 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { } } +/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). +pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { + doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) +} + +/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). +fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { + exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) + .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) + .into_iter() + .collect() +} + /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] -pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { +pub fn optimize( + px: &[[f32; 4]], + bw: usize, + rect: Rect, + st: &WebSettings, + icc: Option<&[u8]>, + exif: Option<&[u8]>, + xmp: Option<&str>, + dpi: f32, + preview: bool, +) -> Result { if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) { return Err(other("slice is outside the pixel buffer")); @@ -460,8 +488,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) - .map_err(e)?; + let bytes = + photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -873,7 +902,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; + let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); @@ -895,18 +926,18 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); @@ -961,7 +992,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u cells.push((piece, bg, text)); continue; } - let o = optimize(&buf.px, bw, piece, &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; let name = unique(&stem, o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; @@ -997,7 +1028,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) + Ok( + json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), + ) } // ---------- Export Preferences / Quick Export ---------- @@ -1108,19 +1141,21 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) + Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- @@ -1430,7 +1465,7 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); - let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; + let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; written.insert(key); @@ -1504,7 +1539,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), @@ -1522,7 +1557,7 @@ pub fn specs() -> Vec { "Quick Export", &[], None, - r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs index 1d319cb382..0c25ce0d24 100644 --- a/crates/engine/src/web_cmds/color_table_tests.rs +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -7,13 +7,13 @@ fn color_replacement_preserves_pixel_membership_and_transparency() { for dither in ["none", "diffusion", "pattern", "noise"] { let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); let original = WebSettings::from_params(&p, "test").unwrap(); - let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, None, 72.0, true).unwrap(); let mut edited = p.clone(); // Replace red with blue: re-quantization would now send all opaque pixels to blue. edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); let settings = WebSettings::from_params(&edited, "test").unwrap(); assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); - let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, None, 72.0, true).unwrap(); for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { if old[3] == 0 { assert_eq!(new[3], 0); @@ -81,8 +81,8 @@ fn malformed_tables_and_out_of_bounds_slices_return_errors() { } let settings = WebSettings::default(); for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { - assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, None, 72.0, false).is_err()); } let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); - assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, None, 72.0, true).is_err()); } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index bd55a549cb..5791e5f27c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -735,7 +735,7 @@ fn previews_show_the_optimised_pixels() { let buf = photocraft_compose::flatten(&wd); for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); - let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); // Red square centre stays red (WBMP: dark). let i = (16 * 64 + 16) * 4; @@ -745,7 +745,7 @@ fn previews_show_the_optimised_pixels() { } else { assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}"); } - let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap(); + let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, false).unwrap(); assert!(none.preview.is_empty()); assert_eq!(none.bytes, o.bytes); } @@ -756,7 +756,7 @@ fn jpeg_preview_at_odd_sizes() { for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] { let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect(); let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap(); - let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, None, 72.0, true).unwrap(); for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] { let i = (y * w + x) * 4; let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32]; @@ -814,3 +814,166 @@ fn overlapping_slices_keep_the_later_slice_in_html() { #[path = "color_table_tests.rs"] mod color_table_tests; + +/// A little-endian camera EXIF: IFD0 with Make, Orientation 6, 300 ppi and pointers to an Exif +/// IFD (DateTimeOriginal) and a GPS IFD (GPSLatitudeRef); IFD0 links to an IFD1 thumbnail. +fn camera_exif() -> Vec { + let entry = |v: &mut Vec, tag: u16, ty: u16, count: u32, value: u32| { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&ty.to_le_bytes()); + v.extend_from_slice(&count.to_le_bytes()); + v.extend_from_slice(&value.to_le_bytes()); + }; + let (ifd0, data0) = (8u32, 8 + 2 + 7 * 12 + 4); + let (make, xres, yres) = (data0, data0 + 10, data0 + 18); + let exif_ifd = data0 + 26; + let date = exif_ifd + 2 + 12 + 4; + let gps_ifd = date + 20; + let ifd1 = gps_ifd + 2 + 12 + 4; + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&ifd0.to_le_bytes()); + v.extend_from_slice(&7u16.to_le_bytes()); + entry(&mut v, 0x010F, 2, 9, make); + entry(&mut v, 0x0112, 3, 1, 6); + entry(&mut v, 282, 5, 1, xres); + entry(&mut v, 283, 5, 1, yres); + entry(&mut v, 296, 3, 1, 2); + entry(&mut v, 0x8769, 4, 1, exif_ifd); + entry(&mut v, 0x8825, 4, 1, gps_ifd); + v.extend_from_slice(&ifd1.to_le_bytes()); + v.extend_from_slice(b"TestMake\0\0"); + for _ in 0..2 { + v.extend_from_slice(&300u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + } + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x9003, 2, 20, date); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"2024:05:01 10:30:00\0"); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0001, 2, 2, u32::from_le_bytes(*b"N\0\0\0")); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0103, 3, 1, 6); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), ifd1 as usize + 18); + v +} + +/// The payload (after `Exif\0\0`) of the file's EXIF APP1 segment, read from the markers. +fn exif_app1(bytes: &[u8]) -> Option> { + let mut i = 2; + while i + 4 <= bytes.len() && bytes[i] == 0xFF && bytes[i + 1] != 0xDA { + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let body = &bytes[i + 4..i + 2 + len]; + if bytes[i + 1] == 0xE1 && body.starts_with(b"Exif\0\0") { + return Some(body[6..].to_vec()); + } + i += 2 + len; + } + None +} + +/// IFD0's entries (tag → inline value) and its link to the next IFD, little-endian only. +fn ifd0(tiff: &[u8]) -> (std::collections::HashMap, u32) { + let u16_at = |o: usize| u16::from_le_bytes([tiff[o], tiff[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([tiff[o], tiff[o + 1], tiff[o + 2], tiff[o + 3]]); + assert_eq!(&tiff[..4], b"II*\0"); + let at = u32_at(4) as usize; + let n = u16_at(at) as usize; + let tags = (0..n) + .map(|k| { + let e = at + 2 + k * 12; + let value = if u16_at(e + 2) == 3 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), value) + }) + .collect(); + (tags, u32_at(at + 2 + n * 12)) +} + +fn photo_session(exif: Vec) -> Session { + let mut s = session(8); + s.execute("file.fileInfo", json!({"copyright": "© Example"})).unwrap(); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif)); + Ok(()) + }) + .unwrap(); + s +} + +/// #7436: Save for Web's "All" writes the photo's EXIF, as Save a Copy does: upright +/// (Orientation 1), without the source's thumbnail and at the exported resolution. Every other +/// choice, "All Except Camera Info" included, leaves the camera data (and GPS) out. +#[test] +fn save_for_web_all_writes_the_photos_exif() { + let dir = tmp("exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": metadata, "path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + let exif = exif_app1(&all).expect("an EXIF APP1 segment"); + let camera = photocraft_algo::exif::read(&exif); + assert_eq!(camera.make.as_deref(), Some("TestMake")); + assert_eq!(camera.date_taken.as_deref(), Some("2024:05:01 10:30:00")); + let (tags, next) = ifd0(&exif); + assert_eq!(tags.get(&0x0112), Some(&1), "the exported pixels are already upright"); + assert!(tags.contains_key(&0x8825), "GPS kept"); + assert_eq!(next, 0, "the source's thumbnail is not linked"); + assert_eq!(photocraft_codecs::exif_resolution(&exif), Some((72.0, 72.0)), "the document's resolution, not the source's 300"); + assert!(decode(&format!("{dir}/all.jpg")).meta.xmp.unwrap_or_default().contains("Example")); + for m in ["none", "copyright", "copyrightAndContact", "allExceptCameraInfo"] { + let (bytes, r) = export(&mut s, m); + assert_eq!(exif_app1(&bytes), None, "{m}"); + assert_eq!(r["warnings"], json!([]), "{m}"); + } + assert_eq!(r["warnings"], json!([]), "it fits"); + let xmp = decode(&format!("{dir}/allExceptCameraInfo.jpg")).meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example"), "all the XMP"); + assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); +} + +#[test] +fn quick_export_all_writes_the_photos_exif() { + let dir = tmp("quick_exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": metadata})).unwrap(); + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + assert_eq!(photocraft_algo::exif::read(&exif_app1(&all).expect("EXIF")).make.as_deref(), Some("TestMake")); + assert_eq!(r["warnings"], json!([])); + assert_eq!(exif_app1(&export(&mut s, "copyright").0), None); + assert_eq!(exif_app1(&export(&mut s, "none").0), None); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// EXIF too large for its one segment is left out and said so; a truncated one doesn't panic. +#[test] +fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { + let dir = tmp("exif_bad"); + let mut big = camera_exif(); + big.resize(70_000, 0); + let mut s = photo_session(big); + let path = format!("{dir}/big.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(exif_app1(&std::fs::read(&path).unwrap()), None); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF (70000 bytes)")), "{r}"); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": "all"})).unwrap(); + let r = s.execute("file.export.quickExport", json!({"path": format!("{dir}/quick.jpg")})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF")), "{r}"); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); + + for cut in [3, 9, 40, 100] { + let mut s = photo_session(camera_exif()[..cut].to_vec()); + let path = format!("{dir}/cut{cut}.jpg"); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48)); + } +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index d1b2f0e551..a84bfe7779 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -1674,7 +1674,7 @@ mod web_playback_tests { let doc = web_frame_document(&app.session.active().unwrap().doc, Some(frame)).unwrap(); let (proxy, _, _) = web_cmds::web_document(&doc, &json!({"width":16,"height":12}), &settings).unwrap(); let pixels = photocraft_compose::flatten(&proxy); - let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, 72.0, true).unwrap(); + let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, None, 72.0, true).unwrap(); assert_eq!(&optimized.preview[..4], expected, "depth={depth} frame={frame}"); } } diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs index a9ee7a933c..b603584573 100644 --- a/crates/ui-egui/src/file_ui/web_preview.rs +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -60,8 +60,8 @@ pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result< let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; f.insert("__paletteApproximate".into(), json!(scale < 1.0)); let buf = photocraft_compose::flatten(&doc); - let mut optimized = - web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + let mut optimized = web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, None, doc.resolution_dpi, false) + .map_err(|e| e.to_string())?; // Tiny transparent features may disappear in the proxy; keep room for their index. if settings.transparency { optimized.color_table.truncate(255); @@ -137,7 +137,7 @@ pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, p } else { let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; let encoded = Arc::new( - web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, None, 72.0, true) .map_err(|e| e.to_string())?, ); ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); From 8736098d38f6b5917f20996a4eb8bc8a4ccde9c4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:16 +0200 Subject: [PATCH 0712/1452] Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (#7473) * Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325) With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on the image adds a point to the channel shown in the dialog, at the pixel's tone and at the curve's current output there; Shift adds one point on each of Red, Green and Blue. Only the press adds, an armed eyedropper still wins, and the control channel's ui.pointer does the same with command: true. * Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan The Hand tool took the Cmd-click press for a pan, so no point was added; the press now counts before the Hand. On the control channel, ui.pointer with space or the middle button pans as the mouse does instead of adding a point. * Curves: the Cmd/Ctrl-click point's comment says where the curve can still change --- crates/ui-egui/src/adjust_dialog.rs | 228 +++++++++++++++++++++++++++ crates/ui-egui/src/adjust_editors.rs | 2 +- crates/ui-egui/src/canvas.rs | 21 ++- crates/ui-egui/src/control.rs | 7 + crates/ui-egui/src/input_tests.rs | 50 ++++++ docs/control-protocol.md | 1 + 6 files changed, 307 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index be9acde2e8..fe483caef9 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -79,6 +79,101 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Whether the top modal is the Curves dialog. +pub fn curves_on_top(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| d.fields.get("__adjust").and_then(Value::as_str) == Some("curves")) +} + +/// ⌘/Ctrl-click on the image with Curves on top (#7325): a point on the curve shown in the dialog +/// at the clicked pixel's tone, at the output the curve gives there rounded to a whole level, so the +/// image barely changes (where the curve is clipped at 0 or 255 the point re-shapes it, as a click on +/// the graph there does); with `every_channel` (Shift) one on each colour channel instead, at that +/// channel's value. +/// The pixel comes from the same merged composite as the eyedroppers. Returns true when Curves is +/// on top: the click is the dialog's even when nothing is added (pencil curve, Lab or CMYK, a mask +/// or saved channel target, outside the image, a full curve). +pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, every_channel: bool) -> bool { + if !curves_on_top(app) { + return false; + } + let Some((id, mut values)) = app.ui.dialogs.last().map(|d| (d.id, crate::filter_dialog::params_of(&d.fields))) else { return false }; + if values.get("pencil").and_then(Value::as_bool) == Some(true) { + return true; + } + let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true }; + if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) { + app.ui.status = tl!("Unavailable in this color mode").into(); + return true; + } + // A mask, saved channel or Quick Mask holds its own gray levels, not the composite's. + let isolated = isolated(app); + if isolated.as_ref().is_some_and(|c| c.gray.is_some()) { + return true; + } + let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true }; + let gray = adjust_editors::is_gray(mode); + let (shown, shown_key) = match &isolated { + Some(c) => (0, c.key), + None => { + let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); + let chans = adjust_editors::tone_channels(adjust_editors::space_of(&values), gray); + (ch, chans.get(ch).map_or("", |c| c.key)) + } + }; + let keys: &[&str] = if every_channel && !gray && isolated.is_none() { &["red", "green", "blue"] } else { &[shown_key] }; + let mut selected = None; + let mut changed = false; + for &key in keys { + let level = match key { + "red" => sample[0], + "green" => sample[1], + "blue" => sample[2], + // The composite's tone: Gray's mean, as the Grayscale eyedroppers read it, and RGB's + // Rec. 709 luma, as the Neutral Gray eyedropper does (`adjust_params::rgb_luma`). + _ if gray => (sample[0] + sample[1] + sample[2]) / 3.0, + _ => sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722, + }; + let input = (level.clamp(0.0, 1.0) * 255.0).round(); + let field = if key.is_empty() { "points" } else { key }; + let mut pts = adjust_editors::read_curve(&values, field); + let curve: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&curve); + let at = (input / 255.0 * lut.len().saturating_sub(1) as f32).round() as usize; + let Some(output) = lut.get(at).map(|o| (o * 255.0).round()) else { continue }; + let before = pts.len(); + let Some(i) = crate::point_curve::insert(&mut pts, [input, output]) else { continue }; + if pts.len() > before { + // Whole levels, as the editor writes a point clicked on the graph. + values[field] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::>()); + changed = true; + } + if key == shown_key { + selected = Some(i); + } + } + if let Some(i) = selected { + let state = dialog_mem("curves").with("curves"); + ctx.data_mut(|d| { + let mut st: crate::state::CurvesEditorState = d.get_temp(state).unwrap_or_default(); + st.channel = shown; + st.gesture = crate::state::PointCurveState { selected: Some(i), drag: None }; + d.insert_temp(state, st); + }); + } + if changed + && let Some(dialog) = app.ui.dialog_mut(id) + && let Value::Object(v) = values + { + dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.extend(v); + if picker(dialog.fields.get(CURVE_PICKER)).is_some() { + let base = crate::filter_dialog::params_of(&dialog.fields); + dialog.fields.insert(CURVE_PICKER_BASE.into(), base); + } + } + true +} + pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response { let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner; let tokens = crate::theme::Tokens::get(ui.ctx()); @@ -767,6 +862,139 @@ mod tests { } } + fn pointer_down(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, modifiers: Value) { + let (request, _receiver) = + crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}], "modifiers": modifiers})); + let _ = crate::control::handle(app, ctx, &request); + } + + /// The Curves curve `pts` (0-255 levels) as a 256-entry 0-255 map. + fn curve_levels(pts: &[[f64; 2]]) -> Vec { + let cp: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] as f32 / 255.0, output: q[1] as f32 / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + (0..256).map(|k| lut[(k as f32 / 255.0 * (lut.len() - 1) as f32).round() as usize] * 255.0).collect() + } + + /// #7325: ⌘/Ctrl-click on the image puts a point on the shown curve at the pixel's tone + /// (#b04020: Rec. 709 luma 85.5 → level 86) and at the curve's own output there, so the + /// image does not change; the new point is the selected one, and the document waits for OK. + #[test] + fn command_click_on_the_image_adds_a_point_on_the_composite_curve() { + let mut app = app_with_image(); + let original = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let base = [[0.0, 0.0], [128.0, 200.0], [255.0, 255.0]]; + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(base)); + let ctx = egui::Context::default(); + let sample = crate::canvas::composite_color(&mut app, 8.0, 8.0).unwrap(); + let level = ((sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722) * 255.0).round(); + assert_eq!(level, 86.0); + let output = f64::from(curve_levels(&base)[86].round()); + assert_ne!(output, 86.0, "the base curve is not the identity at the sampled level"); + + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + let points = channel_points(&app, "points"); + assert_eq!(points, vec![base[0], [86.0, output], base[1], base[2]]); + let (before, after) = (curve_levels(&base), curve_levels(&points)); + assert!(before.iter().zip(&after).all(|(a, b)| (a - b).abs() <= 0.5), "the curve keeps its shape:\n{before:?}\n{after:?}"); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (0, Some(1)), "the new point is selected"); + for key in ["red", "green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + /// #7325: Shift+⌘-click puts a point on each colour channel at that channel's own value + /// (176, 64, 32), and leaves the composite curve alone. + #[test] + fn shift_command_click_adds_a_point_on_each_colour_channel() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true, "shift": true})); + for (key, level) in [("red", 176.0), ("green", 64.0), ("blue", 32.0)] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [level, level], [255.0, 255.0]], "{key}"); + } + assert_eq!(channel_points(&app, "points"), composite); + } + + /// #7325: with Red shown in the Channel popup the point goes on the red curve, at red's value. + #[test] + fn command_click_adds_the_point_on_the_channel_shown_in_the_dialog() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(dialog_mem("curves").with("curves-ch"), 1usize)); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "red"), vec![[0.0, 0.0], [176.0, 176.0], [255.0, 255.0]]); + for key in ["green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert_eq!(channel_points(&app, "points"), composite); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (1, Some(1))); + } + + /// #7325: a plain click, a full curve and an armed eyedropper add nothing. + #[test] + fn command_click_adds_nothing_without_the_modifier_on_a_full_curve_or_with_an_eyedropper() { + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({})); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "no modifier, no point"); + // Space and the middle button pan, as with the mouse. + for extra in [json!({"modifiers": {"command": true, "space": true}}), json!({"command": true, "button": "middle"})] { + let mut params = json!({"events": [{"kind": "down", "x": 8, "y": 8}]}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let (request, _receiver) = crate::control::ControlRequest::new("ui.pointer", params); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "{extra}"); + } + + // Sixteen points (the most a curve holds), none within reach of level 86. + let full: Vec<[f64; 2]> = + [0, 10, 20, 30, 40, 50, 60, 70, 80, 100, 120, 140, 160, 180, 200, 255].iter().map(|&i| [f64::from(i), f64::from(i)]).collect(); + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(full)); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points"), full, "a full curve takes no more points and moves none"); + + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + arm(&mut app, id, "black"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points").len(), 2, "the armed eyedropper samples instead"); + assert_eq!(channel_points(&app, "red")[0], [176.0, 0.0]); + } + + /// #7325: Lab and CMYK curves and a targeted mask are left to a later change: nothing is added. + #[test] + fn command_click_adds_nothing_in_lab_or_on_a_targeted_mask() { + let ctx = egui::Context::default(); + let mut lab = app_in_mode("lab"); + lab.run("edit.fill", json!({"color": "#b04020"})).unwrap(); + let id = open(&mut lab, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut lab, &ctx, 4.0, 4.0, false)); + assert_eq!(crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields), before); + + let mut masked = app_with_image(); + masked.run("layer.layerMask.revealAll", json!({})).unwrap(); + masked.run("channel.target", json!({"channel": "mask"})).unwrap(); + let id = open(&mut masked, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut masked, &ctx, 8.0, 8.0, false)); + assert_eq!(crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields), before); + } + #[test] fn ui_pointer_drives_an_armed_curves_picker() { let mut app = app_with_image(); diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 87a81b8c35..fa32a3b392 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -362,7 +362,7 @@ fn curve_graph(full: Rect, side: f32) -> Rect { Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING)) } -fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { +pub(crate) fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { let pts: Vec<[f32; 2]> = v .get(key) .and_then(Value::as_array) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d5fbe1b81..2c04534962 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3438,7 +3438,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). let shadow_drag = !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; + // ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The + // press's own modifiers decide, and only the press adds: a drag would add one per frame. + let curve_point_press = (primary && !middle && crate::adjust_dialog::curves_on_top(app)) + .then(|| { + ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) + }) + .flatten() + .map(|m| crate::workspace_ui::sticky_mods(app, m)) + .filter(|m| m.command); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none(); if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3483,6 +3497,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q)); crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x()); + } else if let Some(mods) = curve_point_press + && let Some(p) = crate::dialogs::free_press(&ctx, rect) + { + let d = xf.to_doc(p); + crate::adjust_dialog::add_point_at(app, &ctx, d[0], d[1], mods.shift); } else if primary && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p)) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 05540339a5..581925f83c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1043,6 +1043,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + // ⌘/Ctrl-click with Curves open adds a point at the pixel's tone, on press only (#7325). + if !secondary && !space && button != "middle" && mods.command && crate::adjust_dialog::curves_on_top(app) { + if matches!(ev, ToolEvent::Down { .. }) { + crate::adjust_dialog::add_point_at(app, ctx, x, y, mods.shift); + } + continue; + } // A Properties Levels/Curves eyedropper owns the primary button the same way. if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() { if matches!(ev, ToolEvent::Down { .. }) { diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..e06182870c 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -317,6 +317,56 @@ fn curves_picker_samples_document_coordinates_through_the_view_transform() { assert!((red - 32.0).abs() < 0.6, "sampled document-right blue patch through the flipped 400% view: {red}"); } +/// #7325: ⌘/Ctrl-click on the image with Curves open adds a point at the pixel's tone. The +/// modifier is the press's own, a drag onto another tone adds nothing more, and the Hand tool +/// does not take the press for a pan. +#[test] +fn command_click_under_curves_adds_one_point_per_press_not_per_frame() { + // The Hand tool too: the press is the dialog's, not the start of a pan. + for hand in [false, true] { + let mut h = harness(); + if hand { + h.state_mut().ui.tool = crate::state::Tool::Hand; + } + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#804020"})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 80, "y": 0, "width": 320, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#20a0e0"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + let view = &mut h.state_mut().ui.views[0]; + (view.zoom, view.center, view.fit_pending) = (4.0, [200.0, 150.0], false); + let dialog = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(3); + let points = |h: &Harness<'static, PhotocraftApp>| { + let d = h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap(); + d.fields.get("points").and_then(serde_json::Value::as_array).map_or(0, Vec::len) + }; + let canvas = h.state().last_canvas_rect; + // Document x 65 (the brown strip) and 100 (the blue), both left of the centred dialog. + let (brown, blue) = (pos2(canvas.left() + 60.0, canvas.center().y), pos2(canvas.left() + 200.0, canvas.center().y)); + h.hover_at(brown); + h.run_steps(1); + press(&mut h, brown, true); + press(&mut h, brown, false); + assert_eq!(points(&h), 2, "a plain click adds no point (hand: {hand})"); + + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::COMMAND }; + // ⌘ stays held through the drag, so only the press can tell the frames apart. + h.event(egui::Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(button(brown, true)); + h.run_steps(1); + assert_eq!(points(&h), 3, "the press adds the point (hand: {hand})"); + for t in [0.25, 0.5, 0.75, 1.0] { + h.event(egui::Event::PointerMoved(brown + (blue - brown) * t)); + h.run_steps(1); + } + h.event(button(blue, false)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(points(&h), 3, "dragging onto another tone adds nothing more (hand: {hand})"); + } +} + /// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100 /// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends /// the selection by a word. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c62e9046d2..e6d5c9591d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -75,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. + - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) From 72a3423a7e7ca227e4eb11a65218596b03ca3299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Minh=20Long?= Date: Mon, 19 Oct 2026 04:23:21 +0700 Subject: [PATCH 0713/1452] Preferences: Enable Gestures off ignores touch-screen pinch and twist (#204) (#7478) Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures) was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view. The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that arrives while fingers rest on the screen. - wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included); read_with() takes the preference. - rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures are on; Rotate events always count. - canvas, Navigator: pass the preference through. - prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it. - Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with gestures off. - docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168. --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/prefs_ui.rs | 6 +- crates/ui-egui/src/rotate_view.rs | 48 ++++++++++++++-- crates/ui-egui/src/wheel_nav.rs | 91 +++++++++++++++++++++++++++++-- docs/scorecard.md | 6 +- 7 files changed, 144 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2c2507226d..3e87d43fc7 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1040,7 +1040,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", - "enhancedControls.touchGestures", "rawDefaults.colorSpace", "rawDefaults.bitDepth", "rawDefaults.resolution", diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c04534962..60e9fbaedf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3327,6 +3327,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor + // turn the view (#204); the mouse and trackpad are unaffected. + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; let wheel = crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, @@ -3334,6 +3337,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: response.id.with("wheel"), nav_hovered, app.session.prefs().interface.enable_animations, + touch_gestures, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -3366,7 +3370,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: _ => {} } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; - if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { + if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad, touch_gestures) { let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f2961fd7b6..9a1b07f2b7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1980,8 +1980,10 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Use the canvas wheel classifier to honour one ×1.1 step per notch without // applying egui's smoothed wheel tail a second time. let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; if resp.hovered() - && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true) + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 7213407024..4156e380cd 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3506,11 +3506,13 @@ mod tests { assert!(!prefs::is_hidden("type.useEscToCommit")); assert!(!prefs::is_hidden("type.smartQuotes")); assert!(!prefs::is_hidden("type.showFontNamesInEnglish")); - // Rotate View with Trackpad and the pinch zoom are live; the other Enhanced Controls rows - // stay hidden. + // Rotate View with Trackpad, the pinch zoom and Enable Gestures are live; the other + // Enhanced Controls rows stay hidden. assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); + assert!(!prefs::is_hidden("enhancedControls.touchGestures")); + assert!(prefs::is_hidden("enhancedControls.scrubbySliderAcceleration")); // Camera Raw Defaults shows only "Open in Camera Raw" so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 5e173da5dc..5137b9d312 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -4,7 +4,7 @@ //! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with -//! Trackpad. +//! Trackpad; a two-finger twist on a touch screen also needs Enable Gestures (#204). use egui::{Align2, Pos2, Rect, Stroke, pos2, vec2}; use serde_json::{Value, json}; @@ -137,7 +137,9 @@ pub fn apply_trackpad(current: f32, enabled: bool, delta_rad: f32) -> Option Option { +/// The trackpad's own `Rotate` events always count; a touch screen's two-finger twist (egui's +/// multi-touch rotation) only while `touch_gestures` (Enhanced Controls › Enable Gestures) is on. +pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool) -> Option { if !enabled { return None; } @@ -150,7 +152,13 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option { _ => None, }) .sum(); - if from_events != 0.0 { from_events } else { i.rotation_delta() } + if from_events != 0.0 { + from_events + } else if touch_gestures { + i.rotation_delta() + } else { + 0.0 + } }); apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) } @@ -324,8 +332,8 @@ mod tests { let mut disabled = None; let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { - enabled = trackpad_delta(ui.ctx(), true); - disabled = trackpad_delta(ui.ctx(), false); + enabled = trackpad_delta(ui.ctx(), true, true); + disabled = trackpad_delta(ui.ctx(), false, true); }); output.textures_delta.clear(); @@ -333,6 +341,36 @@ mod tests { assert_eq!(disabled, None); } + /// Preferences › Enhanced Controls › Enable Gestures (#204): off, a two-finger twist on a + /// touch screen doesn't turn the view; the trackpad's `Rotate` event still does. + #[test] + fn enable_gestures_switches_touch_screen_rotation() { + use egui::TouchPhase::{Move, Start}; + let touch = |id: u64, phase, pos| Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos, force: None }; + let twist = |touch_gestures: bool, extra: Vec| { + let ctx = egui::Context::default(); + let frames = [ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(pos2(50.0, 50.0))], + vec![touch(1, Start, pos2(40.0, 50.0)), touch(2, Start, pos2(60.0, 50.0))], + // A quarter turn about the centre between the fingers. + [vec![touch(1, Move, pos2(50.0, 40.0)), touch(2, Move, pos2(50.0, 60.0))], extra].concat(), + ]; + let mut last = None; + for (n, events) in (0u32..).zip(frames) { + let raw = egui::RawInput { time: Some(f64::from(n) / 60.0), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| last = trackpad_delta(ui.ctx(), true, touch_gestures)); + out.textures_delta.clear(); + } + last + }; + let on = twist(true, Vec::new()); + assert!(on.is_some_and(|d| (d.abs() - 90.0).abs() < 1e-3), "on: the fingers turn the view: {on:?}"); + assert_eq!(twist(false, Vec::new()), None, "off: the fingers don't"); + let trackpad = twist(false, vec![Event::Rotate(std::f32::consts::FRAC_PI_6)]); + assert!(trackpad.is_some_and(|d| (d - 30.0).abs() < 1e-3), "off: a trackpad rotate still turns: {trackpad:?}"); + } + #[test] fn trackpad_pref_gates_rotation() { assert_eq!(apply_trackpad(10.0, false, 0.5), None); diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index bde5e71ae3..68a2321c44 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -8,6 +8,9 @@ //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › //! Zoom with Trackpad Pinch is off (then a pinch does nothing, and Windows' Ctrl + wheel //! fractions scroll sideways like the wheel, because the fold below is skipped). +//! - A two-finger pinch on a touch screen zooms the same way, unless Preferences › Enhanced +//! Controls › Enable Gestures is off (#204): then the fingers leave the view alone, and a +//! trackpad pinch still zooms ([`pinch_factor`]). //! //! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so //! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and @@ -23,7 +26,7 @@ //! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next //! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. -use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -198,13 +201,15 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { - read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true, true) } /// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's /// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch /// in the frame the wheel turns and stops there (#5721), as scroll areas already do. -pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { +/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's +/// two-finger pinch doesn't zoom ([`pinch_factor`]). +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool, touch_gestures: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -225,7 +230,7 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: _ => None, }) .collect(); - (i.smooth_scroll_delta, i.zoom_delta(), latest_alt, latest_shift, wheel, i.content_rect().height()) + (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); let alt = match latest_alt { Some(a) => { @@ -329,6 +334,24 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: } } +/// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's +/// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences › +/// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only +/// the trackpad's own pinch events count (Windows' Ctrl + wheel pinch included: [`fold_legacy_pinch`] +/// has already turned it into `Event::Zoom`). +pub fn pinch_factor(i: &InputState, touch_gestures: bool) -> f32 { + if touch_gestures || i.multi_touch().is_none() { + return i.zoom_delta(); + } + i.events + .iter() + .filter_map(|e| match e { + Event::Zoom(f) if f.is_finite() && *f > 0.0 => Some(*f), + _ => None, + }) + .product() +} + #[cfg(test)] mod tests { use egui::TouchPhase; @@ -393,7 +416,7 @@ mod tests { let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; let mut pan = Vec2::ZERO; let mut out = ctx.run_ui(raw, |ui| { - if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth, true) { pan = d; } }); @@ -922,4 +945,62 @@ mod tests { let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, -1.0), Modifiers::SHIFT)]], true, false); assert_eq!(snaps, -1, "Line-unit notch down produces −1 step"); } + + /// Two fingers on a touch screen: down 20 points apart, spread to 40 (a ×2 pinch), lifted. + fn touch_pinch_frames() -> Vec> { + let touch = |id: u64, phase: TouchPhase, x: f32| Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(id), + phase, + pos: egui::pos2(x, 50.0), + force: None, + }; + vec![ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(egui::pos2(40.0, 50.0))], + vec![touch(1, TouchPhase::Start, 40.0), touch(2, TouchPhase::Start, 60.0)], + vec![touch(1, TouchPhase::Move, 30.0), touch(2, TouchPhase::Move, 70.0)], + vec![touch(1, TouchPhase::End, 30.0), touch(2, TouchPhase::End, 70.0)], + ] + } + + /// The total zoom [`read_with`] reports over `frames`, Enable Gestures set to `touch_gestures`. + fn touch_zoom(frames: Vec>, touch_gestures: bool) -> f32 { + let owner = Id::new("test-touch"); + let ctx = Context::default(); + configure(&ctx); + let mut zoom = 1.0; + for (n, events) in (0u32..).zip(frames) { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, owner, true, true, touch_gestures) { + zoom *= f; + } + }); + out.textures_delta.clear(); + } + zoom + } + + /// Preferences › Enhanced Controls › Enable Gestures (#204): on (the default) a two-finger + /// pinch on a touch screen zooms; off, it leaves the view alone. + #[test] + fn enable_gestures_switches_touch_screen_pinch_zoom() { + let on = touch_zoom(touch_pinch_frames(), true); + assert!((on - 2.0).abs() < 1e-4, "on: the fingers zoom ×2: {on}"); + let off = touch_zoom(touch_pinch_frames(), false); + assert_eq!(off, 1.0, "off: the fingers don't zoom"); + } + + /// Enable Gestures off only silences the touch screen: a trackpad pinch still zooms, also in a + /// frame where two fingers rest on the screen (egui's `zoom_delta` would report theirs instead). + #[test] + fn enable_gestures_off_keeps_the_trackpad_pinch() { + let zoom = touch_zoom(vec![vec![Event::Zoom(1.25)]], false); + assert!((zoom - 1.25).abs() < 1e-4, "no fingers down: {zoom}"); + let mut frames = touch_pinch_frames(); + frames[2] = vec![Event::Zoom(1.25)]; + let zoom = touch_zoom(frames, false); + assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index a02e8ed210..c8bfb03f3a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,9 +179,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From ca69059dd09d594875255a862cf2b5e74cac0bdc Mon Sep 17 00:00:00 2001 From: Brian T Date: Sun, 18 Oct 2026 14:23:26 -0700 Subject: [PATCH 0714/1452] Edit: Copy with no selection keeps fill and adjustment layers (#2344) (#7497) Copy refused fill and adjustment layers ("the active layer has no pixels"). With no pixel selection they are now taken whole, like smart objects, type, shape and group layers, and Paste recreates them in this or another document. An adjustment layer's clip spans the canvas, so paste placement and the OS-clipboard mirror stay consistent. Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated; the row stays partial (a single pixel layer still copies pixels only). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/edit_cmds.rs | 74 +++++++++++++++++++++++++++++----- docs/gaps.md | 7 ++-- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 ++- scorecard/ui.toml | 2 +- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..7cdb595212 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -35,15 +35,14 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; -/// several selected layers are [`multi_copy_ids`]. +/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no +/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers +/// still copy their pixels; several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 && crate::layer_copy_cmds::layered(d.doc.mode) - && d.doc - .layer(id) - .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) + && d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_))) } /// A scratch document in the source's colour, to hold copied layers. @@ -280,13 +279,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, - // A group has no pixels of its own: the clipboard pixels are what it shows. + // A group or fill layer has no pixels of its own: the clipboard pixels are what it + // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas + // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors + // the clip to the OS clipboard (an empty clip would let a stale OS image replace it). (None, Some(copies)) => { let mut shown = (**copies).clone(); for l in &mut shown.layers { l.visible = true; } - Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + let clip = lift(&merged_surface(&shown), None, canvas)?; + let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; + Clip { layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -1454,12 +1458,12 @@ mod tests { assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok()); - // An adjustment layer has no pixels of its own: Copy is only enabled while its mask is - // targeted (#2928). + // An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and + // its mask while that is targeted (#2928). s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.select", json!({"layer": a})).unwrap(); - assert!(!s.is_enabled("edit.copy"), "no pixels to copy"); + assert!(s.is_enabled("edit.copy"), "the layer itself can be copied"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied"); } @@ -1916,6 +1920,56 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy + /// takes them whole and Paste recreates them, in the same or another document, in one step. + #[test] + fn copy_paste_keeps_fill_and_adjustment_layers() { + for (depth, other) in [(8, 16), (16, 32), (32, 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") }; + let src = s.active().unwrap().active_layer.unwrap(); + assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied"); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows"); + let clip = s.clipboard.clone().unwrap(); + assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step"); + assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer"); + assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer"); + s.undo(); + assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste"); + s.redo(); + assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back"); + + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") }; + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas"); + s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background"); + } + } + + /// #2344: several adjustment layers, which show nothing alone, still copy and paste together. + #[test] + fn several_adjustment_layers_copy_together() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40])); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 5, "both are pasted"); + assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_)))); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..4457a87dde 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -199,8 +199,8 @@ what an agent can't finish alone. with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard - (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer + copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. - **Estimate:** 150–250 h. @@ -270,6 +270,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index c8bfb03f3a..b320028936 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -207,7 +207,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | | UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | done | [#217](https://github.com/storytold/photocraft/issues/217) | stamp_cmds.rs layer.stampVisible (Cmd+Alt+Shift+E) merges every visible layer into a new layer above the active one and keeps the originals; layer.stampDown (Cmd+Alt+E) too. As in Photoshop they have no menu item of their own: Alt + Layer > Merge Visible / Merge Down / Merge Layers run them (stamp_cmds::alt_variant, menus.rs); stamp_tests.rs alt_click_on_merge_items_stamps and cmd_alt_shift_e_stamps_visible_and_can_be_rebound | -| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard | +| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind | ## Type diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..3eca518c27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -167,7 +167,7 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | | Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 908efc6b32..7eff90c3ca 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -164,4 +164,4 @@ id = "UI-217-23" target = "Layer clipboard: copy/paste whole layers (masks, styles, type) between documents" status = "partial" issue = 217 -note = "layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard" +note = "Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind" From f3fb5781ef432aaeff42656865f9dcc9c94d17b3 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:23:32 +0300 Subject: [PATCH 0715/1452] Lens Blur: an alpha channel as the depth map source, as in Photoshop (#7400) (#7508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(filters): Lens Blur reads its depth from an alpha channel, listed in Depth Map › Source as in Photoshop (#7400) * test(filters): Lens Blur depth from an alpha channel by name or index, its errors, and the Source menu (#7400) --- crates/algo/src/blur2.rs | 2 +- crates/algo/src/lib.rs | 3 +- crates/algo/src/params_ext.rs | 3 + crates/engine/src/edit_menu_cmds.rs | 2 +- crates/engine/src/filters.rs | 5 ++ crates/engine/src/filters/validation.rs | 8 ++- crates/engine/src/filters_ext.rs | 16 ++++- crates/engine/src/filters_ext/tests.rs | 45 ++++++++++++ crates/ui-egui/src/filter_dialog.rs | 92 ++++++++++++++++++++++++- crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 12 files changed, 170 insertions(+), 9 deletions(-) diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs index e917405083..bb6b47656c 100644 --- a/crates/algo/src/blur2.rs +++ b/crates/algo/src/blur2.rs @@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V } src.get(x, y, n - 1) } - DepthSource::LayerMask => spec.depth_map?.get(x, y, 0), + DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0), }; Some(if spec.invert { 1.0 - d } else { d }) }; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 5a034e4561..ac4b3b1fcc 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -482,7 +482,8 @@ pub enum FilterParams { distribution: Distribution, monochromatic: bool, seed: u32, - /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller. + /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document + /// coordinates), supplied by the caller. #[serde(skip)] depth_map: Option>, }, diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs index 3a9572fb46..2349604196 100644 --- a/crates/algo/src/params_ext.rs +++ b/crates/algo/src/params_ext.rs @@ -182,6 +182,9 @@ pub enum DepthSource { None, Transparency, LayerMask, + /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the + /// caller like the layer mask. + Channel, } /// Built-in Shape Blur kernels. diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 6f86413d13..f9fab33e37 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -331,7 +331,7 @@ fn can_caf(s: &Session) -> std::result::Result<(), String> { } /// Mask surface (gray) of an alpha channel by index or name. -fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { +pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { match v { Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface), Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface), diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 221709080e..dd0ff8c803 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -292,6 +292,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let surf = match &mut l.content { LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { + // A smart filter re-renders from its params alone, without the document's + // channels, so Lens Blur's alpha-channel depth would be lost (#7400). + if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) { + return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into())); + } // Non-destructive: record the filter and re-render the smart object from its source. let sf = SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 068d37e9da..b2359ffa57 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -123,9 +123,11 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.lensBlur" => { - &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] - } + "filter.blur.lensBlur" => &[ + ("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), + ("depthMap", "none|transparency|layerMask|channel"), + ("distribution", "uniform|gaussian"), + ], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index e4f6523a19..0425e1932a 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -277,6 +277,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { depth: match s(p, "depthMap", "none") { "transparency" => DepthSource::Transparency, "layerMask" => DepthSource::LayerMask, + "channel" => DepthSource::Channel, _ => DepthSource::None, }, focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), @@ -429,8 +430,19 @@ fn map_from_document(doc: &Document, layer: Option) -> Option } /// Resolves inputs that live outside the target layer: Displace's map (`mapPath`, or -/// `mapDocument` index with optional `mapLayer` id). +/// `mapDocument` index with optional `mapLayer` id), and the alpha channel Lens Blur reads its +/// depth from (`depthChannel`, an index or a name). pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::LensBlur { depth: DepthSource::Channel, depth_map, .. } = fp { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let channel = p + .get("depthChannel") + .ok_or_else(|| EngineError::Other("Lens Blur: `depthMap` channel needs `depthChannel` (an alpha channel's index or name)".into()))?; + let surface = crate::edit_menu_cmds::channel_mask(doc, channel) + .ok_or_else(|| EngineError::Other(format!("Lens Blur: the document has no alpha channel {channel}")))?; + *depth_map = Some(Arc::new(algo::Image::read(surface, doc.bounds()))); + return Ok(()); + } let FilterParams::Displace { map, .. } = fp else { return Ok(()) }; if map.is_some() { return Ok(()); @@ -662,7 +674,7 @@ pub fn specs() -> Vec { "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], - r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask|channel","depthChannel":index|name?,"focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), cmd!( "filter.blur.shapeBlur", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 0f32c5367d..490568e87c 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -220,6 +220,51 @@ fn lens_blur_uses_the_layer_mask_as_depth() { assert_ne!(pixels(&s), before); } +/// #7400: an alpha channel is a Lens Blur depth source, by name or index, as Photoshop lists +/// the document's channels in Depth Map › Source. A missing channel is an error that changes +/// nothing, and on a Smart Object (whose smart filters re-render without the channels) it is +/// refused instead of silently blurring without depth. +#[test] +fn lens_blur_reads_an_alpha_channel_as_depth() { + let mut s = session(); + // A white channel: depth 1 everywhere. Creating it targets it; the filter is for the layer. + s.execute("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let before = pixels(&s); + for channel in [json!("Depth Map"), json!(0)] { + let p = |focal: f64| json!({"radius": 6, "depthMap": "channel", "depthChannel": channel, "focalDistance": focal}); + // Focal distance 255 puts depth 1 in focus: nothing blurs. + s.execute("filter.blur.lensBlur", p(255.0)).unwrap(); + assert_eq!(pixels(&s), before, "{channel}: in focus"); + s.execute("filter.blur.lensBlur", p(0.0)).unwrap(); + assert_ne!(pixels(&s), before, "{channel}: out of focus blurs"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s), before); + } + for bad in [ + json!({"radius": 6, "depthMap": "channel"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": "Nope"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": 7}), + ] { + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("Lens Blur"), "{bad}: {e}"); + assert_eq!(pixels(&s), before, "{bad}: nothing changed"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "{bad}: the document was replaced"); + assert_eq!(st.history.past_len(), past, "{bad}: a history step was recorded"); + } + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", json!({"radius": 6, "depthMap": "channel", "depthChannel": "Depth Map"})).unwrap_err().to_string(); + assert!(e.contains("Smart Object"), "{e}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "no smart filter was added"); + assert_eq!(st.history.past_len(), past, "no history step"); +} + #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 72126a69c8..d09caf4953 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -111,7 +111,8 @@ pub fn parse_spec(spec: &str) -> Vec { Kind::Color(crate::color_picker_ui::parse_hex(default)) } else if v == "doc" { Kind::Document - } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') { + } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') || v.trim_end_matches('?') == "index|name" { + // `index|name`: a reference (an alpha channel…) the dialog sets through another control. Kind::Json } else if let Some(n) = v.strip_prefix("int[").and_then(|r| r.strip_suffix(']')).and_then(|n| n.parse().ok()) { Kind::Grid(n) @@ -468,6 +469,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } + if command == LENS_BLUR { + lens_blur_channels(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -594,6 +598,37 @@ fn uses_logarithmic_slider(min: f32, max: f32) -> bool { min > 0.0 && max / min > 500.0 } +const LENS_BLUR: &str = "filter.blur.lensBlur"; + +/// Lens Blur's Depth Map Source lists the document's alpha channels after None, Transparency +/// and Layer Mask, as in Photoshop (#7400): their names go to `__channels`. A `channel` source +/// without a channel this document has (references aren't remembered) opens as None. +fn lens_blur_channels(app: &PhotocraftApp, f: &mut Map) { + let names: Vec = app.session.active().map(|d| d.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let known = f.get("depthChannel").and_then(|v| channel_index(v, &names)).is_some(); + if f.get("depthMap").and_then(Value::as_str) == Some("channel") && !known { + f.insert("depthMap".into(), json!("none")); + f.remove("depthChannel"); + } + f.insert("__channels".into(), json!(names)); +} + +/// Lens Blur's Source menu entry for the alpha channel at `index`. By index, not name: two +/// channels may share a name, and each must be choosable. +fn channel_choice(index: usize) -> String { + format!("channel:{index}") +} + +/// The alpha channel `v` names (an index, or a name: the first channel so named), as the +/// engine reads `depthChannel`. +fn channel_index(v: &Value, names: &[String]) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < names.len()), + Value::String(name) => names.iter().position(|n| n == name), + _ => None, + } +} + /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; @@ -767,6 +802,23 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string(); let labels: Vec = options.iter().map(|o| choice_label(o)).collect(); let mut opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect(); + // Lens Blur's Source: each alpha channel by its name, in place of `channel`. + let depth = cmd == LENS_BLUR && p.key == "depthMap"; + let names: Vec = if depth { + f.get("__channels").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() + } else { + Vec::new() + }; + let channels: Vec = (0..names.len()).map(channel_choice).collect(); + if depth { + opts.retain(|(v, _)| v != "channel"); + opts.extend(channels.iter().cloned().zip(names.iter().map(String::as_str))); + if cur == "channel" + && let Some(i) = f.get("depthChannel").and_then(|v| channel_index(v, &names)) + { + cur = channel_choice(i); + } + } // Like Photoshop, the profile menus also list every profile installed in // the system (printer and paper profiles), by description. let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { @@ -788,6 +840,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); + if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::().ok()) { + f.insert("depthChannel".into(), json!(i)); + cur = "channel".into(); + } else if depth { + f.remove("depthChannel"); + } f.insert(p.key.clone(), json!(cur)); }); } @@ -1468,6 +1526,38 @@ mod tests { assert_eq!(params["amount"].as_f64(), Some(37.5)); } + /// #7400: Lens Blur's Source lists the document's alpha channels, by name, each choosable even + /// when two share a name; `depthChannel` is set through that menu, not shown as a field, and + /// like the other references it isn't remembered, so a remembered `channel` source opens as None. + #[test] + fn lens_blur_source_lists_the_alpha_channels() { + let spec = photocraft_engine::commands::find(LENS_BLUR).unwrap().params; + let params = parse_spec(spec); + assert!(params.iter().any(|p| p.key == "depthChannel" && p.kind == Kind::Json), "depthChannel is no dialog field"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__channels"], json!(["Depth Map"])); + // The menu's values are channel indices, so two channels with one name are both choosable. + app.run("channel.new", json!({"name": "Depth Map", "fill": "black"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + let names: Vec = serde_json::from_value(app.ui.dialog_mut(id).unwrap().fields["__channels"].clone()).unwrap(); + assert_eq!(names, ["Depth Map", "Depth Map"]); + assert_eq!((channel_choice(0), channel_choice(1)), ("channel:0".to_string(), "channel:1".to_string())); + assert_eq!(channel_index(&json!(1), &names), Some(1), "the second one by its index"); + assert_eq!(channel_index(&json!("Depth Map"), &names), Some(0), "a name is the first so named, as the engine reads it"); + assert_eq!(channel_index(&json!(2), &names), None); + + app.session.prefs.edit(|p| { + p.dialogs.insert(LENS_BLUR.into(), json!({"depthMap": "channel", "depthChannel": "Depth Map"})); + }); + let id = open(&mut app, LENS_BLUR).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["depthMap"], "none", "no channel was remembered with it"); + assert!(f.get("depthChannel").is_none()); + } + #[test] fn wide_positive_ranges_use_the_logarithmic_slider_path() { assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius"); diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a170c96632..d5d58b2ff2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -743,6 +743,7 @@ Brush Settings 브러시 설정 Brushes 브러시 Channels 채널 + Channel 채널 Character 문자 Character Styles 문자 스타일 Clone Source 복제 원본 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8928778e47..0f242f8f63 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -772,6 +772,7 @@ Brush Settings 画笔设置 Brushes 画笔 Channels 通道 + Channel 通道 Character 字符 Character Styles 字符样式 Clone Source 仿制源 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0d68180573..621bee5518 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -777,6 +777,7 @@ Brush Settings 筆刷設定 Brushes 筆刷 Channels 色版 + Channel 色版 Character 字元 Character Styles 字元樣式 Clone Source 仿製來源 From e4d4ab4205867683b1c20ae28253191b813579f0 Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:28:21 -0700 Subject: [PATCH 0716/1452] Presets: an Export As preset survives a damaged sibling set (test for #6906) Extends a_damaged_preset_set_keeps_the_other_sets (#5602) to save an Export As preset, check it survives each damaged sibling set, and damage export_presets itself. Fails with export_presets: None in load_presets_json (the build break #6906 fixed), passes on main. Test suggested by dotCooCoo in #6906. --- crates/engine/src/presets/tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 1cde39abe5..978320e273 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -758,14 +758,22 @@ fn a_damaged_preset_set_keeps_the_other_sets() { let mut s = Session::new(); let settings = json!({"width": 1600, "height": 900, "resolution": 144}); s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap(); + let export = json!({ + "format": "jpg", "quality": 70, "lossless": false, "transparency": false, "scale": 50, "width": 640, "height": 480, + "lockAspect": true, "sizing": "pixels", "metadata": "exif", "profile": "srgb", "embedProfile": false, "tgaBits": "24" + }); + s.execute("file.export.presets.save", json!({"name": "Web JPEG", "settings": export})).unwrap(); let saved = s.presets.to_json(&s); - for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] { + for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches", "export_presets"] { let mut blob = saved.clone(); blob[key] = json!("not a preset set"); let mut next = Session::new(); next.load_presets_json(blob); assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`"); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); + if key != "export_presets" { + assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`"); + } } // Null sets keep their defaults, as before. let mut next = Session::new(); From 9c927993b5c114879040bf9bda0bdc0325341dbd Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:29:30 +0800 Subject: [PATCH 0717/1452] Preserve omitted PCraft previews until manifest commit --- crates/format/src/store.rs | 11 ++--- crates/format/tests/roundtrip.rs | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 7da5dd6b53..7ce04ef40f 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -881,17 +881,14 @@ impl PcraftWriter { // Pin all GC directories before removing stale previews or objects. // Unix cleanup then stays within these directories even if a pathname is swapped. let gc = BundleGc::new(dir)?; + write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; + // Omitted previews belong to the previous manifest until the new one commits. + // After commit, a locked leftover is only housekeeping, just like unused objects. for stale in [THUMB, COMPOSITE] { if !p.previews.iter().any(|(n, _)| *n == stale) { - let path = dir.join(stale); - validate_bundle_file(dir, &path)?; - match remove_bundle_file(dir, &path, &gc) { - Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {} - other => other?, - } + let _ = remove_bundle_file(dir, &dir.join(stale), &gc); } } - write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; // The manifest is committed: the save has succeeded. Removing objects it no longer // references is best-effort housekeeping (a vanished or locked file must not turn a // complete save into a reported failure); a leftover is retried on the next save. diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..5a1361bdd3 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -751,3 +751,74 @@ fn variables_follow_layer_id_remapping() { let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); } + +/// Removing previews is post-commit housekeeping, not part of publishing the new document. +#[test] +fn directory_save_without_previews_removes_stale_files_after_commit() { + let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("preview-cleanup"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + doc.name = "after".into(); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(!dir.join("thumb.png").exists()); + assert!(!dir.join("composite/preview.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A real Windows handle denying delete-sharing blocks manifest replacement after preparation. +#[cfg(windows)] +#[test] +fn failed_manifest_commit_preserves_omitted_previews() { + use std::os::windows::fs::OpenOptionsExt; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::with_background("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, depth, photocraft_color::Color::WHITE); + let dir = temp_dir("locked-manifest-previews"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + let old_doc = doc.clone(); + let paths = ["manifest.json", "thumb.png", "composite/preview.png"]; + let before: Vec<_> = paths.iter().map(|p| std::fs::read(dir.join(p)).unwrap()).collect(); + // FILE_SHARE_READ | FILE_SHARE_WRITE; deliberately omit FILE_SHARE_DELETE. + let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(dir.join("manifest.json")).unwrap(); + doc.name = "after".into(); + let error = writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(matches!(error, FormatError::Io(_)), "expected a real commit I/O failure: {error}"); + for (path, bytes) in paths.iter().zip(&before) { + assert_eq!(std::fs::read(dir.join(path)).ok().as_ref(), Some(bytes), "saved file lost after failed commit: {path}"); + } + assert_eq!(load_path(&dir).unwrap(), old_doc); + drop(held); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(!dir.join("thumb.png").exists()); + assert!(!dir.join("composite/preview.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } +} + +#[cfg(windows)] +#[test] +fn locked_stale_preview_does_not_fail_committed_save() { + use std::os::windows::fs::OpenOptionsExt; + + let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("locked-stale-preview"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(dir.join("thumb.png")).unwrap(); + doc.name = "after".into(); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(dir.join("thumb.png").exists(), "locked leftover remains until a later cleanup"); + assert!(!dir.join("composite/preview.png").exists()); + drop(held); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert!(!dir.join("thumb.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); +} From 86b4c67adf6dcf26836803c8e1b2fc0c957ff5e3 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:37:19 +0800 Subject: [PATCH 0718/1452] Expect post-commit best-effort preview cleanup on Unix --- crates/format/tests/directory_symlinks.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/format/tests/directory_symlinks.rs b/crates/format/tests/directory_symlinks.rs index 0302593f49..edeebbdc3d 100644 --- a/crates/format/tests/directory_symlinks.rs +++ b/crates/format/tests/directory_symlinks.rs @@ -121,7 +121,7 @@ mod permissions { } #[test] - fn denied_stale_preview_cleanup_preserves_the_previous_manifest() { + fn denied_stale_preview_cleanup_is_retried_after_a_successful_save() { let root = temp_dir("denied-preview-cleanup"); let bundle = root.join("project.pcraft"); let mut writer = PcraftWriter::new(); @@ -139,12 +139,14 @@ mod permissions { return; } let mut changed = empty_doc(); - changed.name = "must not commit".into(); + changed.name = "committed despite housekeeping failure".into(); let result = writer.save_dir(&changed, &bundle, &SaveOptions::default()); drop(restricted); - assert!(result.is_err(), "a required pre-commit cleanup failure is reported"); - assert_eq!(std::fs::read(preview).unwrap(), b"stale composite"); - assert_eq!(load_path(&bundle).unwrap(), original); + result.expect("a stale preview must not turn a committed save into an error"); + assert_eq!(std::fs::read(&preview).unwrap(), b"stale composite"); + assert_eq!(load_path(&bundle).unwrap(), changed); + writer.save_dir(&changed, &bundle, &SaveOptions::default()).unwrap(); + assert!(!preview.exists(), "a later save retries stale-preview cleanup"); std::fs::remove_dir_all(root).unwrap(); } From 2dcc8788d821576566c91fd631bed61fe3cfc4a8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 15:38:06 -0600 Subject: [PATCH 0719/1452] Move tool: a floating piece's outline also decides presses and the shared frame (#7538) Review follow-ups (CodeRabbit on #7574): - A marquee or lasso press while a piece floats tested the selection it was cut with, so a press in the margin the cut left (where no ants are) dragged the piece. It tests the piece's outline now; there it starts a new selection. - With several layers selected and the active one's pixels floating (a marquee Cmd-drag), Show Transform Controls shrank to that piece and dropped the other layers. The outline frames the piece only when it is framed alone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 72 +++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2a1b0a4e6..e642f6234a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4833,14 +4833,12 @@ fn transform_controls_several(app: &PhotocraftApp) -> bool { /// layers, joined into one. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { let st = app.session.active()?; - let b = transform_controls_layers(app) - .into_iter() - .map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)) - .filter(|b| !b.is_empty()) - .reduce(|a, b| a.union(&b))?; + let layers = transform_controls_layers(app); + let b = layers.iter().map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)).filter(|b| !b.is_empty()).reduce(|a, b| a.union(&b))?; // Floating selected pixels are framed by their outline, as their ants are (#7538), not by the - // selection they were cut with. - let b = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds()); + // selection they were cut with; a frame round other selected layers too keeps them all. + let alone = matches!(layers.as_slice(), [l] if photocraft_engine::float_cmds::floating(st).is_some_and(|f| f.layer == l.id)); + let b = app.session.active_index().filter(|_| alone).and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds()); let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -5647,7 +5645,11 @@ fn hidden_target(app: &PhotocraftApp, tool: Tool) -> Option<&'static str> { /// Is document point `p` inside the active document's selection? fn inside_selection(app: &PhotocraftApp, p: [f64; 2]) -> bool { - let Some(sel) = app.session.active().and_then(|st| st.doc.selection.as_ref()) else { return false }; + app.session.active().and_then(|st| st.doc.selection.as_ref()).is_some_and(|sel| covers(sel, p)) +} + +/// Does selection surface `sel` cover document point `p`? +fn covers(sel: &photocraft_raster::Surface, p: [f64; 2]) -> bool { let mut v = [0.0f32]; sel.read_pixel(p[0].floor() as i32, p[1].floor() as i32, &mut v); v[0] > 0.0 @@ -5668,7 +5670,10 @@ pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: e // ⌘ cuts, ⌘⌥ copies (`select.float`'s `copy`). let cut = mods.command && !mods.shift; if let Some(f) = app.session.active().and_then(photocraft_engine::float_cmds::floating) { - let on = inside_selection(app, [p[0] - f64::from(f.offset.0), p[1] - f64::from(f.offset.1)]); + // A press on the piece's outline (where its ants are) drags it; one in the margin the cut + // left of the selection starts a new selection (#7538). + let at = [p[0] - f64::from(f.offset.0), p[1] - f64::from(f.offset.1)]; + let on = f.parts.outline().map_or_else(|| inside_selection(app, at), |o| covers(o, at)); return (on && !mods.shift && (!mods.alt || mods.command) && (cut || !clicky)).then_some(true); } if !inside_selection(app, p) { @@ -8410,6 +8415,55 @@ mod transform_controls_tests { assert_eq!(frame(&app), [50, 45, 100, 85], "dropped"); } + /// A transparent 200² document: a red square (40..90, 40..80) on its layer, a loose marquee + /// (20..120, 20..110) round it, cut and floated 10, 5 to the right and down. + fn floating_square() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + app.session.execute("select.rect", json!({"x": 40, "y": 40, "width": 50, "height": 40})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + app.sync_views(); + app.session.execute("select.float", json!({"dx": 10, "dy": 5})).unwrap(); + app + } + + /// #7538: a selection tool's press on a floating piece's ants drags it; one in the margin its + /// cut left of the loose marquee draws a new selection, as the ants say. + #[test] + fn a_press_in_the_margin_of_a_floating_piece_starts_a_new_selection() { + let app = floating_square(); + let none = egui::Modifiers::NONE; + for tool in [Tool::RectMarquee, Tool::Lasso] { + assert_eq!(selection_drag_kind(&app, tool, [70.0, 60.0], none), Some(true), "{tool:?}: on the square drags it"); + assert_eq!(selection_drag_kind(&app, tool, [33.0, 28.0], none), None, "{tool:?}: in the old marquee's margin"); + } + } + + /// Several selected layers keep one frame round them all while the active one's pixels float: + /// the piece's outline frames it only when it is the only layer framed. + #[test] + fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { + let mut app = floating_square(); + app.session.execute("select.drop", json!({})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + assert!(transform_controls_several(&app), "two layers framed"); + app.session.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some()); + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let r = transform_controls_rect(&app, &xf).unwrap(); + let (lt, rb) = (xf.to_doc(r.left_top()), xf.to_doc(r.right_bottom())); + assert!(rb[0] - lt[0] > 60.0 && rb[1] - lt[1] > 60.0, "the frame isn't shrunk to the one piece: {lt:?} {rb:?}"); + } + #[test] fn several_selected_layers_share_one_frame_without_handles() { // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform From 1ec378bd0289481cad3405eee90800ec294717ad Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 23:00:17 +0100 Subject: [PATCH 0720/1452] fix(atn): refuse strict export of options the descriptors can't carry Strict .atn export wrote these steps without one of their options, so the imported step replayed differently. Neither side maps the options, so the step is now refused (strict export fails, a partial export skips it) instead of silently changing: - New Layer: clipToPrevious, a colour label, fillNeutral, position below (#7502, #7512, #7513, #7514) - New Document: a black or hex background, or backgroundColor (#7503) - Load Selection: invert, or an add/subtract/intersect operation (#7506) - Solid Color: a colour the opaque RGB descriptor can't hold (#7507) - Layer Properties: keys other than layer, name, opacity, fill, blend, visible and color, such as locks, clipping or a layers list (#7196, #7246) - Group Layers: an explicit layer target (#7236) - any mapped 32-bit integer field outside i32, which was clamped (an Add Noise seed above i32::MAX replayed as i32::MAX, #7287) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 64 +++++++++++++++++++++++--- crates/engine/src/atn_cmds/tests.rs | 70 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..66ad6f0c54 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -435,7 +435,15 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { Kind::Px => px(v.as_f64()?), Kind::Pct => pct(v.as_f64()?), Kind::Ang => ang(v.as_f64()?), - Kind::Long => long(v.as_f64()?), + // A value outside the descriptor's 32-bit integer refuses the step rather than being + // clamped to another value (an Add Noise seed above i32::MAX replayed as i32::MAX, #7287). + Kind::Long => { + let n = v.as_f64()?.round(); + if !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + return None; + } + long(n) + } Kind::Dbl => D::Double(v.as_f64()?), Kind::Bool => D::Boolean(v.as_bool()?), Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), @@ -1110,6 +1118,18 @@ fn make_to(id: &str, p: &Value) -> Option { }; Some(match id { "layer.new.layer" => { + // The descriptor carries the name, opacity and mode only. A step that also clips to + // the layer below, labels the layer, fills it with the neutral colour or places it + // below the active layer would replay as a different layer, so it isn't exported + // (strict export fails, a partial one skips it: #7502, #7512, #7513, #7514). + let set = |key: &str, default: Value| p.get(key).is_some_and(|v| !v.is_null() && *v != default); + if set("clipToPrevious", Value::Bool(false)) + || set("color", json!("none")) + || set("fillNeutral", Value::Bool(false)) + || set("position", json!("above")) + { + return None; + } let mut u = with_name("Lyr "); if let Some(o) = jnum(p, "opacity") { u = u.with("Opct", pct(o * 100.0)); @@ -1121,6 +1141,11 @@ fn make_to(id: &str, p: &Value) -> Option { } "layer.new.group" => desc("null").with("null", class_ref("layerSection")).with("Usng", D::Descriptor(with_name("layerSection"))), "layer.groupLayers" => { + // The descriptor groups the selected layers; an explicit `layer` would group another + // one on replay (#7236). + if p.get("layer").is_some_and(|v| !v.is_null()) { + return None; + } desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")).with("Usng", D::Descriptor(with_name("layerSection"))) } "layer.new.layerViaCopy" | "layer.new.layerViaCut" => { @@ -1132,8 +1157,10 @@ fn make_to(id: &str, p: &Value) -> Option { } "layer.newFillLayer.solidColor" => { let mut t = desc("solidColorLayer"); - if let Some(c) = jstr(p, "color").and_then(color_desc) { - t = t.with("Clr ", D::Descriptor(c)); + // The descriptor's colour is opaque RGB: a colour with alpha, or one it can't parse, + // would replay as the default black (#7507). + if let Some(c) = p.get("color").filter(|v| !v.is_null()) { + t = t.with("Clr ", D::Descriptor(c.as_str().and_then(color_desc)?)); } desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } @@ -1170,10 +1197,19 @@ fn make_to(id: &str, p: &Value) -> Option { } n = n.with("Wdth", px(jnum(p, "width").unwrap_or(1920.0))).with("Hght", px(jnum(p, "height").unwrap_or(1080.0))); n = n.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: jnum(p, "resolution").unwrap_or(72.0) }); - let fill = match jstr(p, "background") { - Some("transparent") => "Trns", - Some("backgroundColor") => "BckC", - _ => "Wht ", + // `Fl ` is white, transparent or Photoshop's own background colour: black, a hex + // colour or a given `backgroundColor` would replay as something else (#7503). + if p.get("backgroundColor").is_some_and(|v| !v.is_null()) { + return None; + } + let fill = match p.get("background").filter(|v| !v.is_null()) { + None => "Wht ", + Some(b) => match b.as_str()? { + "white" => "Wht ", + "transparent" => "Trns", + "backgroundColor" => "BckC", + _ => return None, + }, }; n = n.with("Fl ", enm("Fl ", fill)).with("Dpth", long(jnum(p, "depth").unwrap_or(8.0))); desc("null").with("Nw ", D::Descriptor(n)) @@ -1327,6 +1363,14 @@ fn set_to(id: &str, p: &Value) -> Option { "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), + // The descriptor loads a plain new selection: inverting it or combining it with the current + // one (add, subtract, intersect) would be lost (#7506). + "select.loadSelection" + if p.get("invert").is_some_and(|v| *v != Value::Bool(false) && !v.is_null()) + || p.get("operation").is_some_and(|v| *v != json!("new") && !v.is_null()) => + { + return None; + } "select.loadSelection" => match jstr(p, "channel")? { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), "targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])), @@ -1356,6 +1400,12 @@ fn set_to(id: &str, p: &Value) -> Option { sel(D::Descriptor(desc(cls).with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))) } "layer.setProps" | "layer.renameLayer" | "layer.setLabelColor" => { + // Only these keys map onto the `Lyr ` descriptor; locks, clipping, Advanced Blending + // channels or an explicit `layers` list would be dropped (#7196, #7246). + const MAPPED: [&str; 7] = ["layer", "name", "opacity", "fill", "blend", "visible", "color"]; + if p.as_object().is_some_and(|m| m.iter().any(|(k, v)| !v.is_null() && !MAPPED.contains(&k.as_str()))) { + return None; + } let mut t = desc("Lyr "); if let Some(n) = jstr(p, "name") { t = t.with("Nm ", text(n)); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..7a4e7ec579 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1033,6 +1033,76 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// #7502, #7512, #7513, #7514: a New Layer step that clips to the layer below, has a colour +/// label, fills with the neutral colour or goes below the active layer isn't exported, since the +/// descriptor carries only its name, opacity and mode. Those options at their defaults still +/// export, and the step round-trips. +#[test] +fn new_layer_options_the_descriptor_cannot_carry_refuse_strict_export() { + let new_layer = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.new.layer".into(), p)], ..Default::default() }; + for p in [ + json!({"name": "New", "clipToPrevious": true}), + json!({"name": "New", "color": "red"}), + json!({"name": "New", "mode": "Overlay", "fillNeutral": true}), + json!({"name": "New", "position": "below"}), + json!({"name": "New", "clipToPrevious": 1}), + ] { + assert!(export("Audit7", &[&new_layer(p.clone())], &[], false).is_err(), "{p}"); + let (_, skipped) = export("Audit7", &[&new_layer(p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.new.layer".into() }], "{p}"); + } + let defaults = + json!({"name": "New", "mode": "Multiply", "opacity": 0.6, "clipToPrevious": false, "color": "none", "fillNeutral": false, "position": "above"}); + let (bytes, skipped) = export("Audit7", &[&new_layer(defaults)], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("layer.new.layer".to_string(), json!({"name": "New", "mode": "Multiply", "opacity": 0.6}))]); +} + +/// Options the exported descriptors can't carry refuse strict export (and a partial export skips +/// the step) instead of exporting a step that replays differently; the forms they can carry still +/// export (#7196, #7236, #7246, #7287, #7503, #7506, #7507). +#[test] +fn options_the_descriptors_cannot_carry_refuse_strict_export() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + let refused = [ + ("layer.groupLayers", json!({"layer": 3, "name": "Group"})), + ("layer.newFillLayer.solidColor", json!({"color": "#c0603080"})), + ("layer.newFillLayer.solidColor", json!({"color": "teal"})), + ("file.new", json!({"width": 24, "height": 18, "background": "black"})), + ("file.new", json!({"width": 24, "height": 18, "background": "#ab2233"})), + ("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor", "backgroundColor": [0.2, 0.4, 0.6]})), + ("select.loadSelection", json!({"channel": "Alpha 1", "invert": true})), + ("select.loadSelection", json!({"channel": "Alpha 1", "operation": "subtract"})), + ("layer.setProps", json!({"opacity": 0.5, "locked": true})), + ("layer.setProps", json!({"opacity": 0.5, "clipped": true})), + ("layer.setProps", json!({"layers": [2, 3], "opacity": 0.5})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483648_u64})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 4294967295_u64})), + ]; + for (id, p) in refused { + assert!(export("Audit7", &[&one(id, p.clone())], &[], false).is_err(), "{id} {p}"); + let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "{id} {p}"); + } + let carried = [ + ("layer.groupLayers", json!({"name": "Group"})), + ("layer.newFillLayer.solidColor", json!({"color": "#c06030"})), + ("file.new", json!({"width": 24, "height": 18, "background": "white"})), + ("file.new", json!({"width": 24, "height": 18, "background": "transparent"})), + ("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor"})), + ("select.loadSelection", json!({"channel": "Alpha 1", "invert": false, "operation": "new"})), + ("layer.setProps", json!({"layer": 2, "opacity": 0.5, "visible": true})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483647})), + ]; + for (id, p) in carried { + let (bytes, skipped) = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + assert!(skipped.is_empty(), "{id} {p}"); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps.first().map(|(i, _)| i.as_str()), Some(id), "{p}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From 49570a1678a3068510bc17b3bb3b727e32c2f6ac Mon Sep 17 00:00:00 2001 From: seotarek Date: Mon, 19 Oct 2026 01:07:33 +0300 Subject: [PATCH 0721/1452] test(atn): assert exported descriptor target references and check state invariance on rejections --- crates/engine/src/atn_cmds/tests.rs | 65 ++++++++++++++--------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index e3b533e4c6..a977067721 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1074,20 +1074,41 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - // Named layer reference exports successfully + let parse_first_desc = |v: &Value| -> Descriptor { + let bytes = photocraft_paint::tile::b64_decode(v["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + file.actions[0].items[0].descriptor.clone().unwrap() + }; + + // Named layer reference exports successfully with Name reference to "A" s.actions.list.push(Action::new("DeleteNamed", vec![("layer.delete".into(), json!({"layer": "A"}))])); let saved_named = s.execute("actions.save", json!({"action": "DeleteNamed"})).unwrap(); assert_eq!(saved_named["steps"], 1); + let named_desc = parse_first_desc(&saved_named); + match get_ref(&named_desc, "null").unwrap() { + [ReferenceItem::Name { name, .. }] => assert_eq!(name.to_string_lossy(), "A"), + other => panic!("expected Name reference to A, got {other:?}"), + } - // Omitted layer reference (target selection) exports successfully + // Omitted layer reference (target selection) exports successfully with Trgt enumerated reference s.actions.list.push(Action::new("DeleteTarget", vec![("layer.delete".into(), json!({}))])); let saved_target = s.execute("actions.save", json!({"action": "DeleteTarget"})).unwrap(); assert_eq!(saved_target["steps"], 1); + let target_desc = parse_first_desc(&saved_target); + match get_ref(&target_desc, "null").unwrap() { + [ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")), + other => panic!("expected Trgt enumerated reference, got {other:?}"), + } - // Explicit null layer reference also exports successfully as target selection + // Explicit null layer reference also exports successfully as target selection with Trgt enumerated reference s.actions.list.push(Action::new("DeleteNull", vec![("layer.delete".into(), json!({"layer": null}))])); let saved_null = s.execute("actions.save", json!({"action": "DeleteNull"})).unwrap(); assert_eq!(saved_null["steps"], 1); + let null_desc = parse_first_desc(&saved_null); + match get_ref(&null_desc, "null").unwrap() { + [ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")), + other => panic!("expected Trgt enumerated reference, got {other:?}"), + } // Explicit numeric ID reference cannot be silently converted to current target in strict export s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); @@ -1100,38 +1121,14 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { let err_msg = err_delete.unwrap_err().to_string(); assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}"); - // Same for layer.duplicate with numeric ID - s.actions.list.push(Action::new("DuplicateNumeric", vec![("layer.duplicate".into(), json!({"layer": 42, "name": "Copy"}))])); - let err_dup = s.execute("actions.save", json!({"action": "DuplicateNumeric"})); - assert!(err_dup.is_err(), "strict export must reject numeric layer ID for layer.duplicate rather than falling back to current target"); - - // Same for duplicate into new document with numeric ID - s.actions.list.push(Action::new("DuplicateNewDocNumeric", vec![("layer.duplicate".into(), json!({"layer": 42, "document": "new"}))])); - let err_dup_new = s.execute("actions.save", json!({"action": "DuplicateNewDocNumeric"})); - assert!(err_dup_new.is_err(), "strict export must reject numeric layer ID for duplicate into new document"); - - // Same for layer.translate with numeric ID - s.actions.list.push(Action::new("TranslateNumeric", vec![("layer.translate".into(), json!({"layer": 42, "dx": 5, "dy": 5}))])); - let err_trans = s.execute("actions.save", json!({"action": "TranslateNumeric"})); - assert!(err_trans.is_err(), "strict export must reject numeric layer ID for layer.translate rather than falling back to current target"); - - // Same for layer.setProps with numeric ID - s.actions.list.push(Action::new("SetPropsNumeric", vec![("layer.setProps".into(), json!({"layer": 42, "opacity": 0.5}))])); - let err_set = s.execute("actions.save", json!({"action": "SetPropsNumeric"})); - assert!(err_set.is_err(), "strict export must reject numeric layer ID for layer.setProps"); - - // Same for visibility with numeric ID - s.actions.list.push(Action::new("HideNumeric", vec![("layer.hideLayers".into(), json!({"layer": 42}))])); - let err_hide = s.execute("actions.save", json!({"action": "HideNumeric"})); - assert!(err_hide.is_err(), "strict export must reject numeric layer ID for layer.hideLayers"); - - // Same for edit.transform with numeric ID - s.actions.list.push(Action::new("TransformNumeric", vec![("edit.transform".into(), json!({"layer": 42, "dx": 5, "dy": 5}))])); - let err_transf = s.execute("actions.save", json!({"action": "TransformNumeric"})); - assert!(err_transf.is_err(), "strict export must reject numeric layer ID for edit.transform"); - - // Same for layer.renameLayer, layer.setLabelColor, layer.showLayers, layer.select, and each layer.arrange.* command + // Same rejection and state invariance for remaining commands with numeric layer reference for (name, id, params) in [ + ("DuplicateNumeric", "layer.duplicate", json!({"layer": 42, "name": "Copy"})), + ("DuplicateNewDocNumeric", "layer.duplicate", json!({"layer": 42, "document": "new"})), + ("TranslateNumeric", "layer.translate", json!({"layer": 42, "dx": 5, "dy": 5})), + ("SetPropsNumeric", "layer.setProps", json!({"layer": 42, "opacity": 0.5})), + ("HideNumeric", "layer.hideLayers", json!({"layer": 42})), + ("TransformNumeric", "edit.transform", json!({"layer": 42, "dx": 5, "dy": 5})), ("RenameNumeric", "layer.renameLayer", json!({"layer": 42, "name": "Renamed"})), ("LabelNumeric", "layer.setLabelColor", json!({"layer": 42, "color": "red"})), ("ShowNumeric", "layer.showLayers", json!({"layer": 42})), From 897e1d744ac81c8ff1afa41e524ca29f714cfcee Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 23:07:52 +0100 Subject: [PATCH 0722/1452] fix(atn): keep a Transform Selection's interpolation; refuse what the transform descriptor can't carry The importer read a transform's interpolation (Intr) only for a layer target, so a Transform Selection replayed with the default resampler (#7515). The export scaled and rotated about the centre without skew or warp, so a step with another reference point, a skew, a warp or the corners alias of quad exported as a plain scale; it is now refused (#7516, #7517, #7518, #7519). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 22 +++++++++++++++++++--- crates/engine/src/atn_cmds/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..385b08960a 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1624,8 +1624,15 @@ fn transform_from(d: &Descriptor) -> Mapped { // to keep its sub-pixel placement and interpolation. let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; + // The resampling applies to a selection as much as to a layer (#7515). + let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")); match (ref_class(r).as_deref(), target(r)) { - (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), + (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { + if let Some(i) = interpolation { + p.insert("interpolation".into(), i.into()); + } + step("select.transformSelection", Value::Object(p)) + } (Some("Lyr"), _) => { let Some(mut lp) = layer_target(r) else { return unsupported }; if geometry_only { @@ -1634,7 +1641,7 @@ fn transform_from(d: &Descriptor) -> Mapped { return step("layer.translate", Value::Object(lp)); } lp.extend(p); - if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + if let Some(i) = interpolation { lp.insert("interpolation".into(), i.into()); } step("edit.transform", Value::Object(lp)) @@ -1644,7 +1651,16 @@ fn transform_from(d: &Descriptor) -> Mapped { } fn transform_to(id: &str, p: &Value) -> Option { - if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if ["quad", "corners", "matrix", "rect"].iter().any(|k| given(k)) { + return None; + } + // The descriptor scales and rotates about the centre (`FTcs` average) without skew or warp: + // another reference point, a skew or a warp would replay as a different transform (#7516, + // #7517, #7518, #7519). + let nonzero = |k: &str| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)); + let off_centre = p.get("reference").is_some_and(|v| !v.is_null() && *v != json!("center")); + if nonzero("skewX") || nonzero("skewY") || off_centre || ["warp", "style", "mesh", "grid"].iter().any(|k| given(k)) { return None; } let target = match id { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..30bc733a58 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1033,6 +1033,33 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// #7515: a Transform Selection's interpolation survives the round trip as a layer transform's +/// does. #7516-#7519: a reference point other than the centre, a skew or a warp can't be written, +/// so the step is refused instead of replaying as a plain scale. +#[test] +fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + let p = json!({"dx": 0.7, "dy": 0, "scaleX": 140, "scaleY": 80, "interpolation": "nearest"}); + let (bytes, skipped) = export("Audit7", &[&one("select.transformSelection", p.clone())], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("select.transformSelection".to_string(), p)]); + for id in ["select.transformSelection", "edit.transform"] { + for p in [ + json!({"scaleX": 140, "reference": "topLeft"}), + json!({"scaleX": 140, "reference": [3, 4]}), + json!({"scaleX": 140, "skewX": 12}), + json!({"scaleX": 140, "skewY": -5}), + json!({"warp": "arc"}), + json!({"corners": [[0, 0], [10, 0], [10, 10], [0, 10]]}), + ] { + assert!(export("Audit7", &[&one(id, p.clone())], &[], false).is_err(), "{id} {p}"); + } + let carried = json!({"scaleX": 140, "reference": "center", "skewX": 0, "skewY": 0}); + assert!(export("Audit7", &[&one(id, carried.clone())], &[], false).is_ok(), "{id} {carried}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From 75626fb0649a322ee9ef96a80e9d842cc7261d2a Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 23:09:22 +0100 Subject: [PATCH 0723/1452] test(atn): use a named layer target in the carried Layer Properties case A numeric target exports as the active layer (#7248, handled by #7262), so it didn't show the carried form. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 7a4e7ec579..41c2a5f6eb 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1092,7 +1092,8 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() { ("file.new", json!({"width": 24, "height": 18, "background": "transparent"})), ("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor"})), ("select.loadSelection", json!({"channel": "Alpha 1", "invert": false, "operation": "new"})), - ("layer.setProps", json!({"layer": 2, "opacity": 0.5, "visible": true})), + // A named target (a numeric one is #7248, #7262). + ("layer.setProps", json!({"layer": "Pixels", "opacity": 0.5, "visible": true})), ("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483647})), ]; for (id, p) in carried { From 6b4acf299f26fc3eb71713d5c9c3c0ef10e0b7c3 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 23:14:12 +0100 Subject: [PATCH 0724/1452] fix(atn): refuse strict export of canvas-bound selection modifies and placed pattern buckets Feather, Contract and Smooth (and Expand) carry only their radius, so applyAtCanvasBounds was dropped (#7540, #7541, #7542); the event table gains an uncarried: list of such flags. A pattern Paint Bucket's Pttn names no pattern and holds no placement, so an explicit pattern, scale, angle or phase replayed as the current pattern at its defaults (#7543, #7544, #7545, #7546). Those steps are now refused instead. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 36 +++++++++++++++++++++++++---- crates/engine/src/atn_cmds/tests.rs | 27 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..77a4475b45 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -871,6 +871,21 @@ struct Event { } macro_rules! ev { + // `uncarried`: flags the command reads that the event's descriptor has no field for; a step + // with one of them set isn't exported (strict export fails, a partial one skips it). + ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr, uncarried: $flags:expr) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[$id], + from: |d| match fields_from(d, $fields) { + Some(m) => step($id, Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| if pixel_target(p) && !flag_set(p, $flags) { fields_to(p, $fields, desc("null")) } else { None }, + } + }; ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => { Event { code: $code, @@ -886,6 +901,11 @@ macro_rules! ev { }; } +/// Whether any of the boolean `flags` is on in `p` (present, not null and not `false`). +fn flag_set(p: &Value, flags: &[&str]) -> bool { + flags.iter().any(|f| p.get(*f).is_some_and(|v| !v.is_null() && *v != Value::Bool(false))) +} + /// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's /// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such /// a step can't be exported faithfully. @@ -2053,6 +2073,14 @@ fn fill_to(id: &str, p: &Value) -> Option { d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); // `source` is the command's older name for `contents`. d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") { + // `Pttn` names no pattern and holds no placement: a chosen pattern, a scale, an angle + // or a phase would replay with the current pattern at its defaults (#7543, #7544, + // #7545, #7546). + let num = |k: &str, default: f64| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(default)); + let phase = p.get("phase").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| a.iter().any(|c| c.as_f64() != Some(0.0)))); + if p.get("pattern").is_some_and(|v| !v.is_null()) || num("scale", 100.0) || num("angle", 0.0) || phase { + return None; + } d.with("Usng", enm("FlCn", "Pttn")) } else { // An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `. @@ -2915,10 +2943,10 @@ const EVENTS: &[Event] = &[ }, // Selection. ev!("Invs", "inverse", "Inverse", "select.inverse", &[]), - ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]), - ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]), - ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]), - ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]), + ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)], uncarried: &["applyAtCanvasBounds"]), + ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)], uncarried: &["applyAtCanvasBounds"]), + ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)], uncarried: &["applyAtCanvasBounds"]), + ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)], uncarried: &["applyAtCanvasBounds"]), ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..c5624551c5 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1033,6 +1033,33 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// #7540-#7542: Feather, Contract and Smooth (and Expand) with Apply Effect at Canvas Bounds, and +/// #7543-#7546: a pattern Paint Bucket with a chosen pattern, scale, angle or phase, can't be +/// written, so strict export refuses them. Their defaults still export and round-trip. +#[test] +fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] { + assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}"); + let plain = json!({"radius": 2, "applyAtCanvasBounds": false}); + let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]); + } + let bucket = |extra: Value| { + let mut p = json!({"x": 5, "y": 5, "contents": "pattern", "opacity": 70, "tolerance": 80}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + one("paint.bucket", p) + }; + for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}"); + } + for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From c6fd71241d80798d6ba1693fd02deb5cb968a09b Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 19 Oct 2026 00:15:53 +0200 Subject: [PATCH 0725/1452] docs: add user docs in docs/user (#3951) * docs: add user docs for PhotoCraft Adds docs/user/ (published at getartcraft.com/docs/photocraft), the docs check and publish workflows (copied from craft-launcher), and a User docs rule in AGENTS.md. Checker: 0 error(s), 0 warning(s). Source notes per page: - index.md: README (intro, status, Crafting Apps table), website crafting-apps.ts photocraft entry, links.rs (Discord, issues), menus.rs (Help > Report an Issue). - getting-started/index.md: menus.rs and menu_catalog.rs labels, panels.rs (Layers, Properties, History), dialogs.rs (Create button). - getting-started/install.md: README Downloads and Get started sections, gh release view v0.7.0 asset list, craft-launcher catalog.rs (lists PhotoCraft). - guides/open-and-save-files.md: apps/photocraft/src/services.rs (open and save filters), README Formats and PSD sections, docs/file-format-parity.md. - guides/adjust-colors-with-adjustment-layers.md: menu_catalog.rs, panels.rs (footer button, adjustment list), lib.rs (Properties shows new layer). - guides/select-and-mask.md: menu_catalog.rs Select and Layer Mask menus, state.rs tool names, retouch_ui.rs (Select Subject button and message). - guides/add-and-style-text.md: state.rs (Horizontal Type Tool, T), type_tool.rs, menu_catalog.rs Layer Style and Type menus, dialogs.rs. - guides/export-an-image.md: export_dialog.rs (labels, formats, options). - troubleshooting/taskbar-turns-black.md: issues 3550, 3864, 3430, 3144, PR 3308 (git: not an ancestor of v0.7.0), prefs_ui.rs (System title bar). - troubleshooting/text-moves-when-editing.md: issues 3795, 1317, PR 3819 (open). - troubleshooting/get-help.md: menus.rs, links.rs, gpu_status.rs (System Info content). - faq/*: README (status, license badge, offline, formats), LICENSE-MIT and LICENSE-APACHE, docs/file-format-parity.md. Co-Authored-By: Claude Opus 5.5 * docs(user): apply final review fixes Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 4 items 1-9: remove x86 row and TGA resolution, formats, new photocraft-does-not-start page, mask label, taskbar workaround, wording, Affinity read-only, index. Co-Authored-By: Claude Opus 5.5 * docs(user): use the standard section title Co-Authored-By: Claude Opus 5.5 * docs(user): describe PhotoCraft 0.7.0 startup behaviour Co-Authored-By: Claude Opus 5.5 * docs(user): tighten PhotoCraft startup wording Drop the unsourced "slower" claim for CPU mode and give the exact Preferences path on Windows and macOS. Co-Authored-By: Claude Opus 5.5 * docs: pin the docs checker to a commit and its SHA-256 Co-Authored-By: Claude Opus 5.5 * docs: drop the docs workflows until the checker is on artcraft-services main Co-Authored-By: Claude Opus 5.5 * docs: re-verify against 0.8.2 - taskbar and PSD text pages: both fixes ship in 0.8.2; tell users to update and drop the "not in a release yet" wording and workarounds - does not start: 0.8.2 fixes the Intel Iris Pro/Monterey Device(Lost) case; OpenCore Legacy Patcher Macs still have no fix - install: Windows 10 or later, 32-bit Windows builds, RISC-V deb/rpm, Wayland paste and XWayland notice - open and save: Krita and Paint.NET filters, Krita/PCX/FITS formats, save progress, JPEG/WebP encoding kept on Save - export: TGA Resolution, multi-layer Export As and Quick Export - text: Shift-click starts new text over existing type - CLAUDE.md is now a regular file pointing to AGENTS.md - verified: 0.8.2 on every page Co-Authored-By: Claude Opus 5.5 * Drop the root AGENTS.md docs rule; keep the PR to docs/user/ Co-Authored-By: Claude Opus 5.5 * docs(user): drop the stale missing-tools count from the Photoshop FAQ Re-verified the page against v0.9.0. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/user/AGENTS.md | 60 +++++++++++++++++++ docs/user/CLAUDE.md | 1 + docs/user/faq/index.md | 10 ++++ docs/user/faq/is-photocraft-free.md | 14 +++++ docs/user/faq/open-photoshop-files.md | 16 +++++ docs/user/faq/replace-photoshop.md | 12 ++++ docs/user/getting-started/index.md | 21 +++++++ docs/user/getting-started/install.md | 59 ++++++++++++++++++ docs/user/guides/add-and-style-text.md | 36 +++++++++++ .../adjust-colors-with-adjustment-layers.md | 32 ++++++++++ docs/user/guides/export-an-image.md | 38 ++++++++++++ docs/user/guides/index.md | 14 +++++ docs/user/guides/open-and-save-files.md | 32 ++++++++++ docs/user/guides/select-and-mask.md | 40 +++++++++++++ docs/user/index.md | 25 ++++++++ docs/user/troubleshooting/get-help.md | 19 ++++++ docs/user/troubleshooting/index.md | 11 ++++ .../photocraft-does-not-start.md | 58 ++++++++++++++++++ .../troubleshooting/taskbar-turns-black.md | 29 +++++++++ .../text-moves-when-editing.md | 29 +++++++++ 20 files changed, 556 insertions(+) create mode 100644 docs/user/AGENTS.md create mode 100644 docs/user/CLAUDE.md create mode 100644 docs/user/faq/index.md create mode 100644 docs/user/faq/is-photocraft-free.md create mode 100644 docs/user/faq/open-photoshop-files.md create mode 100644 docs/user/faq/replace-photoshop.md create mode 100644 docs/user/getting-started/index.md create mode 100644 docs/user/getting-started/install.md create mode 100644 docs/user/guides/add-and-style-text.md create mode 100644 docs/user/guides/adjust-colors-with-adjustment-layers.md create mode 100644 docs/user/guides/export-an-image.md create mode 100644 docs/user/guides/index.md create mode 100644 docs/user/guides/open-and-save-files.md create mode 100644 docs/user/guides/select-and-mask.md create mode 100644 docs/user/index.md create mode 100644 docs/user/troubleshooting/get-help.md create mode 100644 docs/user/troubleshooting/index.md create mode 100644 docs/user/troubleshooting/photocraft-does-not-start.md create mode 100644 docs/user/troubleshooting/taskbar-turns-black.md create mode 100644 docs/user/troubleshooting/text-moves-when-editing.md diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 0000000000..dd791ea1f5 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,60 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/photocraft. The file path is the URL: +`troubleshooting/taskbar-turns-black.md` → `/docs/photocraft/troubleshooting/taskbar-turns-black`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using PhotoCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install PhotoCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "PhotoCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths read `File › Open…`. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Export an image as PNG or JPEG + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.7.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/photocraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 0000000000..e7bcfb70ed --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 0000000000..c50180a390 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,10 @@ +--- +title: FAQ +description: Short answers about PhotoCraft's price, Photoshop files and how it compares with Photoshop. +order: 40 +verified: 0.8.2 +--- + +- [Is PhotoCraft free?](is-photocraft-free.md) +- [Can PhotoCraft open my Photoshop files?](open-photoshop-files.md) +- [Can PhotoCraft replace Photoshop?](replace-photoshop.md) diff --git a/docs/user/faq/is-photocraft-free.md b/docs/user/faq/is-photocraft-free.md new file mode 100644 index 0000000000..dd9946540a --- /dev/null +++ b/docs/user/faq/is-photocraft-free.md @@ -0,0 +1,14 @@ +--- +title: Is PhotoCraft free? +description: PhotoCraft is free and open source under the MIT or Apache 2.0 license, and works offline. +order: 10 +verified: 0.8.2 +--- + +Yes. PhotoCraft is one of the Crafting Apps, which the project describes as free, open-source creative tools. The code is licensed under MIT or Apache 2.0, your choice, and is on [GitHub](https://github.com/storytold/photocraft). + +PhotoCraft works offline. Its smart selection tools run on your computer, with no cloud and no account. + +The ArtCraft logos in the repository are not open source. + +See [Install PhotoCraft](../getting-started/install.md) to download it. diff --git a/docs/user/faq/open-photoshop-files.md b/docs/user/faq/open-photoshop-files.md new file mode 100644 index 0000000000..06fbe367bc --- /dev/null +++ b/docs/user/faq/open-photoshop-files.md @@ -0,0 +1,16 @@ +--- +title: Can PhotoCraft open my Photoshop files? +description: PhotoCraft opens and saves layered PSD and PSB files and keeps what it does not understand yet. +order: 20 +verified: 0.8.2 +--- + +Yes. PhotoCraft opens, edits and saves layered PSD and PSB files, including 16-bit, 32-bit, CMYK and Lab documents. + +When it saves the file again, the picture renders the same for 307 of the 309 files in the psd-tools test set the project checks against. Parts PhotoCraft does not model yet, such as raw blocks and descriptors, are carried over instead of being dropped. A re-saved file is not byte for byte the same as the original, because PhotoCraft rewrites image resources, layer records and the composite. + +Text layers stay editable. In version 0.7.0 some of them moved when you started editing them; 0.8.2 fixes that, see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +Results vary from file to file. If a PSD does not open or looks wrong, search the issue tracker or report it with the file. + +PhotoCraft also opens layered TIFF, Paint.NET, OpenRaster, SVG, Krita and Affinity files (Krita and Affinity read only), and many flat formats. The full list is in [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/faq/replace-photoshop.md b/docs/user/faq/replace-photoshop.md new file mode 100644 index 0000000000..56027f2706 --- /dev/null +++ b/docs/user/faq/replace-photoshop.md @@ -0,0 +1,12 @@ +--- +title: Can PhotoCraft replace Photoshop? +description: Not yet. PhotoCraft is in early alpha and is not ready for daily professional work, but it follows Photoshop's menus and shortcuts. +order: 30 +verified: 0.9.0 +--- + +Not yet. PhotoCraft is in early alpha. Much of Photoshop's feature surface exists in some form, but the project says plainly that it is not yet a Photoshop replacement for daily professional work. + +The biggest gaps the project lists are AI and generative features, tools Photoshop has that PhotoCraft does not yet, depth in typography and professional workflows, and plug-in compatibility. Every Photoshop menu item is connected to a command, but that only shows the menu is wired up. Having a tool or command is not the same as matching Photoshop's behaviour. + +What PhotoCraft does well already: layers, masks, adjustment layers, layer styles, type, vector shapes, brushes and live-preview filters, in a menu and shortcut layout that follows Photoshop. See [PhotoCraft](../index.md) for an overview, and the [project roadmap](https://github.com/storytold/photocraft/blob/main/ROADMAP.md) for what comes next. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 0000000000..2383e17f0b --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,21 @@ +--- +title: Getting started +description: Install PhotoCraft and open your first image. +order: 10 +verified: 0.8.2 +--- + +Install PhotoCraft, then open an image with **File › Open…**. The panels you use most are **Layers**, **Properties** and **History**. + +1. [Install PhotoCraft](install.md). +2. Start PhotoCraft and choose **File › Open…**. To start from nothing, choose **File › New…** and click **Create**. +3. Pick the **Horizontal Type Tool**, a selection tool or an adjustment, and work on the image. Every step is listed in the **History** panel and can be undone. +4. Keep your layers with **File › Save As…** (PSD or PhotoCraft), or make a flat copy with **File › Export › Export As…**. + +The guides walk through each of these: + +- [Open and save files](../guides/open-and-save-files.md) +- [Adjust colors without changing your pixels](../guides/adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](../guides/select-and-mask.md) +- [Add and style text](../guides/add-and-style-text.md) +- [Export an image](../guides/export-an-image.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 0000000000..975f7f3b71 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,59 @@ +--- +title: Install PhotoCraft +description: Download PhotoCraft for Windows 10 or 11, macOS or Linux from getartcraft.com or GitHub, or install it with ArtCraft Launcher. +order: 10 +verified: 0.8.2 +--- + +The easiest way is the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). You can also install it with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps), which lists PhotoCraft. For a specific build or file format, take it from the [latest release](https://github.com/storytold/photocraft/releases/latest) on GitHub. In the file names below, `` stands for the version number, for example `0.8.2`. + +## Windows + +PhotoCraft needs Windows 10 or later. On Windows 7 and 8 the installer stops with "PhotoCraft needs Windows 10 or later." + +1. Open the [latest release](https://github.com/storytold/photocraft/releases/latest). +2. Download `photocraft--windows-x64.msi`. It fits most PCs (64-bit Intel and AMD). +3. Run the file and follow the installer. + +To run without installing, download `photocraft--windows-x64-portable.zip` instead and unzip it. + +| Your PC | Installer | Portable | +|------------------------------------|------------------------------------------|---------------------------------------------------| +| 64-bit Intel or AMD | `photocraft--windows-x64.msi` | `photocraft--windows-x64-portable.zip` | +| Arm (Snapdragon and other Arm PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | +| 32-bit | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | + +The installers and executables are code-signed. + +## macOS + +1. Download `photocraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized by Apple. +2. Open it and drag PhotoCraft to Applications. + +## Linux + +Pick one file from the release. Use `x86_64` for Intel and AMD, or `aarch64` for Arm. + +| Format | File | Notes | +|---------------------|----------------------------------------------|------------------------------------------------------------------------------------| +| AppImage | `photocraft--linux-x86_64.AppImage` | Runs anywhere and needs no install. | +| Debian and Ubuntu | `photocraft--linux-x86_64.deb` | | +| Fedora and openSUSE | `photocraft--linux-x86_64.rpm` | | +| Flatpak | `photocraft--linux-x86_64.flatpak` | Run `flatpak install --user `, then `flatpak run ai.storyteller.photocraft`. | +| Tarball | `photocraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +The first time you run the AppImage, it adds its icon and menu entry under `~/.local/share`. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that. For RISC-V, take the `riscv64` file: `photocraft--linux-riscv64.tar.gz`, `.deb` or `.rpm`. The RISC-V tarball needs glibc 2.39 or newer. + +On a Wayland session, files dropped on the PhotoCraft window do not open yet. Use **File › Open…** instead, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. PhotoCraft shows the right command for your install in a notice until you dismiss it. + +## FreeBSD and the web + +FreeBSD 14 (x86_64) has a tarball, `photocraft--freebsd-x86_64.tar.gz`, laid out like `/usr/local`. The web version comes as `photocraft-web-.zip`, a static site you host on any web server. It runs in a modern browser. + +## Check your download + +`SHA256SUMS.txt` on the release lists a checksum for every file. + +## Next step + +Open an image: [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/guides/add-and-style-text.md b/docs/user/guides/add-and-style-text.md new file mode 100644 index 0000000000..71dcd7db97 --- /dev/null +++ b/docs/user/guides/add-and-style-text.md @@ -0,0 +1,36 @@ +--- +title: Add and style text +description: Add point or paragraph text with the Horizontal Type Tool in PhotoCraft, then give it a drop shadow or glow. +order: 40 +verified: 0.8.2 +--- + +Pick the **Horizontal Type Tool** (shortcut `T`), click on the canvas for a line of text or drag for a text box, and type. Text stays editable, so you can change the words and the style later. + +## Add text + +1. Choose the **Horizontal Type Tool** in the toolbox, or press `T`. +2. Click the canvas to start a line of text (point text), or drag a rectangle to start a block of text (paragraph text). +3. Type. The text is edited right on the canvas. +4. Set the font, size, leading, tracking, alignment and color with the character and paragraph controls. Each typing session is one **Edit Type** step in the **History** panel. + +To switch between the two kinds later, use **Type › Convert to Paragraph Text** or **Type › Convert to Point Text**. + +## Edit the text later + +Pick the **Horizontal Type Tool** and click the text on the canvas. Text from a PSD that was made in Photoshop stays editable too. If PSD text jumps when you start editing it, update to 0.8.2; see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +To start new text on top of an existing text layer instead of editing it, hold `Shift` while you click. + +## Add a layer style + +1. Click the text layer in the **Layers** panel. +2. Choose **Layer › Layer Style**, then an effect such as **Drop Shadow…**, **Outer Glow…**, **Stroke…** or **Bevel & Emboss…**. The **Layer Style** dialog opens. +3. Adjust the effect and confirm. + +The other effects in the menu are **Inner Shadow…**, **Inner Glow…**, **Satin…**, **Color Overlay…**, **Gradient Overlay…** and **Pattern Overlay…**. **Layer › Layer Style** also has **Copy Layer Style** and **Paste Layer Style** to reuse a style on another layer, and **Hide All Effects**. + +## Turn text into shapes or pixels + +- **Type › Convert to Shape** makes vector shapes from the letters. +- **Type › Rasterize Type Layer** turns the text into pixels. After that you can no longer edit the words. diff --git a/docs/user/guides/adjust-colors-with-adjustment-layers.md b/docs/user/guides/adjust-colors-with-adjustment-layers.md new file mode 100644 index 0000000000..500ff3521c --- /dev/null +++ b/docs/user/guides/adjust-colors-with-adjustment-layers.md @@ -0,0 +1,32 @@ +--- +title: Adjust colors without changing your pixels +description: Add a Curves, Levels or other adjustment layer in PhotoCraft so you can change or switch it off later. +order: 20 +verified: 0.8.2 +--- + +Use **Layer › New Adjustment Layer** and pick an adjustment. The change sits on its own layer above your image, so your original pixels stay as they were and you can edit or hide the adjustment later. + +## Add an adjustment layer + +1. Open your image and click the layer the adjustment should sit above in the **Layers** panel. +2. Choose **Layer › New Adjustment Layer**, then an adjustment such as **Curves…**, **Levels…**, **Vibrance…** or **Hue/Saturation…**. +3. Change the settings in the **Properties** panel, which shows the settings of the new layer. + +The same list is behind the button at the bottom of the **Layers** panel, labelled **Create new fill or adjustment layer** when you hover over it. + +The adjustments in the menu are **Brightness/Contrast**, **Levels**, **Curves**, **Exposure**, **Vibrance**, **Hue/Saturation**, **Color Balance**, **Black & White**, **Photo Filter**, **Channel Mixer**, **Color Lookup**, **Invert**, **Posterize**, **Threshold**, **Gradient Map** and **Selective Color**. + +## Limit it to one area + +1. Select the area first, for example with the **Elliptical Marquee Tool**. See [Select part of an image and mask it](select-and-mask.md). +2. Add the adjustment layer as above. +3. With the adjustment layer active, choose **Layer › Layer Mask › Reveal Selection**. The adjustment now shows only inside the selection. + +## Change it later + +Click the adjustment layer in the **Layers** panel and edit its settings in **Properties**. Every step you take is also listed in the **History** panel. + +## Apply an adjustment straight to the pixels + +If you do not need to change it later, use **Image › Adjustments**, for example **Image › Adjustments › Curves…**. These change the pixels of the layer directly and are not kept as a separate layer. diff --git a/docs/user/guides/export-an-image.md b/docs/user/guides/export-an-image.md new file mode 100644 index 0000000000..82d5c5a4e4 --- /dev/null +++ b/docs/user/guides/export-an-image.md @@ -0,0 +1,38 @@ +--- +title: Export an image +description: Export a PNG, JPG, WebP, TIFF or TGA from PhotoCraft with a preview, a size estimate and a scale. +order: 50 +verified: 0.8.2 +--- + +Choose **File › Export › Export As…**, set the format, and click **Export**. The dialog shows a preview and an estimate of the file size before you save. Your layered document is not changed. + +## Export with settings + +1. Choose **File › Export › Export As…**. +2. Under **File Settings**, pick a **Format**: **PNG**, **JPG**, **WebP**, **TIFF** or **TGA**. +3. Set the options for that format: + - **JPG** and lossy **WebP** have a **Quality** slider from 1 to 100. + - **WebP** also has a **Lossless** checkbox. + - **TGA** has **Resolution**: **Auto**, **24 bits/pixel** or **32 bits/pixel**. + - **Transparency** keeps transparent areas. JPG has no transparency, so this checkbox is not shown for JPG and the image is flattened. + - **Metadata** is **None** or **All**. +4. Under **Image Size**, set **Scale** in percent. The size in pixels shows below it. +5. Check the preview on the right. The line under it shows the format and the estimated file size. +6. Click **Export**, then choose a name and a place for the file. + +PhotoCraft shows "Exported" with the file path and size in the status bar when it is done. + +## Export in one step + +**File › Export › Quick Export as PNG** exports without asking about settings. It uses the format, quality, metadata and location you set in **File › Export › Export Preferences…**. + +## Export layers + +Choose **Layer › Export As…** (or **Layer › Quick Export as PNG**) to export only the active layer. + +With several layers or artboards selected in the **Layers** panel, both commands export each one as its own file. You choose a name and place once. The first file gets that name, and the others get the same name with a number and the layer name added. + +## Keep your layers + +Exporting makes a flat copy. To keep the layers, use **File › Save As…** with **PSD Document** or **PhotoCraft**; see [Open and save files](open-and-save-files.md). diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 0000000000..a493dc2c8a --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,14 @@ +--- +title: Guides +description: Step-by-step guides for the everyday tasks in PhotoCraft. +order: 20 +verified: 0.8.2 +--- + +- [Open and save files](open-and-save-files.md) +- [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](select-and-mask.md) +- [Add and style text](add-and-style-text.md) +- [Export an image](export-an-image.md) + +Menu paths are written like **File › Open…**. Where a shortcut is shown, `Cmd` is the Ctrl key on Windows and Linux. diff --git a/docs/user/guides/open-and-save-files.md b/docs/user/guides/open-and-save-files.md new file mode 100644 index 0000000000..0c2b4306af --- /dev/null +++ b/docs/user/guides/open-and-save-files.md @@ -0,0 +1,32 @@ +--- +title: Open and save files +description: Open a PSD or other image in PhotoCraft, and save it with its layers or as a flat copy. +order: 10 +verified: 0.8.2 +--- + +Open a file with **File › Open…** and keep your layers with **File › Save As…**, choosing **PSD Document** or **PhotoCraft** as the type. Formats such as PNG and JPEG are flat: they hold one image with no layers. + +## Open a file + +1. Choose **File › Open…**. +2. Pick your file. The type menu in the dialog offers **All Formats**, **PhotoCraft**, **OpenRaster**, **Krita**, **SVG** and **Paint.NET**. +3. Confirm in the dialog. + +You can also reopen a recent file from **File › Open Recent**. On Linux under Wayland, dropping a file on the window does not work yet; see [Install PhotoCraft](../getting-started/install.md). + +PhotoCraft opens PSD and PSB (including 16-bit, 32-bit, CMYK and Lab files), layered TIFF, Paint.NET (`.pdn`), OpenRaster (`.ora`), SVG, and the flat formats PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, PCX, OpenEXR, Radiance HDR and FITS. Krita files (`.kra`) open as one image layer made from the picture Krita saved, without Krita's layers. HEIC and HEIF photos from iPhone and Mac open too, read only, and so do camera raw files such as DNG, CR2, CR3, NEF and ARW. It can also open Affinity files (`.af`, `.afphoto`, `.afdesign`, `.afpub`), read only; PhotoCraft cannot save Affinity files. Anything it cannot match exactly, such as layer effects and adjustments, is approximated or left out and PhotoCraft shows a warning. + +## Save with layers + +1. Choose **File › Save As…**. +2. Pick **PSD Document** (`.psd` or `.psb`) to share the file with Photoshop, or **PhotoCraft** (`.pcraft`) for PhotoCraft's own format. +3. Name the file and save. + +After that, **File › Save** saves into the same file. **File › Save a Copy…** saves a copy under a new name. While a large file saves, the status bar and the tab title show "Saving" with a percentage. + +The **Save As…** dialog also lists OpenRaster, PNG, JPEG, WebP, TIFF, Targa, OpenEXR and FITS. **File › Save** on an opened JPEG keeps its quality, and on a WebP keeps it lossy or lossless. Check the file extension in the name box: PhotoCraft writes the format that the extension names. If you want a flat PNG or JPEG with a size and quality preview, use [Export an image](export-an-image.md) instead. + +## Good to know + +Saving a PSD again keeps the layers and the rendered result, but the file is not byte for byte the same as the one you opened. PhotoCraft rewrites image resources, layer records and the composite. It keeps parts it does not model yet instead of dropping them. diff --git a/docs/user/guides/select-and-mask.md b/docs/user/guides/select-and-mask.md new file mode 100644 index 0000000000..50f78b7685 --- /dev/null +++ b/docs/user/guides/select-and-mask.md @@ -0,0 +1,40 @@ +--- +title: Select part of an image and mask it +description: Make a selection with the marquee tools or Select Subject, soften its edge, and turn it into a layer mask in PhotoCraft. +order: 30 +verified: 0.8.2 +--- + +Pick a selection tool, drag around the area, then choose **Layer › Layer Mask › Reveal Selection** to show only that area. A mask hides part of a layer without erasing any pixels. + +## Make a selection + +Use one of these: + +- **Elliptical Marquee Tool** or **Rectangular Marquee Tool** for simple shapes. Drag on the canvas. +- **Quick Selection Tool**, **Magic Wand Tool** or **Lasso Tool** to follow the edges of an object. +- **Select › Subject** to let PhotoCraft pick the main subject. The **Quick Selection Tool** and **Object Selection Tool** also have a **Select Subject** button in the options bar. If nothing is found, PhotoCraft says "No subject found" and suggests the Object Selection or Lasso tool. + +Smart selection runs on your computer. It needs no account and no cloud. + +## Adjust the selection + +- **Select › Inverse Selection** swaps the selected and unselected areas. +- **Select › Modify** has **Border…**, **Smooth…**, **Expand…**, **Contract…** and **Feather…**. Feather softens the edge. +- **Select › Select and Mask…** refines an edge, for example around hair. +- **Select › Deselect** removes the selection. **Select › Reselect** brings back the last one. + +## Turn the selection into a mask + +1. Click the layer you want to mask in the **Layers** panel. +2. Choose **Layer › Layer Mask › Reveal Selection** to show only the selected area, or **Hide Selection** to hide it. + +To start with the whole layer visible or hidden and paint the mask yourself, use **Layer › Layer Mask › Reveal All** or **Hide All**. + +## Remove or apply a mask + +- **Layer › Layer Mask › Delete** removes the mask. +- **Layer › Layer Mask › Apply** applies the mask to the layer's pixels. +- **Layer › Layer Mask › Disable Layer Mask** (or **Enable Layer Mask**) switches the mask off and on. + +To limit a color adjustment to the selection, see [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md). diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..29e229c0bd --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +title: PhotoCraft +description: PhotoCraft is a free, open-source image editor with layers, masks, type and real PSD files, built as an alternative to Adobe Photoshop. +verified: 0.8.2 +--- + +PhotoCraft is a free, open-source image editor with layers, masks, adjustment layers, layer styles, type, vector shapes and brushes. It is an alternative to Adobe Photoshop: the menus, panels, tools and shortcuts follow Photoshop's layout, so Photoshop users find things where they expect them. + +It is a native app written in Rust that draws the canvas on your graphics card, works offline, and opens and saves layered PSD files. It runs on macOS, Windows, Linux and FreeBSD, and a web version runs in the browser. PhotoCraft is in early alpha: much of Photoshop's feature surface exists, but it is not yet a Photoshop replacement for daily professional work. See [Can PhotoCraft replace Photoshop?](faq/replace-photoshop.md). + +## Start here + +- [Install PhotoCraft](getting-started/install.md) +- [Your first edit](getting-started/index.md) +- [Guides: open, adjust, select, type, export](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Part of the Craft App Suite + +PhotoCraft is one of the Crafting Apps from the ArtCraft team, next to VectorCraft, FilmCraft and the other Craft apps. You can install and update them from [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/photocraft/issues). In PhotoCraft, **Help › Report an Issue…** opens the same issue tracker. diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 0000000000..ee8bc860ee --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,19 @@ +--- +title: Get help with a problem +description: How to ask for help or file a PhotoCraft bug so it can be fixed, and what to include. +order: 30 +verified: 0.8.2 +--- + +Open **Help › Report an Issue…**. It takes you to the [PhotoCraft issue tracker](https://github.com/storytold/photocraft/issues) on GitHub. You can also ask in [Discord](https://discord.gg/artcraft). + +## What to include + +1. What you did, step by step, and what you expected. +2. What happened instead. A screenshot or a short video helps. +3. Your system details. Choose **Help › System Info…**, click **Copy**, and paste the text into your message. It lists the PhotoCraft version, your operating system and processor, and your graphics adapter. +4. The file, if one file causes it. Zip a PSD or other file and attach it. Leave it out if it contains anything private. + +## Before you post + +Search the tracker for your problem first. If someone already reported it, add your details there instead of opening a new issue. For known problems, see the other pages in [Troubleshooting](index.md). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 0000000000..4785ae412b --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,11 @@ +--- +title: Troubleshooting +description: Fixes and workarounds for known PhotoCraft problems. +order: 30 +verified: 0.8.2 +--- + +- [PhotoCraft does not start](photocraft-does-not-start.md) +- [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) +- [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/photocraft-does-not-start.md b/docs/user/troubleshooting/photocraft-does-not-start.md new file mode 100644 index 0000000000..549f6bd65d --- /dev/null +++ b/docs/user/troubleshooting/photocraft-does-not-start.md @@ -0,0 +1,58 @@ +--- +title: PhotoCraft does not start +description: PhotoCraft closes or crashes at startup on Windows PCs with Intel graphics (it uses DirectX 12 and picks a safer graphics mode after a crash) or on older Intel Macs (0.8.2 fixes one cause; no fix yet for Macs patched with OpenCore Legacy Patcher). +order: 30 +verified: 0.8.2 +--- + +On Windows, PhotoCraft starts Intel graphics on DirectX 12. If PhotoCraft closed while it was starting, start it again: it picks a safer graphics mode after a crash. On an older Intel Mac that shows a `Device(Lost)` error, update to 0.8.2, which fixes one cause; on Macs patched with OpenCore Legacy Patcher there is no fix yet. + +## Symptoms + +- Windows with Intel graphics (for example Intel UHD Graphics 630): PhotoCraft closes or crashes right after you start it. Windows Event Viewer shows an application error in `igvk64.dll` with exception `0xc0000005` ([issue 2320](https://github.com/storytold/photocraft/issues/2320), [issue 4](https://github.com/storytold/photocraft/issues/4)). +- Intel Mac: the app icon bounces in the Dock and the app disappears. Started from Terminal, it prints `Error: Wgpu(RequestDeviceError(RequestDeviceError { inner: Core(Device(Lost)) }))` ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). + +## Fix + +### Windows + +1. Use PhotoCraft 0.7.0 or later. Older builds do not have the steps below. +2. Start PhotoCraft again. Double-clicking its icon or a `.pcraft` file works. PhotoCraft notes that the last start did not finish and uses the next safer graphics mode this time: + - A start on Vulkan is followed by one on DirectX 12. + - A start on DirectX 12 is followed by one on the CPU. The CPU mode does not depend on the graphics driver. +3. If it started, nothing more to do. PhotoCraft remembers the mode that worked. You can see or reset it under **Edit › Preferences › Performance…** (on macOS, **PhotoCraft › Settings › Performance…**). + +If PhotoCraft still closes on every start, force DirectX 12 from PowerShell: + +1. Open PowerShell in the folder that contains `photocraft.exe`. +2. Run these three lines: + + $env:WGPU_BACKEND = "dx12" + & .\photocraft.exe + Remove-Item Env:WGPU_BACKEND + +The last line clears the setting so later launches use the default again. You need the first two lines each time you start PhotoCraft this way. While `WGPU_BACKEND` is set, PhotoCraft uses it and does not step down to a safer mode by itself ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). + +### Intel Mac + +1. Update to PhotoCraft 0.8.2 or later. On Intel Macs it skips a set of graphics checks that macOS could not compile on older Intel graphics, such as Intel Iris Pro on macOS Monterey ([issue 3107](https://github.com/storytold/photocraft/issues/3107)). +2. Start PhotoCraft again. + +If it still fails, there is no fix yet. This is the case reported on Macs patched with OpenCore Legacy Patcher, where setting `WGPU_BACKEND`, `--safe-gpu` and `PHOTOCRAFT_CPU_CANVAS=1` did not change the error ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). Add your Mac model, macOS version and PhotoCraft version to that issue. + +## Why this happens + +On Windows, PhotoCraft draws with your graphics driver. Intel's Vulkan driver (`igvk64.dll`) crashed PhotoCraft on a UHD 630 PC with PhotoCraft 0.6.0, and the DirectX 12 setting above avoided the crash ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). Since 0.7.0 PhotoCraft prefers DirectX 12 on Intel graphics. Before it opens the window, it writes a small file called `gpu-starting.json` in its settings folder and deletes it once the first frames are drawn. A start that crashed leaves the file behind, and the next start reads it and moves to the next safer mode. + +## Still stuck? + +See [Get help with a problem](get-help.md). Say which graphics card you have, which PhotoCraft version you use and how you started it. Add one of these: + +- If you can open PhotoCraft: the text from **Help › System Info…**. +- If PhotoCraft does not open at all: the log file `photocraft.log`, plus the faulting module and exception code from Event Viewer on Windows. PhotoCraft keeps it in a `logs` folder inside its settings folder: + - Windows: `%APPDATA%\Photocraft\logs\photocraft.log` + - macOS: `~/Library/Application Support/Photocraft/logs/photocraft.log` + - Linux: `~/.config/photocraft/logs/photocraft.log` + - Portable copy (a `portable.txt` file next to the program): the `PhotoCraftData\logs` folder next to the program. + + Every start moves the previous log to `photocraft.1.log`. If you already started PhotoCraft again after the crash, attach `photocraft.1.log` too, because it holds the run that crashed. diff --git a/docs/user/troubleshooting/taskbar-turns-black.md b/docs/user/troubleshooting/taskbar-turns-black.md new file mode 100644 index 0000000000..0c8f5b733e --- /dev/null +++ b/docs/user/troubleshooting/taskbar-turns-black.md @@ -0,0 +1,29 @@ +--- +title: The Windows taskbar turns black when PhotoCraft is maximized +description: On Windows 11, PhotoCraft 0.7.0 can turn the taskbar black or hide it when maximized. PhotoCraft 0.8.2 fixes this; update. +order: 10 +verified: 0.8.2 +--- + +Update PhotoCraft. Version 0.7.0 on Windows 11 could turn the taskbar black or hide it while the window was maximized. PhotoCraft 0.8.2 includes the fix. + +## Symptoms + +- You maximize the PhotoCraft window, or switch it to full screen, and the Windows taskbar turns completely black or is hidden. +- After you resize the window and maximize it again, a black bar the height of the taskbar stays at the bottom of the screen. + +## Fix + +1. Check your version with **Help › System Info…**, which starts with "PhotoCraft" and the version. +2. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). See [Install PhotoCraft](../getting-started/install.md). +3. Start PhotoCraft again and maximize the window. + +A user who saw the black taskbar on 0.7.0 confirmed that it is gone in 0.8.2 ([issue 3864](https://github.com/storytold/photocraft/issues/3864)). + +## Why this happens + +PhotoCraft draws its own title bar. When such a window was maximized, Windows sized it to cover the whole screen, taskbar included, took it for a full-screen app and dropped the taskbar's background. The fix keeps a maximized PhotoCraft window clear of the taskbar ([issue 3144](https://github.com/storytold/photocraft/issues/3144), [pull request 3308](https://github.com/storytold/photocraft/pull/3308)). + +## Still stuck? + +If you still see it on 0.8.2, add your Windows version and PhotoCraft version to [issue 3864](https://github.com/storytold/photocraft/issues/3864), or ask in [Discord](https://discord.gg/artcraft). As a test, you can switch on **System title bar** under **Edit › Preferences › Interface…**, click **Apply** and restart PhotoCraft; Windows then draws the title bar. See [Get help with a problem](get-help.md) for what to include. diff --git a/docs/user/troubleshooting/text-moves-when-editing.md b/docs/user/troubleshooting/text-moves-when-editing.md new file mode 100644 index 0000000000..7130c8c7cf --- /dev/null +++ b/docs/user/troubleshooting/text-moves-when-editing.md @@ -0,0 +1,29 @@ +--- +title: Text moves when I start editing a PSD text layer +description: In PhotoCraft 0.7.0, a text layer from a Photoshop file can jump when you click it with the Type tool. PhotoCraft 0.8.2 fixes the known causes; update. +order: 20 +verified: 0.8.2 +--- + +Update PhotoCraft. In version 0.7.0, a text layer from a Photoshop file could jump or disappear when you started editing it. PhotoCraft 0.8.2 reads the text's styles and position the way Photoshop saved them, so the text stays where it was. + +## Symptoms + +- You open a PSD that was made in Photoshop and click one of its text layers with the **Horizontal Type Tool**. The text shifts, often to the left, as soon as editing starts. +- Centered text may show as left-aligned in the options bar. +- In some files, text that you click can disappear. + +## Fix + +1. Check your version with **Help › System Info…**. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). +2. Open the PSD again and click the text layer with the **Horizontal Type Tool**. + +If you already saved the file in 0.7.0 after the text moved, the moved text is now in the file. Go back to your original PSD if you still have it. + +## Why this happens + +Clicking a PSD text layer with the Type tool starts editing it, and PhotoCraft then lays the text out itself instead of showing the pixels Photoshop saved. PhotoCraft 0.7.0 read the alignment and size of each run of text only from Photoshop's default styles, and ignored part of the text position that recent Photoshop versions store. Both are fixed in 0.8.2 ([issue 3795](https://github.com/storytold/photocraft/issues/3795), [pull request 3819](https://github.com/storytold/photocraft/pull/3819)). + +## Still stuck? + +If text still moves in 0.8.2, keep a copy of the original with **File › Save a Copy…**, then report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with the PSD (zipped) and the Photoshop version that saved it. You can also ask in [Discord](https://discord.gg/artcraft). See [Get help with a problem](get-help.md). From 6cec0cab24bf80e5ca1fa7bea16174030e70d034 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 16:24:03 -0600 Subject: [PATCH 0726/1452] Save: a read-only file is refused, so its metadata test asserts that MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's 589941a5 (#6600) refuses to replace a read-only file before anything is written. This branch's test wrote to a 0o444 file and expected the save to succeed, so the merge of the two failed on Linux: cannot save .../doc.psd: the file is read-only; the original file was not changed No bug on either side: the two changes disagreed about what a read-only save should do, and main's answer is the deliberate one. The test now asserts the refusal, that the original is untouched, and that the attribute and mode survive — which is what it set out to check. The writable cases alongside it still cover the metadata copy itself. The windows-latest failure was the roll-up gate reporting this same failure, not a second one. --- crates/format/src/atomic/meta/tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs index 8bf5689039..5c8f08c6ba 100644 --- a/crates/format/src/atomic/meta/tests.rs +++ b/crates/format/src/atomic/meta/tests.rs @@ -93,8 +93,8 @@ mod xattrs { assert_eq!(get(&p, TAG), Some(value)); } - /// Copying the mode must not get in the way of copying the attributes: a read-only file is - /// still writable to us *before* its mode lands on the temporary file, and only then. + /// A read-only file is refused outright (#6600), so a save never reaches the metadata copy and + /// the file keeps everything it had. The writable cases above cover the copy itself. #[test] fn a_read_only_file_keeps_its_attributes_and_its_mode() { use std::os::unix::fs::PermissionsExt; @@ -106,9 +106,13 @@ mod xattrs { } std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); - atomic_write(&p, b"second, longer").unwrap(); + // #6600: a read-only file is refused before anything is written, so the question this test + // asks — does a replace keep the attributes and the mode — is answered by the save not + // happening. The writable case above covers attributes surviving a replace. + let e = atomic_write(&p, b"second, longer").expect_err("a read-only file is refused"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); - assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(&p).unwrap(), b"first", "the original was left alone"); assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); } From bd5e5ed781ba13c1d691dd7e70bdd34fafe6f23b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:24:39 -0400 Subject: [PATCH 0727/1452] Artboards: move egui::Rect import into the tests (unused-import Clippy error) Since pasteboard_quads returns [Pos2; 4], only mod tests uses egui::Rect, so the non-test build failed clippy -D warnings with an unused import. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/artboard_ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 63939fa9f3..eac9fe77aa 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -2,7 +2,7 @@ //! View › Fit Artboard on Screen, and the Properties panel section (X/Y/W/H, preset, //! background). Everything that changes the document is `layer.artboard.set` and friends. -use egui::{Align2, Color32, Pos2, Rect, Stroke}; +use egui::{Align2, Color32, Pos2, Stroke}; use photocraft_doc::{ArtboardBackground, Document, Layer}; use serde_json::{Value, json}; @@ -214,6 +214,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { mod tests { use super::*; use crate::menus::{invoke as menu, is_live, menu_items}; + use egui::Rect; fn app() -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From 70eab0c230b45f0f0c2d5d5c843a441b034fbd63 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:24:40 -0400 Subject: [PATCH 0728/1452] Actions: stop when a Save step's document is closed by a Save Document script (#7367) An imported Close with Save: Yes plays as `file.save` then `file.close` with no document. A Save Document script event that closed the saved document left another document active, and the Close then closed that one, unsaved edits and all. Playback now fails the Save step when the document it saved is no longer open, so no later step acts on a fallback document. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 11 +++++ crates/ui-egui/src/actions.rs | 71 ++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..e0024955da 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -128,6 +128,17 @@ pub fn shell_save_command(id: &str) -> bool { matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") } +/// The failure for a played Save step whose document `target` (the one active when the step +/// started) is no longer open: a Save Document script event closed it. Playback stops there, so +/// the next step without a document, such as the Close half of an imported Close with Save: Yes, +/// doesn't act on whichever document is active now (#7367). +pub fn save_target_closed(s: &Session, id: &str, target: Option) -> Option { + let target = target.filter(|_| shell_save_command(id))?; + (!s.documents().iter().any(|d| d.doc.id == target)).then(|| { + "the document was closed during the save (by a Save Document script event); playback stopped so later steps don't act on another document".into() + }) +} + /// View-only menu commands recorded by the desktop shell. Headless playback reports /// them as unsupported instead of silently dropping them; Batch and droplets skip them. pub fn shell_view_command(id: &str) -> bool { diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index cdffc378dd..cc857b89a8 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -115,6 +115,7 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result Result { - if let Some(error) = actions_cmds::nested_failure(id, &value) { + if let Some(error) = actions_cmds::nested_failure(id, &value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { failed = Some(json!({"step": i, "id": id, "error": error})); break; } @@ -1659,4 +1660,72 @@ mod tests { assert!(h.query_by_label("Button Mode").is_some()); assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 1, "the step list returns when Button Mode is turned off"); } + + /// An imported Close with Save: Yes plays as Save then Close. A Save Document script event + /// that closes the saved document stops playback, so the Close never reaches another dirty + /// document (#7367). + #[test] + fn save_hook_closing_the_target_stops_an_imported_close_with_save() { + use photocraft_psd::atn::{self, AtnEvent, AtnItem}; + use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; + use std::{cell::RefCell, rc::Rc}; + // A one-step .atn: Close (`Cls `) with Save: Yes. + let seed = Action::new("Seed", vec![("layer.new.layer".into(), json!({}))]); + let (bytes, _) = photocraft_engine::atn_cmds::export("Set", &[&seed], &[], false).unwrap(); + let mut f = atn::parse(&bytes).unwrap(); + f.actions[0].items = vec![AtnItem { + event: AtnEvent::Code(*b"Cls "), + dictionary: "Close".into(), + descriptor: Some(Descriptor::new("null").with("Svng", D::Enumerated { type_id: Id::new("YsN "), value: Id::new("Ys ") })), + ..Default::default() + }]; + let imported = photocraft_engine::atn_cmds::import(&atn::write(&f).unwrap()).unwrap(); + let close_with_save = imported.actions[0].clone(); + assert_eq!(close_with_save.steps, [("file.save".into(), json!({})), ("file.close".into(), json!({}))]); + for hook in ["none", "activate", "close"] { + let writes = Rc::new(RefCell::new(Vec::::new())); + let w = writes.clone(); + let services = crate::Services { + export: Some(Box::new(|doc, _, _| Ok((doc.name.clone().into_bytes(), Vec::new())))), + write: Some(Box::new(move |path, _| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + // B is dirty with an unsaved KeepB layer; A is active and has its own file. + for name in ["B", "A"] { + app.run("file.new", json!({"name": name, "width": 2, "height": 2})).unwrap(); + } + assert!(app.session.set_active(0)); + app.run("layer.new.layer", json!({"name": "KeepB"})).unwrap(); + assert!(app.session.set_active(1)); + app.session.active_mut().unwrap().path = Some("A.psd".into()); + app.session.actions.list.push(close_with_save.clone()); + let steps = match hook { + "activate" => json!([["document.activate", {"document": 0}]]), + "close" => json!([["file.close", {}]]), + _ => Value::Null, + }; + if !steps.is_null() { + app.run("file.scripts.scriptEventsManager", json!({"add": {"event": "saveDocument", "steps": steps}})).unwrap(); + } + let r = app.run("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(*writes.borrow(), ["A.psd"], "hook={hook}: A is saved once"); + let names: Vec> = app.session.documents().iter().map(|d| d.doc.layers.iter().map(|l| l.name.clone()).collect()).collect(); + assert_eq!(app.session.documents().len(), 1, "hook={hook}: only A closes; result={r}"); + assert_eq!(app.session.active().unwrap().doc.name, "B", "hook={hook}"); + assert_eq!(names, [["Background", "KeepB"]], "hook={hook}: B stays open with its unsaved layer"); + if hook == "close" { + assert_eq!(r["failed"]["step"], 0, "{r}"); + assert_eq!(r["failed"]["id"], "file.save", "{r}"); + assert!(r["failed"]["error"].as_str().unwrap().contains("closed during the save"), "{r}"); + } else { + assert!(r.get("failed").is_none(), "hook={hook}: {r}"); + } + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + } + } } From eeeeb13f0ea156b90cf68d26e1fb5a4a9f66f663 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:25:22 -0400 Subject: [PATCH 0729/1452] Print: restore the winsafe dependency, the DLL check and its tests lost in merges (#5884) The merges from main dropped three parts of 86f6199b: - `winsafe` from crates/engine/Cargo.toml (Cargo.lock still listed it, so every `--locked` CI step failed). It now sits in the engine's existing `cfg(windows)` dependency table next to `same-file`. - the `require_windows_pdf()` call. Main moved `craft_print::submit` into `spool_job`, so the check now runs there, right before the Windows spooler route; that is the only call that reaches winprint's `WinPdfPrinter` (Windows.Data.Pdf.dll). The native RGB route, IPP, the app spooler and Save as PDF don't need the DLL and are unchanged. - the Windows load test and the no-op test for other platforms. Co-Authored-By: Claude Opus 5.5 --- crates/engine/Cargo.toml | 3 +++ crates/engine/src/print_cmds.rs | 1 + crates/engine/src/print_cmds/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fb3bc3fbec..6b710eab93 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -47,6 +47,9 @@ tempfile = "3" # file is that file (std's `MetadataExt::file_index` is unstable). [target.'cfg(windows)'.dependencies] same-file = "1" +# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding +# ui-egui and craft-print already use, pinned to the same version. +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9d84195415..e657a60521 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -90,6 +90,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - return app(sp); } if let Some(name) = system_spooler() { + require_windows_pdf()?; return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); } } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c54dddf6f8..2b0f71bc14 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -772,3 +772,29 @@ mod cups { assert_eq!(jobs, 2, "both prints reached the fake CUPS"); } } + +#[cfg(target_os = "windows")] +#[test] +fn windows_pdf_component_is_detected_by_loading_its_dll() { + // #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of + // aborting at the delay-load stub. + let dir = tempfile::tempdir().unwrap(); + let dll = windows_pdf_dll(dir.path()); + assert!(!windows_pdf_loads(&dll)); + // Present but not loadable counts as missing. + std::fs::create_dir(dir.path().join("System32")).unwrap(); + std::fs::write(&dll, b"MZ").unwrap(); + assert!(!windows_pdf_loads(&dll)); + let root = std::env::var_os("SystemRoot").unwrap(); + let system = windows_pdf_dll(std::path::Path::new(&root)); + if system.is_file() { + assert!(windows_pdf_loads(&system)); + assert!(require_windows_pdf().is_ok()); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn the_windows_pdf_check_is_a_no_op_elsewhere() { + assert!(require_windows_pdf().is_ok()); +} From df94c0442bb1734381386bfc4536c685e0f56fd7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 16:32:21 -0600 Subject: [PATCH 0730/1452] Move tool: moving several layers carries the selection with them (#7538) With several layers selected their selected pixels can't float, so the Move tool moves the layers whole; the marquee stayed behind. Photoshop moves it with them: the ants follow the drag, and the layers and selection land in one Move step. - Engine: `layer.translate` takes `"selection": true` to move the selection by the same offset in the same step. - UI: the Move tool passes it when several layers are selected and there is a selection, and the ants follow the drag live. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 37 +++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 48 ++++++++++++++++++++++++++- crates/ui-egui/src/move_ui.rs | 9 ++++- docs/ui-parity.md | 1 + 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..3e9cff45ce 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1120,7 +1120,7 @@ fn build() -> Vec { "Move Layer", [], None, - r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##, + r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels","selection":bool=false} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer; selection: the selection moves too, in the same step)"##, has_layer, crate::layer_multi_cmds::translate ), diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..aadb4023fc 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -372,6 +372,10 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no active layer".into())); } let mask_target = p.get("target").and_then(Value::as_str) == Some("mask"); + let carry = match p.get("selection") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", "`selection` must be a boolean"))?, + }; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let ids = s.edit("Move", |doc, _| { let (alone, rest): (Vec, Vec) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone)); @@ -396,6 +400,11 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) { crate::artboard_cmds::fit_canvas(doc); } + // The Move tool carries the selection along when it moves several layers whole, as + // Photoshop does (#7538): one step, so Undo takes both back. + if carry { + doc.selection = doc.selection.as_ref().map(|sel| shift_surface(sel, dx, dy)); + } Ok(ids) })?; note_damage(s, &before, &ids); @@ -2725,4 +2734,32 @@ mod tests { assert_eq!(v["selectedLayers"], json!([a.0, b.0])); assert_eq!(v["layers"][0]["selected"], true); } + + /// #7538: `selection: true` moves the selection with the layers in the same step, as the Move + /// tool does with several layers selected; without it the selection stays. + #[test] + fn translate_can_carry_the_selection_in_the_same_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("select.rect", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 70, "height": 30})).unwrap(); + let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()); + let bounds = |s: &Session, id: LayerId| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds(); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.translate", json!({"dx": 7, "dy": 20, "selection": true})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(17, 30, 37, 50), Rect::new(57, 30, 77, 50))); + assert_eq!(sel(&s), Some(Rect::new(12, 25, 82, 55)), "the selection came along"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step"); + s.undo(); + assert_eq!((sel(&s), bounds(&s, a)), (Some(Rect::new(5, 5, 75, 35)), Rect::new(10, 10, 30, 30)), "undone together"); + s.execute("layer.translate", json!({"dx": 7, "dy": 20})).unwrap(); + assert_eq!(sel(&s), Some(Rect::new(5, 5, 75, 35)), "without it the selection stays"); + assert!(s.execute("layer.translate", json!({"dx": 1, "selection": "yes"})).is_err(), "a bad `selection` is an error"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e642f6234a..3267c409ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5784,7 +5784,18 @@ pub(crate) fn floating_outline(app: &PhotocraftApp, idx: usize) -> Option Option<(i32, i32)> { - crate::move_ui::floating_offset(app).or_else(|| selection_drag_delta(app)) + crate::move_ui::floating_offset(app).or_else(|| selection_drag_delta(app)).or_else(|| carried_selection_delta(app)) +} + +/// Whole-pixel offset of a Move drag of several layers, which carries the selection along +/// (`move_ui::carries_selection`): their ants follow the layers while they move (#7538). +fn carried_selection_delta(app: &PhotocraftApp) -> Option<(i32, i32)> { + let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move.is_none())?; + if !crate::move_ui::carries_selection(app) { + return None; + } + let end = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + Some(((end[0] - d.start[0]).round().clamp(-1e7, 1e7) as i32, (end[1] - d.start[1]).round().clamp(-1e7, 1e7) as i32)) } /// End of a selection drag: move the outline, or the floating piece. A click without moving @@ -8415,6 +8426,41 @@ mod transform_controls_tests { assert_eq!(frame(&app), [50, 45, 100, 85], "dropped"); } + /// #7538: with several layers selected the Move tool moves them whole, and the selection goes + /// with them, as in Photoshop: its ants follow the drag and it lands with them in one step. + #[test] + fn moving_several_layers_carries_the_selection() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("select.rect", json!({"x": 40, "y": 40, "width": 50, "height": 40})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": 120, "y": 120, "width": 30, "height": 30})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = false; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let steps = app.session.active().unwrap().history.past_len(); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 68.0, pressure: 1.0 }, m); + assert_eq!(selection_shown_offset(&app), Some((12, 8)), "the ants follow the layers"); + tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 68.0 }, m); + let st = app.session.active().unwrap(); + assert!(photocraft_engine::float_cmds::floating(st).is_none(), "whole layers moved, nothing floats"); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(photocraft_geom::Rect::new(32, 28, 132, 118))); + assert_eq!(st.doc.layer(a).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(52, 48, 102, 88)); + assert_eq!(st.history.past_len(), steps + 1, "one Move step"); + assert!(app.session.undo()); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert_eq!(sel, Some(photocraft_geom::Rect::new(20, 20, 120, 110)), "Undo takes the selection back too"); + } + /// A transparent 200² document: a red square (40..90, 40..80) on its layer, a loose marquee /// (20..120, 20..110) round it, cut and floated 10, 5 to the right and down. fn floating_square() -> PhotocraftApp { diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 86e5675214..f0b857e01f 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -259,6 +259,13 @@ pub(crate) fn is_preview_key(key: u64) -> bool { key > BASE && key < BASE << 1 } +/// Does a Move drag of whole layers take the selection along? With several layers selected, as +/// in Photoshop (#7538): their selected pixels can't float, so the layers move and the marching +/// ants follow them. +pub(crate) fn carries_selection(app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.selection.is_some() && st.selected_layers().len() > 1) +} + /// Release: one `layer.translate` by the drag's offset. The canvas already shows the result, so /// its caches are told it showed the document: the command's damage rect refreshes that area. pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) { @@ -268,7 +275,7 @@ pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) { if dx == 0.0 && dy == 0.0 { return; } - if app.run("layer.translate", json!({"dx": dx, "dy": dy})).is_err() { + if app.run("layer.translate", json!({"dx": dx, "dy": dy, "selection": carries_selection(app)})).is_err() { return; } let Some(p) = shown else { return }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0082044004..a21c9b84ef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -95,6 +95,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌘-arrow nudges the layer from any tool | missing | #2474 | | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Dragging selected pixels shrinks the selection to them (the selection limited to where the layer has pixels, so moving again leaves no edge behind) as the drag starts, also with ⌥; the move box, snapping and Show Transform Controls use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline` and `transform_controls_rect`, `snap_ui.rs` `follow_cut`, engine `float_cmds.rs` `CutParts::outline` | +| With several layers selected and a selection, the Move tool moves the layers whole and the selection with them (ants follow the drag; one Move step) | done | #7538, `move_ui.rs` `carries_selection`, engine `layer.translate` `selection` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | | Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | From 732070b22d77c90b598310a3ee21d089b6bda3bd Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:51:08 -0400 Subject: [PATCH 0731/1452] Print: let the Windows PDF test pass where the system DLL cannot load (#5884) A stripped Windows image can have Windows.Data.Pdf.dll present but not loadable; that is the case `require_windows_pdf` reports, so the test now checks that the result follows the loader instead of assuming the DLL loads. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds/tests.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 2b0f71bc14..31dfa5d1a7 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -786,11 +786,9 @@ fn windows_pdf_component_is_detected_by_loading_its_dll() { std::fs::write(&dll, b"MZ").unwrap(); assert!(!windows_pdf_loads(&dll)); let root = std::env::var_os("SystemRoot").unwrap(); + // The real DLL may be missing or unloadable on a stripped image; the check follows the loader. let system = windows_pdf_dll(std::path::Path::new(&root)); - if system.is_file() { - assert!(windows_pdf_loads(&system)); - assert!(require_windows_pdf().is_ok()); - } + assert_eq!(require_windows_pdf().is_ok(), windows_pdf_loads(&system)); } #[cfg(not(target_os = "windows"))] From 344b8e4be6911b3bf705c993d55adc0bf4604be4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 16:53:34 -0600 Subject: [PATCH 0732/1452] layer.translate: reject a non-boolean `selection` even with no offset (#7538) Review follow-ups (CodeRabbit on #7574): - A zero offset returned before `selection` was checked, so {"dx": 0, "dy": 0, "selection": "yes"} succeeded. It is validated first now, and the test checks a rejected call changes nothing. - A test pins down a Move drag of several layers while a piece floats: the press drops the piece first, then the ants follow the drag from there. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 15 ++++++++++----- crates/ui-egui/src/canvas.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index aadb4023fc..6400f1214c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -361,6 +361,10 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } Some(other) => return Err(bad("layer.translate", format!("unknown units `{other}` (px|pt)"))), }; + let carry = match p.get("selection") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", "`selection` must be a boolean"))?, + }; if dx == 0 && dy == 0 { return Ok(Value::Null); } @@ -372,10 +376,6 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no active layer".into())); } let mask_target = p.get("target").and_then(Value::as_str) == Some("mask"); - let carry = match p.get("selection") { - None | Some(Value::Null) => false, - Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", "`selection` must be a boolean"))?, - }; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let ids = s.edit("Move", |doc, _| { let (alone, rest): (Vec, Vec) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone)); @@ -2760,6 +2760,11 @@ mod tests { assert_eq!((sel(&s), bounds(&s, a)), (Some(Rect::new(5, 5, 75, 35)), Rect::new(10, 10, 30, 30)), "undone together"); s.execute("layer.translate", json!({"dx": 7, "dy": 20})).unwrap(); assert_eq!(sel(&s), Some(Rect::new(5, 5, 75, 35)), "without it the selection stays"); - assert!(s.execute("layer.translate", json!({"dx": 1, "selection": "yes"})).is_err(), "a bad `selection` is an error"); + let state = |s: &Session| (bounds(s, a), bounds(s, b), sel(s), s.active().unwrap().history.past_len()); + let before = state(&s); + for dx in [1, 0] { + assert!(s.execute("layer.translate", json!({"dx": dx, "selection": "yes"})).is_err(), "dx {dx}: a bad `selection` is an error"); + assert_eq!(state(&s), before, "dx {dx}: and changes nothing"); + } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3267c409ce..3b2519ed5d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -8459,6 +8459,20 @@ mod transform_controls_tests { assert!(app.session.undo()); let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); assert_eq!(sel, Some(photocraft_geom::Rect::new(20, 20, 120, 110)), "Undo takes the selection back too"); + + // A piece floating (a marquee ⌘-drag with both selected) when the layers are dragged: the + // press drops it first, so the ants follow the drag from where it was dropped. + app.run("select.float", json!({"dx": 10, "dy": 5})).unwrap(); + let outline = photocraft_engine::float_cmds::floating(app.session.active().unwrap()).unwrap().parts.outline().unwrap().content_bounds(); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + let st = app.session.active().unwrap(); + assert!(photocraft_engine::float_cmds::floating(st).is_none(), "the press dropped the piece"); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(outline.translate(10, 5)), "at its offset"); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 68.0, pressure: 1.0 }, m); + assert_eq!(selection_shown_offset(&app), Some((12, 8)), "then the ants follow the drag"); + tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 68.0 }, m); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert_eq!(sel, Some(outline.translate(22, 13)), "released where the ants were"); } /// A transparent 200² document: a red square (40..90, 40..80) on its layer, a loose marquee From e2adb1816586d28e775d4185cac7bf2f6ddd4518 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Thu, 15 Oct 2026 21:01:24 +0200 Subject: [PATCH 0733/1452] fix(psd): render constant noise gradient fills --- crates/io/src/blocks.rs | 66 ++++++++++++++++++++++++++++++++++++++--- docs/gaps.md | 5 ++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..f356c13204 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -294,6 +294,29 @@ pub(crate) fn gradient_stops_editable(grad: &Descriptor, method: Option<&[u8]>) raw } +/// The exact, constant-RGB subset of Photoshop noise gradients. If every RGB component has the +/// same minimum and maximum, the generated field is constant regardless of seed or roughness. +/// Other models, transparency, and varying ranges are not approximated. +fn constant_rgb_noise_color(grad: &Descriptor) -> Option { + if enum_of(grad, "GrdF")? != b"ClNs" || enum_of(grad, "ClrS")? != b"RGBC" || bool_of(grad, "ShTr") { + return None; + } + let components = |key: &str| -> Option<[f32; 4]> { + let Some(Value::List(values)) = grad.get(key) else { return None }; + if values.len() != 4 { + return None; + } + let converted: Vec = values.iter().map(|value| num(Some(value)).map(|v| v as f32)).collect::>()?; + converted.try_into().ok() + }; + let min = components("Mnm ")?; + let max = components("Mxm ")?; + if min.iter().zip(max).take(3).any(|(a, b)| !a.is_finite() || *a < 0.0 || *a > 100.0 || *a != b) { + return None; + } + Some(Color::rgb(min[0] / 100.0, min[1] / 100.0, min[2] / 100.0)) +} + fn gradient_stops_raw(grad: &Descriptor) -> RawStops { let mut stops = Vec::new(); let mut mids = Vec::new(); @@ -413,7 +436,8 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { b"GdFl" => { let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); - let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d))); + let gradient = get_desc(d, "Grad"); + let raw = gradient.map(|g| gradient_stops_editable(g, gradient_method(d))); let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default(); // Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when // the interpolation is plain); none when fully opaque. @@ -422,7 +446,12 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { opacity_stops.clear(); } if stops.is_empty() { - stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + if let Some(grad) = gradient.filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) { + let color = constant_rgb_noise_color(grad)?; + stops = vec![(0.0, color), (1.0, color)]; + } else { + stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + } } let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0); let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc"))); @@ -757,6 +786,37 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool { #[cfg(test)] mod tests { + use super::*; + + fn noise_gradient_fill(min: [i32; 4], max: [i32; 4], add_transparency: bool) -> Descriptor { + let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect()); + let gradient = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") }) + .with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") }) + .with("ShTr", Value::Boolean(add_transparency)) + .with("VctC", Value::Boolean(true)) + .with("RndS", Value::Integer(9052369)) + .with("Smth", Value::Integer(2048)) + .with("Mnm ", values(min)) + .with("Mxm ", values(max)); + Descriptor::new("null").with("Grad", Value::Descriptor(gradient)) + } + + #[test] + fn constant_rgb_noise_fill_uses_its_exact_colour() { + use photocraft_doc::Fill; + let fill = fill_from_desc(b"GdFl", &noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], false)); + let Some(Fill::Gradient { stops, .. }) = fill else { panic!("constant noise gradient") }; + assert_eq!(stops, vec![(0.0, Color::rgb(0.5, 0.25, 0.0)), (1.0, Color::rgb(0.5, 0.25, 0.0))]); + } + + #[test] + fn variable_or_transparent_noise_fill_is_not_fabricated_as_a_black_to_white_ramp() { + for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] { + assert!(fill_from_desc(b"GdFl", &descriptor).is_none()); + } + } + /// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a /// gradient fill's opacity stops reach the model (as its opacity stops; written from stop /// alpha) and survive a write; the ramp's alpha follows them. @@ -888,8 +948,6 @@ mod tests { assert_eq!(super::brst_data(0), None); } - use super::*; - #[test] fn placed_layer_warp_is_read() { use photocraft_geom::warp::WarpStyle; diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..ab42d0f8ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -42,6 +42,10 @@ what an agent can't finish alone. ### G1. PSD fidelity and Photoshop acceptance - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. +- **Partial (2026-10-15, #5972 A1):** `GdFl` noise fills render when all three RGB ranges are + constant and transparency is disabled. Variable-range synthesis, HSB/Lab modes and + transparency remain unsupported and are preserved verbatim; the five visual examples do not + establish Photoshop's exact noise algorithm or include this degenerate constant case. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers @@ -270,6 +274,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | From 0814a1999e4b18795e33c6d3b2cce09cec806e39 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Sun, 18 Oct 2026 19:13:42 -0400 Subject: [PATCH 0734/1452] size bigtiff long8, slong8 and ifd8 entries as 8 bytes --- crates/raw/src/tiff.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index 671ab11fbe..a5c60ef57f 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -121,7 +121,7 @@ pub(crate) fn type_size(typ: u16) -> usize { 1 | 2 | 6 | 7 => 1, 3 | 8 => 2, 4 | 9 | 11 | 13 => 4, - 5 | 10 | 12 => 8, + 5 | 10 | 12 | 16 | 17 | 18 => 8, _ => 0, } } @@ -386,6 +386,23 @@ mod tests { assert_eq!(t.all_ifds().len(), 1); } + #[test] + fn eight_byte_types_are_read_from_their_offset() { + for typ in [16u16, 17, 18] { + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&50000u16.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&26u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes()); + let t = Tiff::new(&b).unwrap(); + let ifd = t.ifd_at(8, 0).unwrap(); + assert_eq!(t.raw(ifd.get(50000).unwrap()), Some(&0x0102_0304_0506_0708u64.to_le_bytes()[..]), "type {typ}"); + } + } + #[test] fn rational_with_zero_denominator_is_none() { let mut b = b"II*\0\x08\0\0\0".to_vec(); From fdb179401480834d7c3b15b30f84892f7956931a Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Sun, 18 Oct 2026 19:21:31 -0400 Subject: [PATCH 0735/1452] share hsb conversions between photocraft-color and the color picker --- crates/color/src/convert.rs | 55 +++++++++++++ crates/ui-egui/src/color_picker_ui.rs | 109 +++++++++++++++----------- crates/ui-egui/src/panels.rs | 6 +- 3 files changed, 122 insertions(+), 48 deletions(-) diff --git a/crates/color/src/convert.rs b/crates/color/src/convert.rs index ed7cb60e8e..6b864a9b68 100644 --- a/crates/color/src/convert.rs +++ b/crates/color/src/convert.rs @@ -213,6 +213,44 @@ pub fn rgb_to_gray(rgb: [f32; 3]) -> f32 { 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] } +pub fn rgb_to_hsb_sextant(rgb: [f32; 3]) -> [f32; 3] { + let (mx, mn) = (rgb[0].max(rgb[1]).max(rgb[2]), rgb[0].min(rgb[1]).min(rgb[2])); + let d = mx - mn; + let h = if d <= 0.0 { + 0.0 + } else if mx == rgb[0] { + ((rgb[1] - rgb[2]) / d).rem_euclid(6.0) + } else if mx == rgb[1] { + (rgb[2] - rgb[0]) / d + 2.0 + } else { + (rgb[0] - rgb[1]) / d + 4.0 + }; + [h, if mx > 0.0 { d / mx } else { 0.0 }, mx] +} + +pub fn rgb_from_hue_sextant(sextant: f32, chroma: f32, offset: f32) -> [f32; 3] { + let x = chroma * (1.0 - (sextant.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match sextant as u32 { + 0 => (chroma, x, 0.0), + 1 => (x, chroma, 0.0), + 2 => (0.0, chroma, x), + 3 => (0.0, x, chroma), + 4 => (x, 0.0, chroma), + _ => (chroma, 0.0, x), + }; + [r + offset, g + offset, b + offset] +} + +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let chroma = v * s; + rgb_from_hue_sextant(h.rem_euclid(360.0) / 60.0, chroma, v - chroma) +} + +pub fn rgb_to_hsb(rgb: [f32; 3]) -> [f32; 3] { + let [h, s, v] = rgb_to_hsb_sextant(rgb); + [60.0 * h, s, v] +} + #[cfg(test)] mod tests { use super::*; @@ -307,4 +345,21 @@ mod tests { } } } + + #[test] + fn hsb_conversions_match_known_colours_and_round_trip() { + assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]); + assert_eq!(hsb_to_rgb([0.0, 0.0, 0.5]), [0.5, 0.5, 0.5]); + assert_eq!(rgb_to_hsb([0.0, 0.0, 1.0]), [240.0, 1.0, 1.0]); + assert_eq!(rgb_to_hsb([0.25, 0.25, 0.25]), [0.0, 0.0, 0.25]); + for r in 0..=8 { + for g in 0..=8 { + for b in 0..=8 { + let c = [r as f32 / 8.0, g as f32 / 8.0, b as f32 / 8.0]; + let back = hsb_to_rgb(rgb_to_hsb(c)); + assert!(back.iter().zip(c).all(|(x, y)| (x - y).abs() < 1e-5), "{c:?} -> {back:?}"); + } + } + } + } } diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 19272a62ed..4f63c2935a 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -6,6 +6,7 @@ //! canvas is a pipette, and a click or drag there samples into the new colour ([`sample_at`]). use egui::{Color32, Mesh, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_color::convert::{hsb_to_rgb, rgb_to_hsb}; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -55,37 +56,6 @@ pub fn not_web_safe(rgb: [f32; 3]) -> Option<[f32; 3]> { (hex(web) != hex(rgb)).then_some(web) } -pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { - let h = (h.rem_euclid(360.0)) / 60.0; - let c = v * s; - let x = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b) = match h as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - [r + m, g + m, b + m] -} - -pub fn rgb_to_hsv(c: [f32; 3]) -> [f32; 3] { - let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); - let d = mx - mn; - let h = if d <= 0.0 { - 0.0 - } else if mx == c[0] { - 60.0 * ((c[1] - c[2]) / d).rem_euclid(6.0) - } else if mx == c[1] { - 60.0 * ((c[2] - c[0]) / d + 2.0) - } else { - 60.0 * ((c[0] - c[1]) / d + 4.0) - }; - [h, if mx <= 0.0 { 0.0 } else { d / mx }, mx] -} - pub fn hex(c: [f32; 3]) -> String { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; format!("#{:02x}{:02x}{:02x}", q(c[0]), q(c[1]), q(c[2])) @@ -108,12 +78,12 @@ pub fn field_color(mode: &str, z: f32, x: f32, y: f32) -> [f32; 3] { return lab_rgb(lab); } match mode { - "s" => hsv_to_rgb(x * 360.0, z, 1.0 - y), - "v" => hsv_to_rgb(x * 360.0, 1.0 - y, z), + "s" => hsb_to_rgb([x * 360.0, z, 1.0 - y]), + "v" => hsb_to_rgb([x * 360.0, 1.0 - y, z]), "r" => [z, 1.0 - y, x], "g" => [1.0 - y, z, x], "b" => [x, 1.0 - y, z], - _ => hsv_to_rgb(z * 360.0, x, 1.0 - y), + _ => hsb_to_rgb([z * 360.0, x, 1.0 - y]), } } @@ -141,12 +111,12 @@ fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3], lab: [f32; 3]) return lab_rgb(l); } match mode { - "s" => hsv_to_rgb(hsv[0], z, hsv[2]), - "v" => hsv_to_rgb(hsv[0], hsv[1], z), + "s" => hsb_to_rgb([hsv[0], z, hsv[2]]), + "v" => hsb_to_rgb([hsv[0], hsv[1], z]), "r" => [z, rgb[1], rgb[2]], "g" => [rgb[0], z, rgb[2]], "b" => [rgb[0], rgb[1], z], - _ => hsv_to_rgb(z * 360.0, 1.0, 1.0), + _ => hsb_to_rgb([z * 360.0, 1.0, 1.0]), } } @@ -186,7 +156,7 @@ pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map) -> Components { let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); Components { rgb, - hsv: kept(f, "__hsv", rgb, |h| hsv_to_rgb(h[0], h[1], h[2])).unwrap_or_else(|| rgb_to_hsv(rgb)), + hsv: kept(f, "__hsv", rgb, hsb_to_rgb).unwrap_or_else(|| rgb_to_hsb(rgb)), lab: kept(f, "__lab", rgb, photocraft_color::convert::lab_to_srgb).unwrap_or_else(|| photocraft_color::convert::srgb_to_lab(rgb)), cmyk: kept(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb).unwrap_or_else(|| photocraft_color::convert::rgb_to_cmyk(rgb)), } @@ -537,7 +507,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { _ => None, }; match (h, slider_lab(&mode, z, lab)) { - (Some(h), _) => set_rgb(f, hsv_to_rgb(h[0], h[1], h[2]), Keep::Hsv(h)), + (Some(h), _) => set_rgb(f, hsb_to_rgb(h), Keep::Hsv(h)), (None, Some(l)) => set_rgb(f, lab_rgb(l), Keep::Lab(l)), (None, None) => set_rgb(f, slider_color(&mode, z, hsv, rgb, lab), Keep::Nothing), } @@ -652,7 +622,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo let (mut h, mut c) = (hsv, rgb); if i < 3 { h[i] = if i == 0 { v } else { v / 100.0 }; - c = hsv_to_rgb(h[0], h[1], h[2]); + c = hsb_to_rgb(h); edit = Some((c, Keep::Hsv(h))); } else { c[i - 3] = v / 255.0; @@ -1060,7 +1030,7 @@ mod tests { f.insert("__webOnly".into(), json!(true)); set_rgb(&mut f, parse_hex("#ff8800").unwrap(), Keep::Nothing); assert_eq!(f["color"], json!("#ff9900")); - set_rgb(&mut f, hsv_to_rgb(40.0, 1.0, 1.0), Keep::Hsv([40.0, 1.0, 1.0])); + set_rgb(&mut f, hsb_to_rgb([40.0, 1.0, 1.0]), Keep::Hsv([40.0, 1.0, 1.0])); assert_eq!(f["color"], json!("#ff9900")); assert!(f.get("__hsv").is_none(), "kept HSV does not outlive a web snap"); set_rgb(&mut f, photocraft_color::convert::lab_to_srgb([50.0, 40.0, 20.0]).map(|c| c.clamp(0.0, 1.0)), Keep::Lab([50.0, 40.0, 20.0])); @@ -1144,11 +1114,60 @@ mod tests { assert_eq!(parse_hex("#ff880"), None); } + #[test] + fn shared_hsb_output_matches_the_old_picker_conversions() { + fn old_hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let h = (h.rem_euclid(360.0)) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m] + } + fn old_rgb_to_hsv(c: [f32; 3]) -> [f32; 3] { + let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); + let d = mx - mn; + let h = if d <= 0.0 { + 0.0 + } else if mx == c[0] { + 60.0 * ((c[1] - c[2]) / d).rem_euclid(6.0) + } else if mx == c[1] { + 60.0 * ((c[2] - c[0]) / d + 2.0) + } else { + 60.0 * ((c[0] - c[1]) / d + 4.0) + }; + [h, if mx <= 0.0 { 0.0 } else { d / mx }, mx] + } + let bits = |c: [f32; 3]| c.map(f32::to_bits); + for h in (0..=720).map(|i| i as f32 * 0.5) { + for s in (0..=20).map(|i| i as f32 / 20.0) { + for v in (0..=20).map(|i| i as f32 / 20.0) { + assert_eq!(bits(hsb_to_rgb([h, s, v])), bits(old_hsv_to_rgb(h, s, v)), "{h} {s} {v}"); + } + } + } + for r in 0..=32 { + for g in 0..=32 { + for b in 0..=32 { + let c = [r as f32 / 32.0, g as f32 / 32.0, b as f32 / 32.0]; + assert_eq!(bits(rgb_to_hsb(c)), bits(old_rgb_to_hsv(c)), "{c:?}"); + } + } + } + } + #[test] fn hsv_round_trips() { for c in [[1.0, 0.0, 0.0], [0.2, 0.6, 0.4], [0.5, 0.5, 0.5], [0.0, 0.0, 1.0], [0.9, 0.8, 0.1]] { - let h = rgb_to_hsv(c); - let back = hsv_to_rgb(h[0], h[1], h[2]); + let h = rgb_to_hsb(c); + let back = hsb_to_rgb(h); for i in 0..3 { assert!((back[i] - c[i]).abs() < 1e-5, "{c:?} -> {h:?} -> {back:?}"); } @@ -1160,7 +1179,7 @@ mod tests { #[test] fn field_and_locate_are_inverse_in_every_mode() { let rgb = [0.8, 0.3, 0.2]; - let (hsv, lab) = (rgb_to_hsv(rgb), photocraft_color::convert::srgb_to_lab(rgb)); + let (hsv, lab) = (rgb_to_hsb(rgb), photocraft_color::convert::srgb_to_lab(rgb)); for (m, _) in MODES.iter().chain(LAB_MODES) { let (x, y, z) = locate(m, hsv, rgb, lab); let c = field_color(m, z, x, y); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..2f102f96a0 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3951,12 +3951,12 @@ fn srgb_bytes(c: [f32; 4]) -> [u8; 3] { /// so a 50% grey has Brightness 50%. egui's `Hsva` conversions work in linear light, which put /// that grey at 22% and drew the field's middle as 188 (#3323); `Hsva` only holds the numbers. fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva { - let [h, s, v] = crate::color_picker_ui::rgb_to_hsv([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0))); + let [h, s, v] = photocraft_color::convert::rgb_to_hsb([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0))); egui::ecolor::Hsva::new(h / 360.0, s, v, 1.0) } fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] { - let [r, g, b] = crate::color_picker_ui::hsv_to_rgb(h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0)); + let [r, g, b] = photocraft_color::convert::hsb_to_rgb([h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0)]); [r, g, b, 1.0] } @@ -4451,7 +4451,7 @@ mod color_tests { assert_eq!(hsva_color32(egui::ecolor::Hsva::new(0.3, 0.0, 0.5, 1.0)), Color32::from_gray(128)); // The same numbers as the Color Picker dialog for a saturated colour. let c = [0.847, 0.271, 0.180]; - let (ours, dialog) = (srgb_hsva([c[0], c[1], c[2], 1.0]), crate::color_picker_ui::rgb_to_hsv(c)); + let (ours, dialog) = (srgb_hsva([c[0], c[1], c[2], 1.0]), photocraft_color::convert::rgb_to_hsb(c)); assert!((ours.h * 360.0 - dialog[0]).abs() < 0.5 && (ours.s - dialog[1]).abs() < 1e-3 && (ours.v - dialog[2]).abs() < 1e-3, "{ours:?} vs {dialog:?}"); } From 89195d7f16f78b8d6947396d9ef246d89286cb96 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 16:35:59 -0700 Subject: [PATCH 0736/1452] Save: ask before replacing a hard link to the file the dialog named A save whose added or lowercased extension names another entry in the folder now asks before replacing it, even when that entry is a hard link to the file the save dialog asked about. Two spellings of one name on a file system that ignores letter case still count as the same entry and do not ask. --- apps/photocraft/src/services.rs | 48 ++++++++++++++++++++++++++++++- crates/ui-egui/src/file_dialog.rs | 5 ++-- crates/ui-egui/src/lib.rs | 9 +++--- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 1b2cb20a26..2dddaa89f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -346,6 +346,24 @@ fn same_file(a: &Path, b: &Path) -> bool { } } +/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file +/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a +/// file system that ignores letter case is the same entry; a hard link is another entry, which a +/// save replaces while the first name keeps the old file. False when the folder can't be read. +fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool { + let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf(); + if !same_file(a, b) || !same_file(&folder(a), &folder(b)) { + return false; + } + let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false }; + if x == y { + return true; + } + let Ok(entries) = std::fs::read_dir(folder(a)) else { return false }; + let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect(); + !(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y)) +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -559,7 +577,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), - same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))), + same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))), automation_read, automation_write, automation_open_pcraft, @@ -973,6 +991,34 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A hard link is another entry in the folder, though it reaches the same file: a save to it + /// replaces that entry and leaves the other name on the old file. Another spelling of a name, + /// where letter case is ignored, is the same entry. + #[test] + fn a_hard_link_is_another_entry() { + let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id())); + std::fs::create_dir_all(dir.join("other")).unwrap(); + let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd")); + std::fs::write(&cat, b"cat").unwrap(); + std::fs::hard_link(&cat, &bare).unwrap(); + std::fs::hard_link(&cat, &other).unwrap(); + // Where both names reach one file (one inode on Unix), the folder still lists both. + let one_file = |_: &Path, _: &Path| true; + assert!(!same_entry(&bare, &cat, one_file)); + assert!(!same_entry(&bare, &cat, same_file)); + assert!(!same_entry(&other, &cat, same_file), "another folder"); + assert!(same_entry(&cat, &cat, one_file)); + assert!(same_entry(&cat, &cat, same_file)); + if upper.exists() { + assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored"); + } else { + std::fs::hard_link(&cat, &upper).unwrap(); + assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart"); + } + assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7c9e83a0bb..c7ac09dbc7 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -185,9 +185,10 @@ impl PhotocraftApp { /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog /// answered `answered`, and [`saved_path`] changed it. A file at `path` that - /// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the + /// [`Services::same_file`](crate::Services::same_file) finds is the same entry as `answered` (the /// same name in another letter case on a file system that ignores case, as Windows and macOS do - /// by default) counts as asked about. Never on the web, which has no file stamps. + /// by default) counts as asked about; a hard link to it does not. Never on the web, which has no + /// file stamps. fn replaces_unasked(&self, answered: &str, path: &str) -> bool { let Some(stamp) = self.services.file_stamp.as_ref() else { return false }; answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path)) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4cedac077..3ba415b22c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -367,7 +367,7 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; -/// Whether two paths reach the same file on disk; false when either can't be read. +/// Whether two paths name one entry in one folder on disk; false when either can't be read. pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call @@ -476,9 +476,10 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, - /// Whether two paths reach the same file (native): a save dialog's answer and the path the - /// save writes to after Lowercase Extension, on a file system that ignores letter case. A save - /// asks before replacing a file it finds different (`replace_prompt`). + /// Whether two paths name one entry in one folder (native): a save dialog's answer and the + /// path the save writes to after Lowercase Extension, on a file system that ignores letter + /// case. A hard link is another entry. A save asks before replacing an entry it finds + /// different (`replace_prompt`). pub same_file: Option, /// `write` only asks for the bytes to be kept (a browser download, which the user can /// cancel and which reports nothing back), so a save through it names the document but From 8d0c579f02d521eeef67db9e3c666732d9c60ed3 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Thu, 15 Oct 2026 01:01:37 -0700 Subject: [PATCH 0737/1452] Raw: a Nikon maker note's ColorSpace sets the preview colour space (#5987) embedded_preview_color_space only read the EXIF ColorSpace, but Nikon bodies write none: they say Adobe RGB only in the maker note (tag 0x001E: 1 sRGB, 2 Adobe RGB). A Nikon NEF with Adobe RGB selected had its JPEG preview read as sRGB by the colour fit. When the EXIF does not state a colour space, the Nikon maker note's is used (the EXIF still wins, a DNG still states nothing, other makers' maker notes are not read). The maker-note locator in tiffep.rs is shared. A packed Nikon NEF with the tag joins the hostile-input seeds, and rawinfo prints the stated colour space. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh --- crates/raw/README.md | 6 +- crates/raw/examples/rawinfo.rs | 10 ++- crates/raw/src/preview.rs | 125 +++++++++++++++++++++++++++++++-- crates/raw/src/tiffep.rs | 2 +- crates/raw/tests/hostile.rs | 33 ++++++++- docs/gaps.md | 6 ++ 6 files changed, 171 insertions(+), 11 deletions(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index bb4815a4fc..6fe63bcd66 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -119,7 +119,11 @@ out). One cell in three is held out: the look is used only when it shows the sam the matrix is plausible and the held-out mean CIE76 ΔE drops to at most 85 % of the fallback's. Pass it as `DevelopOptions::camera_look`; `develop` then notes "colour fitted to the camera's embedded JPEG (mean ΔE …)". The crate cannot decode JPEG itself: `photocraft-io` decodes the -smallest preview at least 512 pixels long, fits and develops in one go. +smallest preview at least 512 pixels long, fits and develops in one go. A preview without an ICC +profile is read in the colour space the raw states (`embedded_preview_color_space`): the EXIF +ColorSpace (a Sony body set to Adobe RGB writes Uncalibrated with Interoperability "R03"), else a +Nikon maker note's ColorSpace (0x001E; Nikon writes no EXIF ColorSpace), sRGB when the file says +nothing. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index b71e2393bd..6b1722b720 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -9,7 +9,7 @@ use std::time::Instant; -use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, identify}; +use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, embedded_preview_color_space, identify}; fn main() { let mut args = std::env::args().skip(1); @@ -76,7 +76,13 @@ fn main() { println!("{}", dump_structure(&bytes)); } if let Some(p) = embedded_preview(&bytes) { - println!(" preview: {}x{} JPEG, {} bytes", p.width, p.height, p.jpeg.len()); + println!( + " preview: {}x{} JPEG, {} bytes, colour space stated by the file: {:?}", + p.width, + p.height, + p.jpeg.len(), + embedded_preview_color_space(&bytes) + ); if let Some(dir) = png.as_ref().filter(|d| std::path::Path::new(d).is_dir()) { let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let _ = std::fs::write(format!("{dir}/{stem}.preview.jpg"), p.jpeg); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index d5dd013310..4a8139045d 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -107,18 +107,30 @@ pub enum PreviewColorSpace { AdobeRgb, } -/// The colour space the raw's EXIF gives for the camera's JPEG rendering, for a preview that declares none itself: -/// ColorSpace (0xA001) 1 = sRGB, or Uncalibrated (0xFFFF) with the Interoperability IFD's InteropIndex "R03", the -/// DCF option file for Adobe RGB (as a Sony body set to Adobe RGB writes; Exif and DCF specifications). `None` when -/// the file doesn't say, and for a DNG, whose EXIF describes the original capture while its previews are the -/// converter's. A preview's own ICC profile takes precedence over this. +/// Nikon maker note tag ColorSpace: 1 = sRGB, 2 = Adobe RGB (4 = BT.2100, which is not a preview colour space we +/// read), from Nikon's public tag documentation. +const NIKON_COLOR_SPACE: u16 = 0x001E; + +/// The colour space the raw states for the camera's JPEG rendering, for a preview that declares none itself: +/// * the EXIF ColorSpace (0xA001) 1 = sRGB, or Uncalibrated (0xFFFF) with the Interoperability IFD's InteropIndex +/// "R03", the DCF option file for Adobe RGB (as a Sony body set to Adobe RGB writes; Exif and DCF specifications); +/// * else, where the EXIF doesn't decide, a Nikon maker note's ColorSpace (0x001E): Nikon bodies write no EXIF +/// ColorSpace, and say Adobe RGB only here. +/// +/// `None` when the file doesn't say, and for a DNG, whose EXIF describes the original capture while its previews +/// are the converter's. A preview's own ICC profile takes precedence over this. pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { let t = Tiff::new(bytes)?; let ifd0 = t.ifd_at(t.first_ifd, 0)?; if ifd0.has(tag::DNG_VERSION) { return None; } - let exif = t.ifd_at(t.tag_uint(&ifd0, tag::EXIF_IFD)? as usize, 0)?; + exif_color_space(&t, &ifd0).or_else(|| nikon_color_space(&t)) +} + +/// The colour space the EXIF IFD gives (see [`embedded_preview_color_space`]). +fn exif_color_space(t: &Tiff<'_>, ifd0: &crate::tiff::Ifd) -> Option { + let exif = t.ifd_at(t.tag_uint(ifd0, tag::EXIF_IFD)? as usize, 0)?; match t.tag_uint(&exif, tag::COLOR_SPACE)? { 1 => Some(PreviewColorSpace::Srgb), 0xFFFF => { @@ -129,6 +141,16 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { } } +/// The colour space a Nikon maker note gives. +fn nikon_color_space(t: &Tiff<'_>) -> Option { + let (note, ifd) = crate::tiffep::nikon_maker_note(t, &t.all_ifds())?; + match note.tag_uint(&ifd, NIKON_COLOR_SPACE)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -167,4 +189,95 @@ mod tests { assert_eq!(embedded_preview_color_space(junk), None); } } + + /// The maker note's embedded TIFF stream (`ifd` entries, in the given byte order) behind `header`. + fn maker_note(header: &[u8], tags: Vec<(u16, Val)>, big_endian: bool) -> Vec { + let mut note = TiffBuilder { big_endian, ..Default::default() }; + let ifd = note.ifd(tags); + note.chain = vec![ifd]; + let mut maker = header.to_vec(); + maker.extend(note.build()); + maker + } + + /// A raw whose EXIF IFD holds the entries `exif` makes (it may add directories to the builder) and a maker note. + fn raw_with_note(maker: Vec, exif: impl FnOnce(&mut TiffBuilder) -> Vec<(u16, Val)>, dng: bool) -> Vec { + let mut t = TiffBuilder::default(); + let mut exif = exif(&mut t); + exif.push((tag::MAKER_NOTE, Val::Undefined(maker))); + let exif = t.ifd(exif); + let mut ifd0 = vec![(tag::MAKE, Val::Ascii("NIKON CORPORATION".into())), (tag::EXIF_IFD, Val::Ifds(vec![exif]))]; + if dng { + ifd0.push((tag::DNG_VERSION, Val::Byte(vec![1, 6, 0, 0]))); + } + t.chain = vec![t.ifd(ifd0)]; + t.build() + } + + const NIKON: &[u8] = b"Nikon\0\x02\x10\0\0"; + + /// A Nikon raw whose maker note has ColorSpace `color_space` (when given) and whose EXIF IFD holds `exif`. + fn nikon_raw(color_space: Option, exif: impl FnOnce(&mut TiffBuilder) -> Vec<(u16, Val)>, big_endian: bool) -> Vec { + let tags = color_space.map(|cs| (NIKON_COLOR_SPACE, Val::Short(vec![cs]))).into_iter().chain([(0x0002, Val::Ascii("200".into()))]).collect(); + raw_with_note(maker_note(NIKON, tags, big_endian), exif, false) + } + + /// EXIF entries for a preview in Adobe RGB the way a Sony body writes it: Uncalibrated, Interoperability "R03". + fn exif_r03(t: &mut TiffBuilder) -> Vec<(u16, Val)> { + let interop = t.ifd(vec![(tag::INTEROP_INDEX, Val::Ascii("R03".into()))]); + vec![(tag::COLOR_SPACE, Val::Short(vec![0xFFFF])), (tag::INTEROP_IFD, Val::Ifds(vec![interop]))] + } + + /// Nikon bodies write no EXIF ColorSpace; the maker note's ColorSpace (1 sRGB, 2 Adobe RGB) is + /// what says their JPEG previews are Adobe RGB. + #[test] + fn nikon_maker_note_colour_space_of_the_previews() { + for big_endian in [false, true] { + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(2), |_| vec![], big_endian)), Some(PreviewColorSpace::AdobeRgb), "be {big_endian}"); + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(1), |_| vec![], big_endian)), Some(PreviewColorSpace::Srgb), "be {big_endian}"); + } + // BT.2100 (4), unknown values, a note without the tag + for cs in [Some(4), Some(0), Some(3), Some(0xFFFF), None] { + assert_eq!(embedded_preview_color_space(&nikon_raw(cs, |_| vec![], false)), None, "{cs:?}"); + } + } + + /// The EXIF IFD, when it states a colour space, comes before the maker note; when it does not decide, the + /// maker note does. + #[test] + fn exif_colour_space_comes_before_the_nikon_maker_note() { + // EXIF says sRGB, the note Adobe RGB: EXIF wins. + let raw = nikon_raw(Some(2), |_| vec![(tag::COLOR_SPACE, Val::Short(vec![1]))], false); + assert_eq!(embedded_preview_color_space(&raw), Some(PreviewColorSpace::Srgb)); + // EXIF says Adobe RGB (R03), the note sRGB: EXIF wins. + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(1), exif_r03, false)), Some(PreviewColorSpace::AdobeRgb)); + // EXIF says Uncalibrated without R03, which decides nothing: the note does. + let raw = nikon_raw(Some(2), |_| vec![(tag::COLOR_SPACE, Val::Short(vec![0xFFFF]))], false); + assert_eq!(embedded_preview_color_space(&raw), Some(PreviewColorSpace::AdobeRgb)); + } + + /// Only a Nikon maker note is read, and not for a DNG, whose previews are the converter's. + #[test] + fn only_a_nikon_maker_note_of_a_non_dng_is_read() { + let tags = || vec![(NIKON_COLOR_SPACE, Val::Short(vec![2]))]; + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(NIKON, tags(), false), |_| vec![], false)), Some(PreviewColorSpace::AdobeRgb)); + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(NIKON, tags(), false), |_| vec![], true)), None); + for header in [&b"OLYMPUS\0II\x03\0"[..], b"SONY DSC \0\0\0", b"Nikon", b""] { + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(header, tags(), false), |_| vec![], false)), None, "{header:?}"); + } + } + + /// A maker note that is cut short or points outside the file states nothing, and never panics. + #[test] + fn a_damaged_nikon_maker_note_states_nothing() { + let good = maker_note(NIKON, vec![(NIKON_COLOR_SPACE, Val::Short(vec![2]))], false); + assert_eq!(embedded_preview_color_space(&raw_with_note(good.clone(), |_| vec![], false)), Some(PreviewColorSpace::AdobeRgb)); + for len in 0..good.len() { + let _ = embedded_preview_color_space(&raw_with_note(good[..len].to_vec(), |_| vec![], false)); + } + // the embedded TIFF's first-IFD offset (bytes 14..18 of the note) points outside the file + let mut wild = good.clone(); + wild[14..18].copy_from_slice(&[0xFF; 4]); + assert_eq!(embedded_preview_color_space(&raw_with_note(wild, |_| vec![], false)), None); + } } diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 25befbddea..d15b645604 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -66,7 +66,7 @@ pub(crate) fn cfa(t: &Tiff, raw: &Ifd, ifds: &[Ifd]) -> Option { /// Nikon maker note: "Nikon\0", version, then an embedded TIFF header whose /// offsets are relative to itself. -fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { +pub(crate) fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { let e = ifds.iter().find_map(|i| i.get(tag::MAKER_NOTE).copied())?; let b = t.raw(&e)?; if !b.starts_with(b"Nikon\0") { diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/hostile.rs index 3212d554e1..e376f23e9a 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/hostile.rs @@ -1,9 +1,32 @@ //! Truncated and corrupted raw files must fail cleanly, never panic, and //! never allocate beyond the limits. -use photocraft_raw::testgen::{Cr2Spec, DngSpec, DngStorage, RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, orf, rw2, scene, sony_craw, tiff_ep}; +use photocraft_raw::testgen::{ + Cr2Spec, DngSpec, DngStorage, RafPacking, RafSpec, Val, XTRANS, mosaic, mosaic_cfa, nef_compressed, orf, rw2, scene, sony_craw, tiff_ep, +}; use photocraft_raw::*; +/// 12-bit samples packed little-endian, `w` per row, rows byte-aligned (Nikon's packed NEF layout). +fn pack12(values: &[u16], w: usize) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += 12; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + } + out +} + /// Losslessly encodes 16-bit RGB as a JPEG XL codestream. fn jxl_rgb16(rgb: &[u16], w: usize, h: usize) -> Vec { use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; @@ -84,6 +107,8 @@ fn samples() -> Vec> { .build(), ); out.push(tiff_ep("NIKON", w, h, &cfa, [0, 1, 1, 2], 12, vec![])); + // A Nikon NEF (packed 12-bit) whose maker note says the previews are Adobe RGB (ColorSpace 0x001E). + out.push(nef_compressed(w, h, 12, pack12(&cfa, w), [0, 1, 1, 2], false, vec![(0x0093, Val::Short(vec![6])), (0x001E, Val::Short(vec![2]))])); let codes = mosaic(&scene(64, 6), 64, [0, 1, 1, 2], 256, 1900); out.push(sony_craw(64, 6, &codes, [8000, 10400, 12900, 14100])); out.push(rw2(30, 8, &mosaic(&scene(30, 8), 30, [0, 1, 1, 2], 128, 4095), 12)); @@ -126,6 +151,12 @@ fn originals_develop() { } } +#[test] +fn a_seed_states_a_maker_note_colour_space() { + // The truncation and corruption tests below only exercise the maker-note reader if a seed has one. + assert!(samples().iter().any(|b| embedded_preview_color_space(b) == Some(PreviewColorSpace::AdobeRgb))); +} + #[test] fn every_truncation_fails_cleanly() { for b in samples() { diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..3b8a54917c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -129,6 +129,11 @@ what an agent can't finish alone. tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a measured D5100 colour calibration; [validation](nikon-nef-development.md). +- **Progress (2026-10-14):** a Nikon NEF's embedded preview takes its colour space from the + maker note's ColorSpace (0x001E: 1 sRGB, 2 Adobe RGB) when the EXIF states none, so the + colour fit reads an Adobe RGB preview as Adobe RGB (49 of the 291 public Nikon samples; + Nikon writes no EXIF ColorSpace); `crates/raw/src/preview.rs`. A raw's preview-fallback + document is still opened untagged. - **Progress (2026-10-13):** the D90's image area (4288 × 2848 at (10, 10) in a 4352 × 2868 plane) is measured by aligning the decoded plane with the camera's own JPEG, so a D90 NEF opens on the camera's frame instead of the uncropped sensor plane; @@ -272,6 +277,7 @@ what an agent can't finish alone. |---|---|---| | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-14 | minor | G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E); gap remains open | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | From ea0eec98a3cd238e97505a0bb7c978330f4f9791 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 08:42:52 -0700 Subject: [PATCH 0738/1452] macOS: list fonts activated outside the font folders (#6683) The installed-font scan read only /System/Library/Fonts, /Library/Fonts and ~/Library/Fonts (plus PingFang). Fonts macOS offers from anywhere else, such as those a font manager, Adobe Fonts or Office activated, never reached the font menus although other apps showed them. load_system_fonts now also registers every file Core Text offers. The list comes from fontique's Core Text backend, enabled for macOS only, used as a scratch collection whose families are discarded: only its file paths are read and registered in our own collection, so generic-family defaults and the merge of installed faces into the bundled families are unchanged. The reserved UI fonts under /System/Library/PrivateFrameworks are skipped (fontique cannot draw their outlines). On a stock macOS install the folder scan already listed all 183 Core Text families; the gap is fonts Core Text knows from elsewhere. With Hack activated from a scratch folder via CTFontManagerRegisterFontsForURL, the list had 313 families before and 314 after (Hack added, none removed), type.fonts lists it and a type layer renders in it. The scan stays 37-57 ms. Tests: font_files_of_lists_each_font_file_once (all platforms); coretext_lists_the_system_font_files and every_family_coretext_offers_is_listed (macOS). The last fails without the call when a font is activated elsewhere ("lacks: Hack") and passes with it. Cargo.lock gains fontique's optional per-platform dependencies for the `system` feature; `cargo tree` shows the feature only for aarch64-apple-darwin. Still open: Windows fonts outside the two folders (#3336) and the web build. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 38 ++++++++++++-- crates/text/Cargo.toml | 8 +++ crates/text/src/fonts.rs | 106 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aff38ac01b..5d7788208c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610 dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", - "roxmltree", + "roxmltree 0.20.0", "tempfile", "winprint", "winsafe", @@ -2116,7 +2116,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2141,9 +2141,17 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", "parlance", "read-fonts", + "roxmltree 0.21.1", "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", ] [[package]] @@ -4824,7 +4832,7 @@ dependencies = [ "photocraft-text", "photocraft-vector", "rayon", - "roxmltree", + "roxmltree 0.20.0", "same-file", "serde", "serde_json", @@ -4912,7 +4920,7 @@ dependencies = [ "photocraft-vector", "proptest", "resvg", - "roxmltree", + "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", ] @@ -5958,6 +5966,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7458,7 +7475,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "rustybuzz", "simplecss", "siphasher", @@ -8704,6 +8721,17 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 985f627e90..f7d4150fa1 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -28,6 +28,14 @@ flate2 = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } +# macOS only: the `system` feature adds fontique's Core Text backend, which lists every font the +# system offers wherever its file lives (fonts a font manager or Adobe Fonts activated, Office's +# own, Apple's on-demand downloads). `fonts.rs` reads only its list of files and registers them +# like the folder scan's, so our own collection keeps its behaviour (generic families, merging +# installed faces into the bundled families). It uses no fontconfig, unlike `system` on Linux. +[target.'cfg(target_os = "macos")'.dependencies] +parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts", "system"] } + [build-dependencies] # Deflates the bundled fonts (build.rs); `flate2` above inflates them. flate2 = { workspace = true } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 756ce87455..720d2b77eb 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -10,6 +10,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock}; use parley::FontContext; +#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))] +use parley::fontique::SourceKind; use parley::fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache}; use skrifa::raw::{FileRef, TableProvider, types::Tag}; use skrifa::{MetadataProvider, string::StringId}; @@ -254,6 +256,10 @@ impl FontDb { } #[cfg(target_os = "linux")] files.extend(fontconfig_font_paths()); + // Core Text knows every font macOS offers, also those activated from outside the + // font folders (#6683). + #[cfg(target_os = "macos")] + files.extend(coretext_font_paths()); // PingFang (the macOS Chinese UI font) lives outside the font folders. if cfg!(target_os = "macos") { files.extend(crate::cjk::mac_pingfang()); @@ -761,6 +767,49 @@ mod fontconfig_tests { } } +/// The font files Core Text offers: the installed fonts and those activated from any other folder +/// (a font manager, Adobe Fonts, Office, Apple's on-demand downloads). A scratch collection with +/// fontique's Core Text backend lists them; its fonts are not used, only their files, so our own +/// collection registers them like any scanned file. +#[cfg(target_os = "macos")] +fn coretext_font_paths() -> Vec { + // Reading arbitrary fonts must not take the scan down: no files is the folder scan's result. + std::panic::catch_unwind(|| { + let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true }); + let mut files = font_files_of(&mut system); + files.retain(|f| !f.starts_with(MACOS_PRIVATE_FONTS)); + files + }) + .unwrap_or_default() +} + +/// Fonts reserved for the system UI (PingFangUI.ttc among them). Core Text offers them, but +/// fontique can't draw their outlines (it works around PingFangUI for the same reason), so they +/// are not listed. +#[cfg(target_os = "macos")] +const MACOS_PRIVATE_FONTS: &str = "/System/Library/PrivateFrameworks"; + +/// The files `collection`'s fonts are read from, each once and in a fixed order. Fonts +/// registered from memory have no file. +#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))] +fn font_files_of(collection: &mut Collection) -> Vec { + let names: Vec = collection.family_names().map(str::to_string).collect(); + let mut files = HashSet::new(); + for name in names { + let Some(family) = collection.family_by_name(&name) else { + continue; + }; + for font in family.fonts() { + if let SourceKind::Path(path) = font.source().kind() { + files.insert(path.to_path_buf()); + } + } + } + let mut files: Vec<_> = files.into_iter().collect(); + files.sort(); + files +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn system_font_dirs() -> Vec { use std::path::PathBuf; @@ -903,6 +952,63 @@ mod tests { assert_eq!(super::localized_name("X", &[]), None); } + /// The files a collection's fonts come from (what Core Text's fonts are loaded from, #6683): + /// each once, none for fonts held in memory. + #[test] + fn font_files_of_lists_each_font_file_once() { + use super::{Blob, Collection, CollectionOptions}; + let dir = std::env::temp_dir().join(format!("photocraft-font-files-{}", std::process::id())); + let activated = dir.join("activated"); + std::fs::create_dir_all(&activated).unwrap(); + let (inter, mono) = (dir.join("inter.ttf"), activated.join("mono.ttf")); + std::fs::write(&inter, super::INTER_REGULAR.as_slice()).unwrap(); + std::fs::write(&mono, super::JETBRAINS_MONO_REGULAR.as_slice()).unwrap(); + let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: false }); + collection.load_fonts_from_paths([&dir]); + // The same file again and a font from memory add no files. + collection.load_fonts_from_paths([&inter]); + collection.register_fonts(Blob::new(std::sync::Arc::new(super::INTER_MEDIUM.clone())), None); + let files = super::font_files_of(&mut collection); + let _ = std::fs::remove_dir_all(&dir); + let mut expected = vec![inter, mono]; + expected.sort(); + assert_eq!(files, expected); + } + + /// Core Text lists the system's font files, all readable, none of the reserved UI fonts. + #[test] + #[cfg(target_os = "macos")] + fn coretext_lists_the_system_font_files() { + let files = super::coretext_font_paths(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_file() && !f.starts_with("/System/Library/PrivateFrameworks"))); + } + + /// Every family macOS offers is in the font list, not only those in the font folders (#6683). + #[test] + #[cfg(target_os = "macos")] + fn every_family_coretext_offers_is_listed() { + use super::{Collection, CollectionOptions, SourceKind}; + let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true }); + // Its names include localized aliases of a family ("Systemschrift"); the family has one name. + let aliases: Vec = system.family_names().map(str::to_string).collect(); + let mut offered = Vec::new(); + for family in aliases.iter().filter_map(|n| system.family_by_name(n)) { + // A family only the reserved UI fonts provide (PingFang before its download) is not listed. + let listed_file = + |font: &parley::fontique::FontInfo| matches!(font.source().kind(), SourceKind::Path(p) if !p.starts_with(super::MACOS_PRIVATE_FONTS)); + if !super::is_hidden_family(family.name()) && family.fonts().iter().any(listed_file) { + offered.push(family.name().to_string()); + } + } + offered.sort(); + offered.dedup(); + assert!(!offered.is_empty()); + let listed = super::FontDb::with_system_fonts().families(); + let missing: Vec<&String> = offered.iter().filter(|n| !listed.contains(n)).collect(); + assert!(missing.is_empty(), "macOS offers these families, the font list lacks them: {missing:?}"); + } + #[test] fn missing_font_dirs_are_skipped() { let mut files = Vec::new(); From bad6c62c80e69242edc1a13aacf2856029572e5c Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Thu, 15 Oct 2026 01:01:37 -0700 Subject: [PATCH 0739/1452] Raw: list the Nikon ColorSpace tag in the clean-room sources and bump the gaps status line (#5987) The README's clean-room section names every maker-note tag the crate reads and where it is documented; 0x001E is added, and the code comment now names ExifTool's Nikon tag documentation as its source. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh --- crates/raw/README.md | 2 +- crates/raw/src/preview.rs | 2 +- docs/gaps.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index 6fe63bcd66..56de3cf465 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -25,7 +25,7 @@ Implemented only from public specifications, papers and observation of files: * ITU-T T.81 (ISO 10918-1) Annex H: lossless JPEG, process 14 ("LJ92"). * The published description of Canon's CR2 container (header, raw IFD, slice tag 0xC640). * Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010), - SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony + SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C), ColorSpace (0x001E) and BlackLevel (0x003D), Sony BlackLevel (0x7310), WB_RGGBLevels (0x7313), SonyRawFileType (0x7000) and SonyToneCurve (0x7010), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings (0x2020) preview tags, the Fujifilm RAF header and metadata records (RawImageCropTopLeft / diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 4a8139045d..a090dee6c7 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -108,7 +108,7 @@ pub enum PreviewColorSpace { } /// Nikon maker note tag ColorSpace: 1 = sRGB, 2 = Adobe RGB (4 = BT.2100, which is not a preview colour space we -/// read), from Nikon's public tag documentation. +/// read), from ExifTool's public Nikon tag documentation. const NIKON_COLOR_SPACE: u16 = 0x001E; /// The colour space the raw states for the camera's JPEG rendering, for a preview that declares none itself: diff --git a/docs/gaps.md b/docs/gaps.md index 3b8a54917c..51c0e7996d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that From 01479be23aba55e6631be0d0064ada15ad6c8560 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 17:41:16 -0600 Subject: [PATCH 0740/1452] Toolbar: colour chips stay centred when the tools scroll (#7316) In a window too short for the tools, the toolbar's tool column scrolls, and egui widens the UI by the scroll bar. The colour chips (and Default and Switch Colors with them) were centred in that wider UI, so they sat a bar's width right of the tools: about 3 px in the Studio and Pro themes, more with Classic's wider bar. They are centred in the column as it is before the tools scroll now, as in a window tall enough not to scroll. Tested in every theme, one and two columns, at 1x and 2x: the chips paint at the same place in a 420 pt and a 1400 pt window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 72 ++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..191f2c47b5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -293,7 +293,9 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); - color_chips(app, ui, double); + // Centred in the tool column as it is before the tools scroll: a scroll bar widens the + // UI it is in, and the chips moved over by its width (#7316). + color_chips(app, ui, double, full.width()); if t.pro { ui.add_space(8.0); @@ -417,7 +419,7 @@ fn chips_height(pro: bool, studio: bool, wide: bool) -> f32 { /// Photoshop's foreground / background colour chips: the Default Colors (D) and Switch Colors (X) /// icons above them, the foreground chip over the background one, all centred in the toolbar /// column and kept inside it. -fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { +fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool, column: f32) { let t = Tokens::get(ui.ctx()); // Studio themes: round chips stacked vertically, the controls below them. let studio = t.round_chips; @@ -425,7 +427,7 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { // With two tool columns (a short window) the round chips lie side by side, which saves height. let wide = studio && two_columns; let group = if studio && !wide { chip } else { chip + step }; - let w = ui.available_width().max(group); + let w = column.max(group); let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro, studio, wide)), Sense::hover()); let left = (rect.left() + (w - group) / 2.0).round(); let (fg, bg) = if wide { @@ -4515,9 +4517,13 @@ mod color_tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; app.session.tools.background = [0.0, 0.0, 1.0, 1.0]; - let mut h = egui_kittest::Harness::builder() - .with_size(vec2(80.0, 120.0)) - .build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, two_columns), app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let w = ui.available_width(); + color_chips(app, ui, two_columns, w) + }, + app, + ); PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(2); h.get_by_label("Swap colours (X)").click(); @@ -4569,8 +4575,13 @@ mod color_tests { resize: None, preedit: None, }); - let mut h = - egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let w = ui.available_width(); + color_chips(app, ui, false, w) + }, + app, + ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(2); h.get_by_label("Swap colours (X)").click(); @@ -6237,6 +6248,51 @@ mod compact_visual_regressions { } } + /// #7316: a window too short for the tools scrolls them, and the scroll bar widened the UI + /// the colour chips were centred in, so they (and Switch Colors with them) sat a bar's width + /// right of the tools. They stay where they are in a window tall enough not to scroll. + #[test] + fn color_chips_stay_centred_when_the_tools_scroll() { + let chips = |kind: crate::theme::ThemeKind, double: bool, height: f32, scale: f32| { + let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, kind); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0]; + app.session.tools.background = [0.0, 1.0, 0.0, 1.0]; + app.ui.panels.toolbar_double = double; + let input = || input_at_scale(Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, height)), scale); + // The scroll bar shows from the frame after the one that measured the tools. + let mut output = ctx.run_ui(input(), |ui| toolbar(&mut app, ui)); + for _ in 0..2 { + output.textures_delta.clear(); + output = ctx.run_ui(input(), |ui| toolbar(&mut app, ui)); + } + output.textures_delta.clear(); + [Color32::from_rgb(255, 0, 255), Color32::GREEN].map(|color| { + output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) if rect.fill == color => Some(rect.rect.center()), + egui::Shape::Circle(circle) if circle.fill == color => Some(circle.center), + _ => None, + }) + .unwrap_or_else(|| panic!("{kind:?}: both color chips are painted")) + .x + }) + }; + for kind in crate::theme::ThemeKind::ALL { + for double in [false, true] { + for scale in [1.0, 2.0] { + let (short, tall) = (chips(kind, double, 420.0, scale), chips(kind, double, 1400.0, scale)); + assert_eq!(short, tall, "{kind:?}, two columns {double}, scale {scale}: the chips don't move when the tools scroll"); + } + } + } + } + #[test] fn compact_toolbar_keeps_both_color_chips_visible() { for kind in crate::theme::ThemeKind::ALL { From afa87061390ef0a461ee06ebf73215197bb9ab71 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 17:56:41 -0600 Subject: [PATCH 0741/1452] Move tool: soft edges stay partly selected and move whole, as in Photoshop (#7538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked against Photoshop 2025 on a 15 px feathered shape moved from a loose marquee: - Its selection after the move follows the edge's transparency: the marching ants run along the 50 % line and Quick Mask shows the edge partly selected (measured strength 0.10 / 0.29 / 0.42 / 0.64 / 0.81 where alpha is 0.05 / 0.24 / 0.39 / 0.57 / 0.71). We had made those pixels fully selected. - Moving it again, even after Deselect and Reselect, leaves nothing behind, though Delete with the same selection leaves a ghost: the Move tool lifts at most each pixel's alpha, not alpha × selection. So the move cut lifts min(alpha, selection) of each pixel (feathered moves over opaque pixels are unchanged: k lifted, 1 − k left), and the moved pixels' selection is what they took. Moving them again lifts them whole, with no faint outline, and their ants hug the soft edge's 50 % line. Also: arrow nudges add up to one floating piece, so up then down was back at no offset and the drop kept the loose marquee. A piece that moved and came back now keeps the moved pixels' selection, in one step; a click still changes nothing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/float_cmds.rs | 52 ++++++++++++++----- crates/engine/src/float_cmds/tests.rs | 75 +++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 323c0bee97..92e48692d8 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -33,9 +33,10 @@ pub struct CutParts { pub layer: LayerId, rest: Surface, piece: Surface, - /// The selection the moved pixels take, unmoved: the selection limited to where the layer has - /// pixels (Photoshop shrinks the marching ants to the piece as the drag starts, #7538). `None` - /// keeps the selection: a mask has no transparency, and an empty piece no outline. + /// The selection the moved pixels take, unmoved: the alpha the piece took, the selection + /// limited to the layer's transparency (Photoshop shrinks the marching ants to the piece as the + /// drag starts, #7538). `None` keeps the selection: a mask has no transparency, and an empty + /// piece no outline. outline: Option, /// The pieces are the layer's mask (`"target":"mask"`), not its pixels. mask: bool, @@ -76,20 +77,32 @@ impl CutParts { let w = b.width() as usize; let mut cover = Vec::with_capacity(if mask { 0 } else { w * b.height() as usize }); for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { - let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0); - if !mask { - // Where the layer has pixels, the selection keeps its strength: shrinking it to - // their alpha would leave half-transparent edges partly behind on the next - // move, a faint outline (#7538). - cover.push(if p[a] > 0.0 { k.max(0.0) } else { 0.0 }); + let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0).clamp(0.0, 1.0); + if mask { + // A mask has no alpha of its own: its selected part moves by the selection. + if k <= 0.0 { + p.fill(0.0); + } else { + p[a] *= k; + } + continue; } - if k <= 0.0 { + // The selection is how much of each pixel's alpha moves, at most all of it, as + // Photoshop's Move tool lifts it (#7538): a feathered selection over opaque pixels + // lifts k of them and leaves 1 − k, while one no stronger than the pixels' own + // alpha (the outline a previous move left) lifts them whole. Multiplying instead + // left a × (1 − a) of every half-transparent edge pixel behind, a faint outline. + let lifted = p[a].min(k); + // The moved pixels' selection is what they took with them (Photoshop's marching + // ants hug the 50 % line of a soft edge), so moving them again lifts them whole. + cover.push(lifted); + if lifted <= 0.0 { p.fill(0.0); } else { - p[a] *= k; + p[a] = lifted; } if !copy && !fill { - r[a] *= 1.0 - k; + r[a] -= lifted; } } if !mask { @@ -149,6 +162,9 @@ pub struct Floating { pub offset: (i32, i32), pub revision: u64, pub parts: Arc, + /// The piece has been moved at least once, even if it is back where it was cut (nudged away + /// and back): its selection is the moved pixels' then, as each of Photoshop's nudges leaves it. + pub moved: bool, } /// The floating piece of `st`, if it is still valid (the document hasn't changed under it). @@ -200,10 +216,11 @@ fn float(s: &mut Session, p: &Value) -> Result { if floating(st).is_none() { let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?; - st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) }); + st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false }); } let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?; f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy)); + f.moved |= (dx, dy) != (0, 0); Ok(json!({"layer": f.layer.0, "offset": [f.offset.0, f.offset.1]})) } @@ -218,6 +235,15 @@ pub fn drop_floating(s: &mut Session) -> Result { st.floating = None; let (dx, dy) = f.offset; if (dx, dy) == (0, 0) { + // Back where it was cut: the pixels are as they were. A piece that never moved (a click) + // changes nothing; one nudged away and back keeps the moved pixels' selection (#7538). + if let Some(outline) = f.parts.outline().filter(|_| f.moved) { + let outline = outline.clone(); + s.edit("Move", |doc, _| { + doc.selection = Some(outline); + Ok(()) + })?; + } return Ok(json!({"dropped": true, "offset": [0, 0]})); } s.edit("Move", |doc, _| { diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index f9a7e85728..eca31d43a3 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -223,10 +223,10 @@ fn moving_shrinks_the_selection_to_the_moved_pixels_at_every_depth() { } } -/// The new selection is the old one where the layer has pixels: a soft selection keeps its soft -/// edge, and half-transparent pixels stay as selected as they were, so they move whole next time. +/// The new selection is what each pixel took with it: the selection, at most the pixel's alpha. A +/// soft selection keeps its soft edge; a half-transparent pixel half selected moves whole. #[test] -fn the_outline_keeps_soft_edges_and_selects_half_transparent_pixels_as_before() { +fn the_outline_is_what_the_moved_pixels_took() { let (mut s, id) = session(); s.edit("soften", |doc, _| { // Half-transparent red over (30..40, 10..30), beside the opaque (10..30)² square. @@ -243,8 +243,8 @@ fn the_outline_keeps_soft_edges_and_selects_half_transparent_pixels_as_before() let doc = &s.active().unwrap().doc; let near = |x: i32, y: i32, want: f32| assert!((selected(doc, x, y) - want).abs() < 0.01, "({x}, {y}): {} for {want}", selected(doc, x, y)); near(25, 20, 1.0); // fully selected, opaque - near(35, 20, 0.5); // half selected, opaque - near(45, 20, 0.5); // half selected, half transparent: as selected as before + near(35, 20, 0.5); // half selected, opaque: half of it moved + near(45, 20, 0.5); // half selected, half transparent: all of it moved near(5, 5, 0.0); // selected, but nothing there assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 10, 50, 30)); } @@ -313,3 +313,68 @@ fn moving_again_from_the_shrunk_selection_leaves_no_edge_behind() { assert!((total(doc) - before).abs() < 0.05, "{depth}-bit: every edge pixel came back whole ({} vs {before})", total(doc)); } } + +/// #7538, measured in Photoshop 2025: after moving a soft-edged shape from a loose marquee, the +/// selection's strength at each edge pixel is that pixel's alpha (its marching ants run along the +/// edge's 50 % line, and Quick Mask shows the edge partly selected), and a feathered selection +/// over opaque pixels still lifts only part of them, leaving the rest behind. +#[test] +fn a_soft_edge_is_as_selected_as_it_is_opaque_and_feathered_moves_leave_the_rest() { + let (mut s, id) = session(); + s.edit("soft edge", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + for (x, a) in [(30, 0.75), (31, 0.5), (32, 0.25)] { + surf.fill_rect(Rect::new(x, 10, x + 1, 30), &[1.0, 0.0, 0.0, a]); + } + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 40, "height": 30})).unwrap(); + s.execute("select.float", json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + for (x, a) in [(39, 1.0), (40, 0.75), (41, 0.5), (42, 0.25), (43, 0.0)] { + assert!((selected(doc, x, 20) - a).abs() < 0.01, "x {x}: selected {} where alpha {a}", selected(doc, x, 20)); + assert!((alpha(doc, id, x, 20) - a).abs() < 0.01, "x {x}: the pixel moved whole"); + } + assert!((10..20).all(|x| alpha(doc, id, x, 20) == 0.0), "nothing left where it was"); + + // A feathered (half) selection over opaque pixels lifts half of them and leaves half. + let (mut s, id) = session(); + s.edit("half", |doc, _| { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(10, 10, 30, 30), &[0.5]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 30, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!((alpha(doc, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(doc, id, 45, 20) - 0.5).abs() < 0.01, "half left, half moved"); +} + +/// #7538: arrow nudges add up to one floating piece, so up then down is back at no offset. The +/// selection is the moved pixels' all the same, as after each of Photoshop's nudges, in one step; +/// a piece that never moved (a click) changes nothing. +#[test] +fn nudging_away_and_back_keeps_the_moved_pixels_selection() { + let (mut s, _) = session(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 40, "height": 40})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 0, "dy": -1})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 1})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(10, 10, 30, 30)), "the moved square's"); + assert_eq!(st.history.past_len(), steps + 1, "one step"); + s.undo(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(2, 2, 42, 42)), "undone"); + + // A click: cut, never moved, dropped. + s.execute("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(2, 2, 42, 42)), "a click keeps the marquee"); + assert_eq!(st.history.past_len(), steps, "and adds no step"); +} From 9e83b41f458f7d9181ac2ab3addc013171f31c3c Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:01:19 -0700 Subject: [PATCH 0742/1452] control-protocol: CPU rendering lasts the session only after a device loss (#6897 review) --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 46fbe47fe3..937330bd45 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,7 +82,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window -- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch. Retry GPU saves GPU mode; after an error (`error: true`) it also builds a new GPU canvas on the same device at once (`gpuInfo.canvas` is `gpu` again, #6577), and after a loss it asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback +- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor (after a loss for the rest of the session; after an error until a successful Retry GPU) and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch. Retry GPU saves GPU mode; after an error (`error: true`) it also builds a new GPU canvas on the same device at once (`gpuInfo.canvas` is `gpu` again, #6577), and after a loss it asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback - `ui.screenshot {path?, focus?}`: capture the main window (PNG). With no path the reply contains base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering From 53c876fe201da0bc56b90253861c8c981ab4efcd Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Sun, 18 Oct 2026 20:02:48 -0400 Subject: [PATCH 0743/1452] document type_size --- crates/raw/src/tiff.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index a5c60ef57f..2f685ccb2e 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -116,6 +116,7 @@ impl Ifd { } } +/// Bytes per value of TIFF field type `typ`, BigTIFF's 8-byte types included, or 0 for an unknown type. pub(crate) fn type_size(typ: u16) -> usize { match typ { 1 | 2 | 6 | 7 => 1, From 308385b0bfa842223987be3039659e3402e1b0bd Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 18:07:04 -0600 Subject: [PATCH 0744/1452] Lasso tools: the outline is solid until the selection is made (#7495) Photoshop draws the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline as a thin solid line while it is being drawn, and marching ants only once the selection is made. Ours used the animated ants from the first point. - `tool_feedback::solid_path`: a 1 pt black line over a soft light edge, Photoshop's look on light pixels and still visible on dark ones. - The freehand Lasso's drag, the Polygonal Lasso's sides and rubber band, and the Magnetic Lasso's traced border use it. Patch and Content-Aware Move drags (the same branch as the Lasso) keep their ants, and so does every committed selection. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 81 ++++++++++++++++++++++++- crates/ui-egui/src/magnetic_lasso_ui.rs | 3 +- crates/ui-egui/src/tool_feedback.rs | 26 ++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..84356d9fcc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4787,8 +4787,9 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, pts.push(h); } } - // Just the outline and its rubber band: the vertices aren't handles to grab. - crate::tool_feedback::draw_ants(painter, &pts, false); + // Just the outline and its rubber band (the vertices aren't handles to grab), solid until + // the selection is made (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); } crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover); crate::blur_pins::draw(app, painter, xf); @@ -5059,7 +5060,13 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View // On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the // canvas sits (a pixel edge would blur it to grey). let pts: Vec = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect(); - crate::tool_feedback::draw_ants(painter, &pts, false); + // The Lasso's outline is solid until the release makes the selection (#7495); a Patch + // or Content-Aware Move area keeps its ants. + if d.tool == Tool::Lasso { + crate::tool_feedback::draw_solid_path(painter, &pts, false); + } else { + crate::tool_feedback::draw_ants(painter, &pts, false); + } } Tool::Gradient => { let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32); @@ -8885,3 +8892,71 @@ mod rotation_preview_isolation_tests { #[cfg(test)] #[path = "canvas_display_memo_tests.rs"] mod display_memo_tests; + +#[cfg(test)] +mod lasso_preview_tests { + use super::*; + + fn xf() -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 } + } + + /// What `draw` paints: (black dashes, black solid paths). + fn painted(app: &mut PhotocraftApp, draw: impl Fn(&mut PhotocraftApp, &egui::Painter)) -> (usize, usize) { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui.painter())); + output.textures_delta.clear(); + let mut flat = Vec::new(); + for clipped in output.shapes { + let mut stack = vec![clipped.shape]; + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(v) => stack.extend(v), + s => flat.push(s), + } + } + } + let dashes = flat.iter().filter(|s| matches!(s, egui::Shape::LineSegment { stroke, .. } if stroke.color == Color32::BLACK)).count(); + let solid = flat.iter().filter(|s| matches!(s, egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::BLACK))).count(); + (dashes, solid) + } + + fn document() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app + } + + /// #7495: as in Photoshop, the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline is a + /// solid line until the selection is made; a Patch area dragged out keeps its marching ants. + #[test] + fn lasso_outlines_are_solid_until_the_selection_is_made() { + let points = vec![[20.0, 20.0, 1.0], [120.0, 30.0, 1.0], [150.0, 140.0, 1.0], [40.0, 160.0, 1.0]]; + for tool in [Tool::Lasso, Tool::Patch] { + let mut app = document(); + app.ui.tool = tool; + app.drag = Some(Drag::new(tool, [20.0, 20.0], points.clone(), egui::Modifiers::NONE, false)); + let (dashes, solid) = painted(&mut app, |app, p| draw_drag_preview(app, p, &xf())); + if tool == Tool::Lasso { + assert_eq!((dashes, solid), (0, 1), "{tool:?}: one solid outline, no ants"); + } else { + assert!(dashes > 10 && solid == 0, "{tool:?}: still marching ants ({dashes} dashes)"); + } + } + + let mut app = document(); + app.ui.tool = Tool::PolygonLasso; + app.ui.polygon = vec![[20.0, 20.0], [120.0, 30.0], [150.0, 140.0]]; + let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), Some(pos2(60.0, 150.0)))); + assert_eq!((dashes, solid), (0, 1), "Polygonal Lasso: its sides and rubber band are one solid line"); + + let mut app = document(); + app.ui.tool = Tool::MagneticLasso; + app.ui.magnetic.path = vec![[20.0, 20.0], [80.0, 25.0], [120.0, 60.0]]; + app.ui.magnetic.anchors = vec![0, 2]; + app.ui.magnetic.live = vec![[120.0, 60.0], [130.0, 100.0]]; + let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), None)); + assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid"); + } +} diff --git a/crates/ui-egui/src/magnetic_lasso_ui.rs b/crates/ui-egui/src/magnetic_lasso_ui.rs index 90655368f3..28ef343212 100644 --- a/crates/ui-egui/src/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/magnetic_lasso_ui.rs @@ -420,7 +420,8 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: } let screen = |q: &[f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let pts: Vec = m.path.iter().chain(m.live.iter().skip(1)).map(screen).collect(); - crate::tool_feedback::draw_ants(painter, &pts, false); + // Solid until the selection is made, as in Photoshop (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); for p in m.anchors.iter().filter_map(|&i| m.path.get(i)).map(screen) { let r = Rect::from_center_size(p, vec2(5.0, 5.0)); painter.rect_filled(r, 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index 2e66ae07c1..58bd14ccea 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -217,6 +217,20 @@ pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) { painter.extend(ants(points, closed, phase)); } +/// A selection outline still being drawn (Lasso, Polygonal Lasso, Magnetic Lasso): a solid line, +/// as Photoshop draws it until the selection is made (#7495). Marching ants are for selections +/// that exist. A thin dark line on a soft light edge: Photoshop's look on light pixels, and still +/// visible on dark ones. +pub fn solid_path(points: &[Pos2], closed: bool) -> Vec { + let make = |s: Stroke| if closed { Shape::closed_line(points.to_vec(), s) } else { Shape::line(points.to_vec(), s) }; + vec![make(Stroke::new(3.0, Color32::from_white_alpha(140))), make(Stroke::new(1.0, Color32::BLACK))] +} + +/// Draw [`solid_path`]. +pub fn draw_solid_path(painter: &egui::Painter, points: &[Pos2], closed: bool) { + painter.extend(solid_path(points, closed)); +} + /// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth). pub fn ellipse_points(r: egui::Rect) -> Vec { let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); @@ -294,6 +308,18 @@ mod tests { assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y)); } + /// #7495: an outline still being drawn is one unbroken line (no dashes, nothing marching), + /// black over a light edge. + #[test] + fn solid_path_is_one_unbroken_dark_line_on_a_light_edge() { + let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; + for closed in [false, true] { + let shapes = solid_path(&pts, closed); + assert!(shapes.iter().all(|s| matches!(s, Shape::Path(p) if p.closed == closed && p.points.len() == 3)), "{shapes:?}"); + assert_eq!(strokes(&shapes), vec![Color32::from_white_alpha(140), Color32::BLACK], "the dark line on top"); + } + } + #[test] fn ants_use_two_contrasting_colours_and_march() { let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; From 7bb7819b0050f485370aa5d6d92f6e0d2eadd1de Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:15:13 -0700 Subject: [PATCH 0745/1452] Lost window device: close only once every unsaved document is saved for recovery, retrying every 2 s; also check the device on the screen-picker frame (#6464 review) --- crates/format/src/autosave.rs | 9 ++- crates/format/tests/autosave.rs | 8 ++- crates/ui-egui/src/gpu_status.rs | 90 ++++++++++++++++++------- crates/ui-egui/src/lib.rs | 11 ++- crates/ui-egui/tests/gpu_device_loss.rs | 67 ++++++++++++++++++ 5 files changed, 155 insertions(+), 30 deletions(-) diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index a2fbddb734..a2739dfc3a 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -299,13 +299,16 @@ impl RecoveryStore { /// Finish every queued autosave and wait for it, then return the outcomes as /// [`take_completed`](Self::take_completed) does. For a session that ends without asking, - /// e.g. because the window's graphics device was lost (#3438). The workers stop, so later - /// autosaves of the same documents report a stopped worker. + /// e.g. because the window's graphics device was lost (#3438). The workers stop; a later + /// autosave of the same document starts a new one on the same entry, so a failed write can be + /// tried again. pub fn flush(&mut self) -> Vec<(u64, u64, std::result::Result<(), String>)> { for saver in self.savers.values_mut() { saver.shutdown(); } - self.take_completed() + let done = self.take_completed(); + self.savers.clear(); + done } /// Document `doc_id` was saved or closed: remove its recovery data, both its own autosaves diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/autosave.rs index fa078dab6c..4f5f1de29c 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/autosave.rs @@ -204,7 +204,13 @@ fn flush_writes_every_queued_autosave_before_returning() { let docs = recovered(&RecoveryStore::new(&dir)); assert_eq!(docs.len(), 2); assert!(docs.values().any(|d| *d == same(a.clone())) && docs.values().any(|d| *d == same(b.clone()))); - assert!(store.autosave_checked(&Arc::new(a), 5, None).is_err(), "the workers stopped"); + // A later autosave (trying a failed write again) starts a new worker on the same entry. + let newer = version(1, 4, ColorMode::Rgb); + store.autosave_checked(&Arc::new(newer.clone()), 5, None).unwrap(); + assert_eq!(store.flush(), vec![(1, 5, Ok(()))]); + let docs = recovered(&RecoveryStore::new(&dir)); + assert_eq!(docs.len(), 2, "the same entry, not a new one"); + assert!(docs.values().any(|d| *d == same(newer.clone())) && !docs.values().any(|d| *d == same(a.clone()))); drop(store); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 008e52a6f1..401704a135 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -43,7 +43,9 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { /// with shapes panics creating its staging buffers (#1215), and one without leaves a frozen window /// whose close button opens an unsaved-changes prompt nobody can see. So this frame and every later /// one draw nothing; the first writes every unsaved document to crash recovery, waits for the -/// writes, and closes the app without asking. The next launch reopens the documents. +/// writes, and closes the app without asking once all of them are saved. The next launch reopens +/// the documents. If a write fails (a full disk, say) the app stays open with the documents in +/// memory and tries again every [`RECOVERY_RETRY_SECS`]: closing would lose them. pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some((device, health)) = app.window_device.as_ref() else { return }; // A loss while the app sat idle or the PC slept is noticed here, before the next frame. @@ -52,36 +54,78 @@ pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } ctx.graphics_mut(|g| *g = Default::default()); - if app.window_lost { - return; + let now = ctx.input(|i| i.time); + if !app.window_lost { + app.window_lost = true; + let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string()); + log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing"); + } else { + match app.window_recovery_retry { + // Saved, and the close is on its way. + None => return, + Some(at) if now < at => { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((at - now).max(0.0))); + return; + } + Some(_) => {} + } + } + match save_for_recovery(app) { + Ok(()) => { + app.window_recovery_retry = None; + app.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(e) => { + log::error!("{e}; keeping the documents open and trying again in {RECOVERY_RETRY_SECS} s"); + app.window_recovery_retry = Some(now + RECOVERY_RETRY_SECS); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECOVERY_RETRY_SECS)); + } } - app.window_lost = true; - let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string()); - log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing"); - save_for_recovery(app); - app.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); } -/// Queue a crash-recovery autosave of every document with unsaved changes, whatever the autosave -/// timer and preference, and wait until the writes finished (see `Services::flush_autosaves`). -fn save_for_recovery(app: &mut PhotocraftApp) { +/// Seconds between attempts to save unsaved work for recovery after the window's device was lost. +pub const RECOVERY_RETRY_SECS: f64 = 2.0; + +/// Write every document with unsaved changes to crash recovery, whatever the autosave timer and +/// preference, and wait until the writes finished (see `Services::flush_autosaves`). `Ok` only when +/// each of them is known to be on disk. +fn save_for_recovery(app: &mut PhotocraftApp) -> Result<(), String> { let jobs: Vec<_> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| (d.doc.clone(), d.revision, d.path.clone())).collect(); - if let Some(save) = app.services.autosave.as_mut() { - for (doc, revision, path) in &jobs { - if let Err(e) = save(doc, *revision, path.as_deref()) { - log::error!("couldn't save {} for recovery: {e}", doc.name); - } + if jobs.is_empty() { + return Ok(()); + } + // (document id, why) for each document not known to be saved. + let mut failed: Vec<(u64, String)> = Vec::new(); + let Some(save) = app.services.autosave.as_mut() else { return Err("no crash-recovery autosave".into()) }; + for (doc, revision, path) in &jobs { + if let Err(e) = save(doc, *revision, path.as_deref()) { + failed.push((doc.id.0, format!("{}: {e}", doc.name))); } } - if let Some(flush) = app.services.flush_autosaves.as_mut() { - for (id, revision, outcome) in flush() { - match outcome { - Ok(()) => log::info!("document {id} (revision {revision}) saved for recovery"), - Err(e) => log::error!("couldn't save document {id} for recovery: {e}"), - } + // Without a flush, only a service whose `autosave` reports the finished write can confirm it. + let outcomes = match app.services.flush_autosaves.as_mut() { + Some(flush) => flush(), + None if app.services.autosave_results.is_none() => Vec::new(), + None => return Err("crash-recovery autosaves can't be waited for".into()), + }; + let confirm = app.services.flush_autosaves.is_some(); + for (doc, revision, _) in &jobs { + if failed.iter().any(|(id, _)| *id == doc.id.0) { + continue; + } + // The writer skips to the newest queued snapshot, so a later revision confirms this one. + let mine: Vec<_> = outcomes.iter().filter(|(id, written, _)| *id == doc.id.0 && written >= revision).collect(); + if let Some((.., Err(e))) = mine.iter().find(|(.., outcome)| outcome.is_err()) { + failed.push((doc.id.0, format!("{}: {e}", doc.name))); + } else if confirm && mine.is_empty() { + failed.push((doc.id.0, format!("{}: the write wasn't confirmed", doc.name))); + } else { + log::info!("{} (revision {revision}) saved for recovery", doc.name); } } + let failed: Vec<_> = failed.into_iter().map(|(_, why)| why).collect(); + if failed.is_empty() { Ok(()) } else { Err(format!("couldn't save for recovery: {}", failed.join("; "))) } } /// Drop the GPU canvas after `fault`: free its resources, send every view through the CPU diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7736de1171..10a078ed43 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -820,8 +820,11 @@ pub struct PhotocraftApp { /// The device the window itself draws with, and its health flag: the GPU canvas's, or one /// watched for the CPU canvas (see `gpu_status::check_window`). window_device: Option<(eframe::wgpu::Device, photocraft_gpu::DeviceHealth)>, - /// That device was lost: frames draw nothing and the app is closing (#3438). + /// That device was lost: frames draw nothing and the app closes once its work is saved (#3438). window_lost: bool, + /// When (in `egui::InputState::time`) to try saving unsaved work for recovery again after a + /// failed attempt; the app closes only once it succeeded. + window_recovery_retry: Option, /// Run once the first frames have rendered (see [`Self::on_started`]). started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). @@ -987,6 +990,7 @@ impl PhotocraftApp { gpu: None, window_device: None, window_lost: false, + window_recovery_retry: None, started: None, perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), @@ -1062,8 +1066,8 @@ impl PhotocraftApp { self.window_device = Some((device.clone(), photocraft_gpu::DeviceHealth::watch(device))); } - /// Whether the window's graphics device was lost, so the app saved its documents for - /// recovery and is closing (#3438). + /// Whether the window's graphics device was lost, so the app saves its documents for + /// recovery and closes once they are saved (#3438). pub fn window_lost(&self) -> bool { self.window_lost } @@ -2069,6 +2073,7 @@ impl eframe::App for PhotocraftApp { } }); self.automation_input = false; + gpu_status::check_window(self, &ctx); return; } if screen_picker::busy(&ctx) { diff --git a/crates/ui-egui/tests/gpu_device_loss.rs b/crates/ui-egui/tests/gpu_device_loss.rs index 3a84b96cbb..37dd3db267 100644 --- a/crates/ui-egui/tests/gpu_device_loss.rs +++ b/crates/ui-egui/tests/gpu_device_loss.rs @@ -293,3 +293,70 @@ fn a_lost_window_device_saves_unsaved_work_for_recovery_and_closes() { assert_eq!(upload(&h), (0, true), "a frame with shapes panics inside egui-wgpu on a lost device"); assert_eq!(h.state().session.documents().len(), 1, "the document stays until the app closes"); } + +/// #6464 review: when the recovery save fails (a full disk, say), closing would lose the work, so +/// the app stays open with the document and tries again until the write succeeds, then closes. +#[test] +fn a_lost_window_device_closes_only_once_unsaved_work_is_saved() { + let _gpu = gpu_lock(); + let disk_full = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (full, tries) = (disk_full.clone(), attempts.clone()); + let pending = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u64, u64)>::new())); + let queued = pending.clone(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_max_steps(16).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = photocraft_ui_egui::Services { + autosave: Some(Box::new(move |doc: &std::sync::Arc, revision: u64, _: Option<&str>| { + queued.lock().unwrap().push((doc.id.0, revision)); + Ok(()) + })), + autosave_results: Some(Box::new(Vec::new)), + flush_autosaves: Some(Box::new(move || { + tries.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let outcome = if full.load(std::sync::atomic::Ordering::SeqCst) { Err("disk full".to_string()) } else { Ok(()) }; + pending.lock().unwrap().drain(..).map(|(id, revision)| (id, revision, outcome.clone())).collect() + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }) + })); + let Ok(mut h) = built else { + eprintln!("skipping: no GPU adapter"); + return; + }; + h.run_steps(4); + assert!(h.state_mut().destroy_window_device()); + for _ in 0..120 { + h.run_steps(1); + if h.state().window_lost() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + assert!(h.state().window_lost(), "the loss was noticed"); + let tries = || attempts.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(tries(), 1); + assert!(h.state().has_unsaved_work(), "a failed recovery save doesn't let the app close"); + // Each step is 0.25 s: no new attempt before the retry interval. + h.run_steps(4); + assert_eq!(tries(), 1, "waits before trying again"); + h.run_steps(5); + assert_eq!(tries(), 2, "tries again"); + assert!(h.state().has_unsaved_work()); + disk_full.store(false, std::sync::atomic::Ordering::SeqCst); + h.run_steps(9); + assert_eq!(tries(), 3); + assert!(!h.state().has_unsaved_work(), "closes once the work is saved"); + h.run_steps(9); + assert_eq!(tries(), 3, "saved once, not again"); + assert_eq!(h.state().session.documents().len(), 1, "the document stays until the app closes"); +} From e0d3d940c622f03e72b7d9338777b8311747372a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:36:47 +0700 Subject: [PATCH 0746/1452] fix(compose): resolve Dissolve coverage across adjustment, effects, and clipping paths (#3050-#3052) --- crates/compose/src/effects.rs | 8 +++++- crates/compose/src/lib.rs | 18 ++++++++++++ crates/compose/src/tests.rs | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..c50449d817 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2172,7 +2172,13 @@ pub(crate) fn composite_with_effects_prepared( let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; let a = before.px[i]; let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a }; - backdrop.px[i] = mix_premul(a, b, op); + backdrop.px[i] = if layer.blend == BlendMode::Dissolve { + // Dissolve applies layer opacity to the completed interior/exterior effect + // stack as pixel coverage, not continuous gray interpolation (#3051). + if crate::dissolve_noise(x, y) < op { b } else { a } + } else { + mix_premul(a, b, op) + }; } } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..38c7dcfccc 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1440,6 +1440,14 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Only `adjusted` has changed; this pixel's original backdrop is still in place. let b = backdrop.px[i]; let a = adjusted.px[i]; + if layer.blend == BlendMode::Dissolve { + // An adjustment has no standalone source alpha. Its effective coverage + // comes from its own opacity and mask, not a continuous RGB mix (#3050). + if dissolve_noise(x, y) < k { + backdrop.px[i] = [a[0], a[1], a[2], b[3]]; + } + continue; + } let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]]; } @@ -1839,6 +1847,16 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { continue; } let s = content.px[i]; + if layer.blend == BlendMode::Dissolve { + // Clipping preserves the opaque base's alpha. Dissolve still selects source + // pixels by spatial coverage instead of continuously mixing the colours (#3052). + let x = rect.x0 + (i as i32 % rect.width() as i32); + let y = rect.y0 + (i as i32 / rect.width() as i32); + if dissolve_noise(x, y) < s[3] * opacity { + *p = [s[0], s[1], s[2], alpha]; + } + continue; + } // Blend as if the base were opaque, then keep the base's alpha. let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma); *p = [r[0], r[1], r[2], alpha]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..9ec37ff884 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3214,3 +3214,57 @@ fn concurrent_effect_identity_misses_compute_once() { }); assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); } + +#[test] +fn dissolve_adjustment_resolves_opacity_to_binary_coverage() { + let mut d = doc_white(16, 16); + let mut layer = Layer::new("Invert", LayerContent::Adjustment(Adjustment::Invert)); + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.5; + d.layers.push(layer); + let pixels = render(&d, d.bounds()).px; + let black = pixels.iter().filter(|p| p[0] < 0.01).count(); + let white = pixels.iter().filter(|p| p[0] > 0.99).count(); + assert!(black > 0 && white > 0, "Dissolve must select both original and adjusted pixels"); + assert_eq!(black + white, pixels.len(), "no intermediate gray pixels"); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated"); +} + +#[test] +fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() { + let mut d = doc_white(16, 16); + let mut fill = Layer::new("Black", LayerContent::Fill(Fill::Solid(Color::BLACK))); + fill.blend = BlendMode::Dissolve; + fill.opacity = 0.5; + d.layers.push(fill); + let control = render(&d, d.bounds()).px; + d.layers.last_mut().unwrap().clipped = true; + let clipped = render(&d, d.bounds()).px; + assert_eq!(clipped, control, "clipping over a fully opaque base must preserve dissolve pixels"); + let black = clipped.iter().filter(|p| p[0] < 0.01).count(); + assert!(black > 0 && black < clipped.len()); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); +} + +#[test] +fn dissolve_color_overlay_preserves_binary_layer_coverage() { + let mut d = doc_white(16, 16); + let mut layer = Layer::new("Black", LayerContent::Fill(Fill::Solid(Color::BLACK))); + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.5; + d.layers.push(layer); + let control = render(&d, d.bounds()).px; + d.layers.last_mut().unwrap().effects.items.push(photocraft_doc::Effect::ColorOverlay { + common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), + color: Color::BLACK, + }); + let styled = render(&d, d.bounds()).px; + assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray"); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); +} From 61724b49f0fa32385e3ce1aaf3d1ccb7d0692fbe Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:21:50 +0700 Subject: [PATCH 0747/1452] fix(gpu): match CPU Dissolve coverage for adjustment, clipping and effects --- crates/gpu/src/compose.wgsl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..8aeea51d48 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -833,6 +833,9 @@ fn fs_atop(in: VOut) -> @location(0) vec4 { let base = textureLoad(tex_a, p, 0); if (base.a <= 0.0) { return base; } let s = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(base, vec4(s.rgb, base.a), dissolve_noise(doc_px(p)) < s.a * op.opacity); + } let r = composite_g(op.mode, vec4(base.rgb, 1.0), s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); return vec4(r.rgb, base.a); } @@ -855,6 +858,9 @@ fn fs_adjmix(in: VOut) -> @location(0) vec4 { let k = op.opacity * mask_value(doc_px(p)); if (k <= 0.0) { return b; } let a = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(b, vec4(a.rgb, b.a), dissolve_noise(doc_px(p)) < k); + } let bl = blend_rgb(op.mode, b.rgb, a.rgb); return vec4(b.rgb + (bl - b.rgb) * k, b.a); } @@ -1174,7 +1180,9 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let atop = (op.flags & F_ATOP) != 0u; var before = c; if (atop) { before = vec4(c.rgb, 1.0); } - let o = mix_premul(before, w, op.opacity); + let o = select(mix_premul(before, w, op.opacity), + select(before, w, dissolve_noise(doc_px(p)) < op.opacity), + op.mode == M_DISSOLVE); if (atop) { if (c.a > 0.0) { return vec4(o.rgb, c.a); } return c; From 28807ed7ab13775487e60b816ed51d19a6aa9d2a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:21:57 +0700 Subject: [PATCH 0748/1452] test(gpu): cover Dissolve adjustment, clipping and effect parity --- crates/gpu/tests/parity.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..3f69affa1c 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2618,3 +2618,35 @@ fn pass_through_clipping_base_with_an_adjustment() { d.layers.push(clip); check(&mut g, &d, "pass-through clipping base with an adjustment"); } + + +/// Dissolve must use document-coordinate binary coverage on both renderers. +#[test] +fn dissolve_specialized_paths_match_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.5; + d.layers.push(adjustment); + check(&mut g, &d, "dissolve adjustment"); + + let mut d = base_doc(64, 48); + let mut clipped = Layer::new("clipped", LayerContent::Fill(Fill::Solid(Color::BLACK))); + clipped.clipped = true; + clipped.blend = BlendMode::Dissolve; + clipped.opacity = 0.5; + d.layers.push(clipped); + check(&mut g, &d, "dissolve clipped"); + + let mut d = base_doc(64, 48); + let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK))); + styled.blend = BlendMode::Dissolve; + styled.opacity = 0.5; + styled.effects.items.push(Effect::ColorOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + color: Color::BLACK, + }); + d.layers.push(styled); + check(&mut g, &d, "dissolve effects"); +} From 21871dd40e75049953bef26d78a2cbb4902e5c9d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:35:26 +0700 Subject: [PATCH 0749/1452] refactor(gpu): branch explicitly on Dissolve effect-stack coverage --- crates/gpu/src/compose.wgsl | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 8aeea51d48..439cbafe38 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1180,9 +1180,14 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let atop = (op.flags & F_ATOP) != 0u; var before = c; if (atop) { before = vec4(c.rgb, 1.0); } - let o = select(mix_premul(before, w, op.opacity), - select(before, w, dissolve_noise(doc_px(p)) < op.opacity), - op.mode == M_DISSOLVE); + var o: vec4; + if (op.mode == M_DISSOLVE) { + // Match the CPU effects compositor: opacity gates the completed stack, not + // its individual colours or alpha. Use document coordinates across chunks. + o = select(before, w, dissolve_noise(doc_px(p)) < op.opacity); + } else { + o = mix_premul(before, w, op.opacity); + } if (atop) { if (c.a > 0.0) { return vec4(o.rgb, c.a); } return c; From 7e1c21e9a86681d34174f224662f76a818a0057f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:36:02 +0700 Subject: [PATCH 0750/1452] test(gpu): split Dissolve parity cases and exercise adjustment masking --- crates/gpu/tests/parity.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 3f69affa1c..ac2c04ee14 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2620,17 +2620,23 @@ fn pass_through_clipping_base_with_an_adjustment() { } -/// Dissolve must use document-coordinate binary coverage on both renderers. +/// An adjustment has no standalone source alpha: its opacity and mask gate binary coverage. #[test] -fn dissolve_specialized_paths_match_cpu() { +fn dissolve_adjustment_matches_cpu() { let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); adjustment.blend = BlendMode::Dissolve; adjustment.opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 32, 48), 31, 0.0)); d.layers.push(adjustment); - check(&mut g, &d, "dissolve adjustment"); + check(&mut g, &d, "dissolve adjustment + mask"); +} +/// Clipped pixels still use document-positioned noise, without changing the base alpha. +#[test] +fn dissolve_clipped_layer_matches_cpu() { + let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let mut clipped = Layer::new("clipped", LayerContent::Fill(Fill::Solid(Color::BLACK))); clipped.clipped = true; @@ -2638,7 +2644,12 @@ fn dissolve_specialized_paths_match_cpu() { clipped.opacity = 0.5; d.layers.push(clipped); check(&mut g, &d, "dissolve clipped"); +} +/// Layer opacity gates the completed effects stack, instead of fading its pixels. +#[test] +fn dissolve_effects_stack_matches_cpu() { + let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK))); styled.blend = BlendMode::Dissolve; From 157711d1be64f2ff9f885199fd2c216afb9a53e8 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:38:27 +0700 Subject: [PATCH 0751/1452] style(compose): apply rustfmt layout from CI for Dissolve regression --- crates/compose/src/tests.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9ec37ff884..3d550201fa 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3258,10 +3258,12 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() { layer.opacity = 0.5; d.layers.push(layer); let control = render(&d, d.bounds()).px; - d.layers.last_mut().unwrap().effects.items.push(photocraft_doc::Effect::ColorOverlay { - common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), - color: Color::BLACK, - }); + d.layers + .last_mut() + .unwrap() + .effects + .items + .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); let styled = render(&d, d.bounds()).px; assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray"); d.layers.last_mut().unwrap().blend = BlendMode::Normal; From d127cd6afb285da8816bdd439f5d4f337029fada Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:38:36 +0700 Subject: [PATCH 0752/1452] style(gpu): match rustfmt compact ColorOverlay initialization --- crates/gpu/tests/parity.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index ac2c04ee14..474a6e8e89 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2654,10 +2654,7 @@ fn dissolve_effects_stack_matches_cpu() { let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK))); styled.blend = BlendMode::Dissolve; styled.opacity = 0.5; - styled.effects.items.push(Effect::ColorOverlay { - common: FxCommon::new(BlendMode::Normal, 1.0), - color: Color::BLACK, - }); + styled.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); d.layers.push(styled); check(&mut g, &d, "dissolve effects"); } From 9050e86dac506fa7561a583658d32ee1ac67afb2 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:54:00 +0700 Subject: [PATCH 0753/1452] test(compose): preserve clipped Dissolve coverage across fill, boundaries, and regions --- crates/compose/src/tests.rs | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3d550201fa..d02ddb7000 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -316,6 +316,65 @@ fn clipping_mask_restricts_to_base_alpha() { assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden"); } +/// #3052 / #4921: clipped Dissolve must apply layer Opacity × Fill as binary coverage, +/// restricted to the clipping base's shape. +#[test] +fn clipped_dissolve_opacity_fill_is_binary_and_preserves_base_shape() { + let mut d = doc_white(40, 12); + d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0])); + let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = 0.6; + clip.fill_opacity = 0.5; + d.layers.push(clip); + let mut red = 0; + let mut blue = 0; + for y in 0..12 { + for x in 0..20 { + let p = px(&d, x, y); + if close4(p, [1.0, 0.0, 0.0, 1.0]) { + red += 1; + } else { + assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})"); + blue += 1; + } + } + assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible"); + } + assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}"); + assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}"); +} + +/// Dissolve noise uses document coordinates, not the region origin; checked across +/// negative coordinates, tile boundaries, and all supported RGB sample depths. +#[test] +fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("coverage", Size::new(32, 18), ColorMode::Rgb, fmt.sample); + let bounds = Rect::new(-3, -2, 32, 18); + let mut base = Layer::raster("base", fmt); + base.surface_mut().unwrap().fill_rect(bounds, &[0.0, 0.0, 1.0, 0.5]); + d.layers.push(base); + let mut clip = Layer::raster("clip", fmt); + clip.surface_mut().unwrap().fill_rect(bounds, &[1.0, 0.0, 0.0, 0.75]); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = 0.6; + clip.fill_opacity = 0.5; + d.layers.push(clip); + let whole = render(&d, bounds); + for region in [Rect::new(-3, -2, 5, 4), Rect::new(5, 4, 17, 11), Rect::new(17, 11, 32, 18)] { + let part = render(&d, region); + for y in region.y0..region.y1 { + for x in region.x0..region.x1 { + assert!(close4(part.get(x, y), whole.get(x, y)), "{fmt:?}: region mismatch at ({x},{y})"); + } + } + } + } +} + #[test] fn hidden_base_hides_clipping_group() { let mut d = doc_white(4, 4); From 2ef53bb673b728eb745158a346738e37e1e26f4f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:54:04 +0700 Subject: [PATCH 0754/1452] test(gpu): cover clipped Dissolve opacity and fill parity --- crates/gpu/tests/parity.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 474a6e8e89..b40d3786a3 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -735,6 +735,19 @@ fn groups_masks_and_clipping() { d.layers.extend([base, c1, c2]); check(&mut g, &d, &format!("clipping hidden={hidden}")); } + // #3052/#4921: a clipped Dissolve layer retains opacity/fill binary coverage + // across the partially covered clipping base; the GPU must match the CPU. + for (opacity, fill) in [(0.4, 1.0), (0.8, 0.5)] { + let mut d = base_doc(56, 44); + let base = noise_layer("base", PixelFormat::RGBA8, Rect::new(5, 6, 48, 40), 45, 0.0); + let mut clip = noise_layer("dissolve clip", PixelFormat::RGBA8, d.bounds(), 46, 0.0); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = opacity; + clip.fill_opacity = fill; + d.layers.extend([base, clip]); + check(&mut g, &d, "clipped Dissolve opacity/fill CPU/GPU parity"); + } // Clipped group base (isolated) with a clipped layer. let mut d = base_doc(56, 44); let mut gb = Layer::group("gb", vec![child(18, BlendMode::Normal)]); From a221622ad4742e096f1f92d609acd2d7ee00bb7c Mon Sep 17 00:00:00 2001 From: Ni-zav Date: Mon, 19 Oct 2026 07:18:53 +0700 Subject: [PATCH 0755/1452] fix(compose): cover clipped Dissolve adjustments Port the clipped-adjustment coverage path from #4711 and verify depth, alpha, masks, and document-coordinate GPU parity. Co-authored-by: Vikram Bhaskaran --- crates/compose/src/lib.rs | 8 +++++++ crates/compose/src/tests.rs | 46 +++++++++++++++++++++++++++++++++++++ crates/gpu/src/compose.wgsl | 1 + crates/gpu/tests/parity.rs | 19 ++++++++++++++- 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 38c7dcfccc..cebc6a64a1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1793,6 +1793,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { for (i, p) in base.px.iter_mut().enumerate() { let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i); let a = adjusted.px[i]; + if layer.blend == BlendMode::Dissolve { + let x = rect.x0 + (i % rect.width() as usize) as i32; + let y = rect.y0 + (i / rect.width() as usize) as i32; + if dissolve_noise(x, y) < k { + *p = [a[0], a[1], a[2], p[3]]; + } + continue; + } let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]); for c in 0..3 { p[c] += (bl[c] - p[c]) * k; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d02ddb7000..1d967e6fc6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3329,3 +3329,49 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() { let continuous = render(&d, d.bounds()).px; assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); } + +#[test] +fn clipped_dissolve_adjustment_preserves_alpha_and_document_coverage() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("clipped adjustment", Size::new(272, 12), ColorMode::Rgb, fmt.sample); + let bounds = Rect::new(-3, -2, 272, 12); + let mut base = Layer::raster("base", fmt); + base.surface_mut().unwrap().fill_rect(bounds, &[1.0, 1.0, 1.0, 0.5]); + d.layers.push(base); + let original = render(&d, bounds); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.clipped = true; + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.6; + adjustment.fill_opacity = 0.5; + let mut mask = photocraft_raster::Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-3, -2, 260, 12), &[1.0]); + adjustment.mask = Some(LayerMask { surface: mask, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(adjustment); + let whole = render(&d, bounds); + let mut changed = 0; + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + let p = whole.get(x, y); + let value = if x < 260 && dissolve_noise(x, y) < 0.3 { + changed += 1; + 0.0 + } else { + 1.0 + }; + assert!(close4(p, [value, value, value, original.get(x, y)[3]]), "{fmt:?} at ({x}, {y}): {p:?}"); + } + } + assert!(changed > 0 && changed < whole.px.len()); + let region = Rect::new(250, 1, 270, 10); + let part = render(&d, region); + for y in region.y0..region.y1 { + for x in region.x0..region.x1 { + assert!(close4(part.get(x, y), whole.get(x, y)), "{fmt:?} region at ({x}, {y})"); + } + } + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let normal = render(&d, bounds); + assert!(normal.px.iter().any(|p| p[0] > 0.01 && p[0] < 0.99), "Normal keeps continuous mixing"); + } +} diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 439cbafe38..c495618539 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -851,6 +851,7 @@ fn fs_adjust(in: VOut) -> @location(0) vec4 { } // Mix an adjusted result B back over the original A with blend mode, opacity and mask. +// Both ordinary and clipped adjustments use this pass, retaining the original alpha. @fragment fn fs_adjmix(in: VOut) -> @location(0) vec4 { let p = local(in.pos); diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index b40d3786a3..3f59aa710f 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2632,7 +2632,6 @@ fn pass_through_clipping_base_with_an_adjustment() { check(&mut g, &d, "pass-through clipping base with an adjustment"); } - /// An adjustment has no standalone source alpha: its opacity and mask gate binary coverage. #[test] fn dissolve_adjustment_matches_cpu() { @@ -2671,3 +2670,21 @@ fn dissolve_effects_stack_matches_cpu() { d.layers.push(styled); check(&mut g, &d, "dissolve effects"); } + +#[test] +fn dissolve_clipped_adjustment_matches_cpu() { + let Some(mut g) = gpu() else { return }; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("clipped adjustment", Size::new(272, 24), ColorMode::Rgb, fmt.sample); + let base = noise_layer("base", fmt, Rect::new(-3, -2, 268, 22), 47, 0.0); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.clipped = true; + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.6; + adjustment.fill_opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 260, 24), 48, 0.0)); + d.layers.extend([base, adjustment]); + check(&mut g, &d, &format!("clipped Dissolve adjustment {fmt:?}")); + diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}")); + } +} From ce6f1eef0d46c9edd1d976b3246d0eb0b1b47fd1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:21:11 +0200 Subject: [PATCH 0756/1452] Fix merge with main: load the contour library in load_presets_json; regenerate scorecard Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/presets/mod.rs | 1 + docs/scorecard.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index fe8d2cbeb9..689b677aa4 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -197,6 +197,7 @@ impl Session { custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), + contours: take(&mut m, "contours"), }; let st = &mut self.presets; if let Some(g) = p.gradients { diff --git a/docs/scorecard.md b/docs/scorecard.md index b320028936..0dd5d2f132 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -114,7 +114,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | -| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | +| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | From a9e7b48bf42864ad352c6416ea159ab76f9d4c77 Mon Sep 17 00:00:00 2001 From: Cl4VD3 <344569071+Cl4VD3@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:12:57 +0200 Subject: [PATCH 0757/1452] =?UTF-8?q?Previous=20/=20Next=20/=20First=20/?= =?UTF-8?q?=20Last=20Brush=20commands=20(,=20.=20=E2=87=A7,=20=E2=87=A7.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop lists Previous Brush, Next Brush, First Brush and Last Brush in Edit › Keyboard Shortcuts › Tools (`,` `.` `⇧,` `⇧.`); PhotoCraft had no way to step through brush presets from the keyboard. `tools.previousBrush`, `tools.nextBrush`, `tools.firstBrush` and `tools.lastBrush` join the brush key commands in `brush_key_cmds.rs`, so they can be rebound, recorded and driven by agents like `[` / `]`. Each picks a preset with `tools.setBrush {preset}` (journaled, as a click in the Brushes panel), walking the presets in the panel's order (groups by first appearance, then each group's folder tree depth first, as `brushes_tab` draws them) and wrapping around; with no preset picked yet, Next starts at the first and Previous at the last. No presets is an error, not a panic. Tests: panel order with groups and nested folders; stepping, wrapping, first/last, the default shortcuts, and the empty-library error. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_key_cmds.rs | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index 56663b400a..acb3c4f368 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -3,6 +3,9 @@ //! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound, //! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush. //! +//! `,` / `.` pick the previous or next brush preset and ⇧, / ⇧. the first or last, in the order +//! the Brushes panel lists them (Photoshop's "Previous Brush"… in Keyboard Shortcuts › Tools). +//! //! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean //! other things with other tools, so the shell gives them to it only for the painting tools //! (`move_mods::arrow_keys`). @@ -34,6 +37,71 @@ fn size(s: &mut Session, up: bool) -> Result { crate::brush_cmds::set_brush(s, &json!({ "brush": { "size": size } })) } +/// Brush preset indices in the Brushes panel's order: groups in order of first appearance, and in +/// each group its folders where their first preset is, depth first (as `brushes_tab` draws them). +fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { + fn path(p: &photocraft_paint::BrushPreset) -> &[String] { + p.folder.get(..p.folder.len().min(crate::brush_preset_cmds::MAX_FOLDER_DEPTH)).unwrap_or_default() + } + fn flatten(presets: &[photocraft_paint::BrushPreset], items: &[usize], depth: usize, out: &mut Vec) { + let mut seen: Vec<&str> = Vec::new(); + for &i in items { + let Some(p) = presets.get(i) else { continue }; + match path(p).get(depth) { + None => out.push(i), + Some(name) if !seen.contains(&name.as_str()) => { + seen.push(name); + let inside: Vec = items.iter().copied().filter(|&j| presets.get(j).is_some_and(|q| path(q).get(depth) == Some(name))).collect(); + flatten(presets, &inside, depth + 1, out); + } + Some(_) => {} + } + } + } + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for (i, p) in presets.iter().enumerate() { + match groups.iter_mut().find(|(g, _)| *g == p.group) { + Some((_, v)) => v.push(i), + None => groups.push((&p.group, vec![i])), + } + } + let mut out = Vec::with_capacity(presets.len()); + for (_, items) in &groups { + flatten(presets, items, 0, &mut out); + } + out +} + +/// Which way [`step_preset`] moves through the presets. +#[derive(Clone, Copy)] +enum Step { + Previous, + Next, + First, + Last, +} + +/// `,` / `.` / ⇧, / ⇧.: pick the previous, next, first or last brush preset (wrapping around), as +/// a click in the Brushes panel would (`tools.setBrush` with the preset). With no preset picked +/// yet, Next starts at the first and Previous at the last. +fn step_preset(s: &mut Session, step: Step) -> Result { + let order = panel_order(&s.tools.presets); + let n = order.len(); + if n == 0 { + return Err(EngineError::Other("there are no brush presets".into())); + } + let at = s.tools.current_preset.as_deref().and_then(|c| order.iter().position(|&i| s.tools.presets.get(i).is_some_and(|p| p.name.eq_ignore_ascii_case(c)))); + let k = match (step, at) { + (Step::First, _) | (Step::Next, None) => 0, + (Step::Last, _) | (Step::Previous, None) => n - 1, + (Step::Next, Some(k)) => (k + 1) % n, + (Step::Previous, Some(k)) => (k + n - 1) % n, + }; + let name = order.get(k).and_then(|&i| s.tools.presets.get(i)).map(|p| p.name.clone()).ok_or_else(|| EngineError::Other("no such brush preset".into()))?; + crate::brush_cmds::set_brush(s, &json!({ "preset": name }))?; + Ok(json!({ "preset": name })) +} + fn hardness(s: &mut Session, up: bool) -> Result { let hardness = hardness_step(s.tools.brush.hardness, up); crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } })) @@ -68,6 +136,10 @@ pub fn specs() -> Vec { key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)), key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)), key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)), + key_cmd!("tools.previousBrush", "Previous Brush", ",", |s, _| step_preset(s, Step::Previous)), + key_cmd!("tools.nextBrush", "Next Brush", ".", |s, _| step_preset(s, Step::Next)), + key_cmd!("tools.firstBrush", "First Brush", "Shift+,", |s, _| step_preset(s, Step::First)), + key_cmd!("tools.lastBrush", "Last Brush", "Shift+.", |s, _| step_preset(s, Step::Last)), CommandSpec { id: "tools.rotateBrush", label: "Rotate Brush Tip", @@ -120,6 +192,60 @@ mod tests { assert_eq!(s.tools.brush.angle, -15.0); } + fn preset(name: &str, group: &str, folder: &[&str], size: f32) -> photocraft_paint::BrushPreset { + photocraft_paint::BrushPreset { + name: name.into(), + brush: photocraft_paint::BrushSettings { size, ..Default::default() }, + builtin: false, + group: group.into(), + folder: folder.iter().map(|f| f.to_string()).collect(), + captures: Default::default(), + } + } + + #[test] + fn panel_order_groups_then_folders_depth_first() { + let presets = vec![ + preset("a", "G1", &[], 1.0), + preset("b", "G2", &[], 2.0), + preset("c", "G1", &["F"], 3.0), + preset("d", "G1", &[], 4.0), + preset("e", "G1", &["F"], 5.0), + preset("f", "G1", &["F", "Sub"], 6.0), + preset("g", "G1", &["F"], 7.0), + ]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + // G1: a, folder F (c, e, then Sub's f where its first preset is, g), d; then G2: b. + assert_eq!(names, ["a", "c", "e", "f", "g", "d", "b"]); + } + + #[test] + fn previous_and_next_brush_walk_the_presets_and_wrap() { + let mut s = Session::new(); + s.tools.presets = vec![preset("One", "", &[], 11.0), preset("Two", "", &[], 22.0), preset("Three", "", &[], 33.0)]; + s.tools.current_preset = None; + let picked = |s: &Session| (s.tools.current_preset.clone().unwrap_or_default(), s.tools.brush.size); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("One".into(), 11.0), "Next with no preset picked starts at the first"); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("Two".into(), 22.0)); + s.execute("tools.lastBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("Three".into(), 33.0)); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "One", "wraps past the last"); + s.execute("tools.previousBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "Three", "wraps before the first"); + s.execute("tools.firstBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "One"); + let sc = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!( + [sc("tools.previousBrush"), sc("tools.nextBrush"), sc("tools.firstBrush"), sc("tools.lastBrush")], + [Some(","), Some("."), Some("Shift+,"), Some("Shift+.")] + ); + s.tools.presets.clear(); + assert!(s.execute("tools.nextBrush", Value::Null).is_err()); + } + #[test] fn commands_step_the_session_brush_and_journal() { let mut s = Session::new(); From a94c4e19e673db6abb1d2449ecc25ca88eda5aba Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Sun, 18 Oct 2026 22:41:46 -0300 Subject: [PATCH 0758/1452] fix: prioritize both cameras during zoom redraws --- crates/automation/src/server.rs | 2 +- crates/ui-egui/src/canvas.rs | 44 +++++++++++++++++++++--- crates/ui-egui/src/profile_open_tests.rs | 1 + 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 97e622dbde..f52376a9c5 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -308,7 +308,7 @@ pub struct MenuParams { pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, - /// vectorMaskTarget, selectionMode, layerFilter (by kind, name or blend mode), zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, + /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8474d05943..f8034eb195 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2052,6 +2052,12 @@ fn visible_doc_rect(xf: &ViewXform) -> DRect { DRect::new(c(x0.floor()) - 2, c(y0.floor()) - 2, c(x1.ceil()) + 2, c(y1.ceil()) + 2) } +/// Prioritize deferred redraw blocks visible at either the target or the animated display camera. +/// The displayed camera may still show old texture contents outside the target's visible area. +fn redraw_focus(target: &ViewXform, displayed: &ViewXform) -> DRect { + visible_doc_rect(target).union(&visible_doc_rect(displayed)) +} + /// Zoomed-out Image › Adjustments preview on a large document: the wgpu compositor renders the /// reduced preview document (`adjust_preview::gpu_proxy`) into its own texture, over the target's /// area after the first frame. Returns (factor, gpu key, dimensions) to draw. @@ -3134,7 +3140,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Document rendering follows the target; only shader uniforms and overlay geometry ease. let target_xf = ViewXform { rect, zoom: view.zoom / ppp, center: view.center, flip, rotation: view.rotation, aspect }; let render_zoom = view.zoom; - let render_visible = visible_doc_rect(&target_xf); let mut display_view = animated_view(&mut view, target_xf, ppp, app.session.prefs().general.animated_zoom, ctx.input(|i| i.time)); if primary && let Some(stored) = app.ui.views.get_mut(idx) { stored.zoom_animating = view.zoom_animating; @@ -3213,7 +3218,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: offset: [0.0, 0.0], }; paint_gpu_view(&painter, rect, params, &pattern); - } else if gpu_ok && ensure_gpu(app, &ctx, idx, render_visible) { + } else if gpu_ok && ensure_gpu(app, &ctx, idx, redraw_focus(&target_xf, &xf)) { on_gpu = true; app.perf.gpu = true; // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). @@ -4343,9 +4348,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view } -/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under -/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the -/// centre of the view instead (#204). /// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image, /// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray /// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps @@ -4360,6 +4362,8 @@ fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 { if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) } } +/// Zoom to `new_zoom` about `p`: keep its document point under the pointer, or move it to the +/// view centre when Zoom Clicked Point to Center (`center_on_point`) is enabled. fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) { let before = xf.to_doc(p); view.zoom = new_zoom; @@ -8952,14 +8956,44 @@ mod rotation_preview_isolation_tests { mod zoom_animation_tests { use super::*; + /// Screen geometry shared by the zoom-transition regressions. fn camera(view: &View) -> ViewXform { ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)), zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 } } + /// A settled camera whose transition state starts empty. fn view() -> View { View { zoom: 1.0, center: [200.0, 150.0], doc_size: [400, 300], fit_pending: false, ..Default::default() } } + /// Deferred uploads prioritize both visible areas throughout a recentering zoom. + #[test] + fn deferred_redraw_focus_covers_target_and_displayed_cameras() { + for rotation in [0.0, 37.0] { + let mut v = view(); + let mut initial = camera(&v); + initial.rotation = rotation; + animated_view(&mut v, initial, 1.0, true, 0.0); + v.zoom = 2.0; + v.center = [1600.0, 900.0]; + v.request_zoom_animation(true); + let target = ViewXform { zoom: v.zoom, center: v.center, ..initial }; + let target_visible = visible_doc_rect(&target); + for time in [0.0, 0.11, 0.22] { + let shown = animated_view(&mut v, target, 1.0, true, time); + let displayed = ViewXform { zoom: shown.zoom, center: shown.center, ..target }; + let displayed_visible = visible_doc_rect(&displayed); + let focus = redraw_focus(&target, &displayed); + assert_eq!(focus.intersect(&target_visible), target_visible); + assert_eq!(focus.intersect(&displayed_visible), displayed_visible); + if time == 0.0 { + assert_ne!(focus, target_visible, "the old view must also receive priority"); + } + } + assert_eq!(redraw_focus(&target, &target), target_visible, "settled views keep the same focus"); + } + } + #[test] fn disabling_animated_zoom_snaps_a_secondary_canvas_on_its_next_frame() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index b071048dea..83523da0d1 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -13,6 +13,7 @@ use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; use serde_json::Value; +/// The embedded-profile choice uses the current built-in profile's displayed name. fn use_embedded() -> String { format!("Use the embedded profile ({})", photocraft_cms::Builtin::AdobeRgbCompat.description()) } From 64e7ed1583a4f616bc18aa6574dcabe5982d97e3 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 19 Oct 2026 10:43:28 +0900 Subject: [PATCH 0759/1452] Studio: the floating Properties card has the Adjustments tab (#7265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Adjustments already selects Properties group tab 1, but Studio themes float Properties as a card that only drew properties_body, so the menu item (and the rail) opened the document's Properties and the Adjustments grid could not be reached. The card's title is now the group's pill tab strip, Properties | Adjustments, as on the docked group: it follows and sets dock_tabs.properties, honours hidden tabs, and its context menu closes a tab or the card. The tab close logic moves out of the dock into dock::close_tab so the card shares it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/dock.rs | 34 +++++++++++++---------- crates/ui-egui/src/dock_tests.rs | 47 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 45 ++++++++++++++++++++++-------- 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0412c4fc67..517462bbe2 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -417,6 +417,24 @@ pub fn rail_click(app: &mut PhotocraftApp, g: Group, docked: bool) { } } +/// A tab strip's Close: hide `tab` of `g`, and the group with its last tab. When the selected +/// tab closes, the next one on the strip (or the previous one, for the last) is selected. +pub(crate) fn close_tab(app: &mut PhotocraftApp, g: Group, tab: usize, pro: bool) { + let shown_before = app.ui.dock.visible_tabs(g, pro); + app.ui.dock.hide_tab(g, tab, pro); + let remaining = app.ui.dock.visible_tabs(g, pro); + if remaining.is_empty() { + *g.shown_mut(&mut app.ui.panels) = false; + } else if *g.tab_mut(&mut app.ui.dock_tabs) == tab { + // Prefer the next tab on the strip, or the previous one when closing the last. + let at = shown_before.iter().position(|(i, _)| *i == tab).unwrap_or(0); + let next = shown_before.iter().skip(at + 1).find(|x| remaining.contains(x)).or_else(|| remaining.last()); + if let Some(&(next, _)) = next { + *g.tab_mut(&mut app.ui.dock_tabs) = next; + } + } +} + /// Per-group interactions collected while drawing, applied afterwards. enum Action { ToggleCollapse(Group), @@ -690,21 +708,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod app.ui.dock.set_collapsed(g, on); } Action::Close(g) => *g.shown_mut(&mut app.ui.panels) = false, - Action::CloseTab(g, tab) => { - let shown_before = app.ui.dock.visible_tabs(g, t.pro); - app.ui.dock.hide_tab(g, tab, t.pro); - let remaining = app.ui.dock.visible_tabs(g, t.pro); - if remaining.is_empty() { - *g.shown_mut(&mut app.ui.panels) = false; - } else if *g.tab_mut(&mut app.ui.dock_tabs) == tab { - // Prefer the next tab on the strip, or the previous one when closing the last. - let at = shown_before.iter().position(|(i, _)| *i == tab).unwrap_or(0); - let next = shown_before.iter().skip(at + 1).find(|x| remaining.contains(x)).or_else(|| remaining.last()); - if let Some(&(next, _)) = next { - *g.tab_mut(&mut app.ui.dock_tabs) = next; - } - } - } + Action::CloseTab(g, tab) => close_tab(app, g, tab, t.pro), Action::MoveTab(g, tab, before) => app.ui.dock.move_tab(g, tab, before, t.pro), Action::Move(g, before) => { if before != Some(g) { diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index f0a64562c4..ae6ef9aa61 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -672,6 +672,53 @@ fn panel_tab_context_menu_closes_one_tab_or_its_group() { } } +/// #7265: Studio's floating Properties card has the docked group's Adjustments tab, so Window › +/// Adjustments shows the grid of adjustment layers instead of Properties. +#[test] +fn studio_properties_card_shows_the_adjustments_tab() { + use egui_kittest::kittest::Queryable; + + let (mut app, _, _) = app_with_layers(); + app.last_canvas_rect = Rect::from_min_size(Pos2::ZERO, vec2(900.0, 700.0)); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::properties_window(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::Studio); + h.state_mut().ui.theme = ThemeKind::Studio; + h.run_steps(3); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "window.panel.adjustments", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.panels.properties && h.state().ui.dock_tabs.properties == 1); + assert!(h.query_by_label("Add an adjustment").is_some(), "Window › Adjustments shows the Adjustments grid"); + // The Properties tab switches back to the layer's properties. + h.get_by_label("Properties").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dock_tabs.properties, 0); + assert!(h.query_by_label("Add an adjustment").is_none()); + assert!(h.query_by_label("Reset to defaults").is_some(), "the Curves layer's properties"); + // Closing the Adjustments tab leaves Properties alone on the card. + h.get_by_label("Adjustments").click(); + h.run_steps(3); + let p = h.get_by_label("Adjustments").rect().center(); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.get_by_label("Close").click(); + h.run_steps(3); + assert!(h.state().ui.panels.properties && h.state().ui.dock_tabs.properties == 0); + assert!(h.query_by_label("Adjustments").is_none() && h.query_by_label("Reset to defaults").is_some()); +} + fn is_pro(theme: ThemeKind) -> bool { theme.is_pro() } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..e4f73f727f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3547,8 +3547,9 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = visible.iter().map(|(i, _)| *i).collect(); + let tabs: Vec<&str> = visible.iter().map(|(_, name)| *name).collect(); + let mut sel = indices.iter().position(|i| *i == app.ui.dock_tabs.properties).unwrap_or(0); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); + let hide = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0)); + if icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(hide)), "minus", 22.0, false, tl!("Hide Properties")).clicked() { + app.ui.panels.properties = false; + } + // The title bar drags the card; registered before the tabs so they keep their clicks. + let bar = Rect::from_min_max(row.min, pos2((hide.left() - 6.0).max(row.left()), row.bottom())); let grip = ui.interact(bar, card_id.with("title"), Sense::drag()); if grip.hovered() || grip.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } drag = grip.drag_delta(); + let strip = crate::tab_strip::pill_tabs(ui, card_id.with("tabs"), bar, &tabs, &mut sel, false); + if let Some(&picked) = indices.get(sel) { + app.ui.dock_tabs.properties = picked; + } + match strip.context { + Some(crate::tab_strip::TabContextAction::Close(i)) => { + if let Some(&tab) = indices.get(i) { + crate::dock::close_tab(app, group, tab, false); + } + } + Some(crate::tab_strip::TabContextAction::CloseGroup) => app.ui.panels.properties = false, + None => {} + } ui.add_space(6.0); widgets::hairline(ui); ui.add_space(8.0); - properties_body(app, ui); + match indices.get(sel) { + Some(0) | None => properties_body(app, ui), + Some(_) => adjustments_grid(app, ui), + } }); }); if drag != egui::Vec2::ZERO && canvas.is_positive() { From bffa2cc783f05a18a5a1c6c165631b8ba7c2a392 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Sun, 18 Oct 2026 23:05:59 -0300 Subject: [PATCH 0760/1452] fix: cancel animation for Navigator rectangle zoom --- crates/ui-egui/src/panels.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ff26dc3c60..f8c484cd6c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2111,6 +2111,7 @@ fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2 view.zoom = crate::zoom_levels::clamp(zoom, view.doc_size); view.center = [r.center().x, r.center().y]; view.fit_pending = false; + view.request_zoom_animation(false); } fn navigator_preview_id() -> egui::Id { @@ -2246,6 +2247,13 @@ mod navigator_wheel_tests { assert_eq!(app.ui.views[0].zoom, 1.0); let c = app.ui.views[0].center; assert!((c[0] - 100.0).abs() < 1.0 && (c[1] - 50.0).abs() < 1.0, "the view follows the pointer: {c:?}"); + // Finishing direct navigation during an existing zoom must snap to the new target. + let v = &mut app.ui.views[0]; + v.zoom = 2.0; + v.request_zoom_animation(true); + v.display_camera = Some((1.5, c)); + v.zoom_transition = Some(crate::state::ZoomTransition { from: (1.0, c), target: (v.zoom, c), started: 0.0 }); + v.zoom_anchor = Some(([50.0, 25.0], [200.0, 150.0])); // ⌘/Ctrl-drag: the rectangle fills the canvas (`last_canvas_rect`, 800 × 600 pt). drag(&mut app, &ctx, Modifiers::COMMAND, [50.0, 25.0], [100.0, 50.0]); let mut expected = app.ui.views[0].clone(); @@ -2267,6 +2275,8 @@ mod navigator_wheel_tests { (expected.zoom, expected.center) ); assert!(v.zoom > 1.0 && !v.fit_pending); + assert!(!v.zoom_animating && v.zoom_transition.is_none() && v.zoom_anchor.is_none(), "direct navigation cancels the old transition"); + assert_eq!(v.display_camera, Some((v.zoom, v.center)), "the selected rectangle is displayed immediately"); assert!(ctx.data(|d| d.get_temp::(navigator_zoom_drag_id())).is_none(), "the drag is over"); } From 7cdfb250b97d0a10401572d9d95e6401db308eef Mon Sep 17 00:00:00 2001 From: Gustavo Ziger Date: Sun, 18 Oct 2026 23:19:13 -0300 Subject: [PATCH 0761/1452] fix: align Navigator viewport with animated camera --- crates/ui-egui/src/panels.rs | 53 ++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f8c484cd6c..66fbcd8944 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2013,11 +2013,13 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Visible-area rectangle. let v = app.ui.views[idx].clone(); let canvas = app.last_canvas_rect; - let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6); + let (zoom, center) = + if v.zoom_animating && app.session.prefs().general.animated_zoom { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; + let point_zoom = (zoom / app.canvas_ppp()).max(1e-6); // Pixel aspect correction shows fewer document columns across the same canvas width. let vw = canvas.width() / point_zoom / app.ui.view.display_aspect() * s; let vh = canvas.height() / point_zoom * s; - let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); + let c = pos2(rect.min.x + center[0] * s, rect.min.y + center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); // Photoshop's one row under the preview (#4840): the zoom, typed in percent, then Zoom Out, @@ -2143,6 +2145,53 @@ mod navigator_wheel_tests { use super::*; use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase}; + /// The painted viewport follows the camera visible on the canvas, including preference changes. + #[test] + fn navigator_viewport_follows_the_displayed_camera_during_animation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.last_canvas_rect = Rect::from_min_size(Pos2::ZERO, vec2(80.0, 60.0)); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + for _ in 0..3 { + frame(&mut app, &ctx, pos2(5.0, 5.0), 0.0); + } + for (animating, enabled, displayed) in [ + (true, true, Some((2.0, [70.0, 40.0]))), + (true, true, Some((3.0, [110.0, 50.0]))), + (false, true, Some((2.0, [70.0, 40.0]))), + (true, false, Some((2.0, [70.0, 40.0]))), + (true, true, None), + ] { + app.run("prefs.set", json!({"values": {"general.animatedZoom": enabled}})).unwrap(); + let v = &mut app.ui.views[0]; + v.zoom = 4.0; + v.center = [140.0, 60.0]; + v.zoom_animating = animating; + v.display_camera = displayed; + let mut out = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), ..Default::default() }, |ui| { + navigator(&mut app, ui) + }); + let painted = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == Color32::from_rgb(255, 84, 84) => Some(r.rect), + _ => None, + }) + .expect("Navigator viewport outline"); + let (zoom, center) = if animating && enabled { displayed.unwrap_or((4.0, [140.0, 60.0])) } else { (4.0, [140.0, 60.0]) }; + let preview = last_navigator_preview(&ctx).unwrap(); + let scale = preview.width() / 200.0; + let expected_center = preview.min + vec2(center[0], center[1]) * scale; + let expected_size = vec2(80.0 / app.ui.view.display_aspect(), 60.0) * (app.canvas_ppp() / zoom * scale); + assert!((painted.center() - expected_center).length() < 0.01, "{animating}/{enabled}/{displayed:?}: {painted:?}"); + assert!((painted.size() - expected_size).length() < 0.01, "{animating}/{enabled}/{displayed:?}: {painted:?}"); + out.textures_delta.clear(); + } + } + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) { let mut events = vec![Event::PointerMoved(pointer)]; if wheel != 0.0 { From 2cca4faba0ae52e1eb31fc296f62be28dccb62c8 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 19 Oct 2026 10:22:57 +0800 Subject: [PATCH 0762/1452] Play .atn steps that switch to another open document Photoshop records a switch to another open document as a `select` of `Dcmn` by offset (-1 previous, 1 next). Import it as `document.activate {"offset": n}`, the tab that many places from the active one, and save it back as the same reference. Past the first or last tab is refused rather than wrapped (#7103). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 13 +++++- crates/engine/src/atn_cmds/tests.rs | 69 +++++++++++++++++++++++++++++ crates/engine/src/commands.rs | 29 ++++++++++-- crates/engine/src/tests.rs | 47 ++++++++++++++++++++ docs/gaps.md | 8 +++- 5 files changed, 160 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..195cef1fa7 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -13,7 +13,9 @@ //! work; tool selections and other steps with no document effect are imported switched off. //! //! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which -//! `commands::layer_param` resolves in whatever document the action plays on (#219). +//! `commands::layer_param` resolves in whatever document the action plays on (#219). A switch +//! to another open document (`select` of `Dcmn` by offset) becomes `document.activate +//! {"offset"}`, the tab that many places from the active one (#7103). use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; @@ -1442,6 +1444,9 @@ fn select_from(d: &Descriptor) -> Mapped { } } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + // Switching to another open document: its offset from the active one, -1 for the previous + // tab and 1 for the next (a template action copies from one and pastes into the other, #7103). + (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), _ => Mapped::Unsupported { harmless: false }, } } @@ -1476,6 +1481,11 @@ fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), + // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. + "document.activate" if p.get("document").is_none_or(Value::is_null) => { + let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) + } _ => return None, }) } @@ -2854,6 +2864,7 @@ const EVENTS: &[Event] = &[ "channel.target", "edit.undo", "edit.redo", + "document.activate", ], from: select_from, to: select_to, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..c50936133f 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -301,6 +301,7 @@ fn every_mapped_command_exports_and_imports_back() { ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), + ("document.activate", json!({"offset": -1})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), ("filter.pixelate.crystallize", json!({"cellSize": 5})), @@ -865,6 +866,74 @@ fn fsel(t: D) -> Descriptor { desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t) } +/// #7103: Photoshop records switching to another open document as a `select` of `Dcmn` by +/// offset (previous -1, next +1). The template action of the report, which copies from the +/// source image and pastes into the template opened after it, imports with no Photoshop steps, +/// plays across the two documents and saves back the same shape. With no previous document +/// open, playback stops at the switch instead of pasting into the wrong document. +#[test] +fn select_document_by_offset_imports_plays_and_saves_back() { + let doc = |offset: i32| desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])); + // A recorded document id (`DocI`) means nothing in another session: ignored, not written back. + let copy_in = vec![ + item("slct", "Select", Some(doc(-1).with("DocI", long(35.0)))), + item("setd", "Set", Some(fsel(enm("Ordn", "Al ")))), + item("copy", "Copy", None), + item("slct", "Select", Some(doc(1))), + item("past", "Paste", None), + ]; + let data = set_of("Template", vec![("Copy In", copy_in)]); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "source"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "template", "background": "transparent"})).unwrap(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let steps = &s.actions.list[0].steps; + assert_eq!(steps[0], ("document.activate".to_string(), json!({"offset": -1}))); + assert_eq!(steps[3], ("document.activate".to_string(), json!({"offset": 1}))); + + let pixel = |s: &mut Session| -> Vec { serde_json::from_value(s.execute("document.pixel", json!({"x": 4, "y": 4})).unwrap()).unwrap() }; + assert_eq!(pixel(&mut s), [0.0; 4]); + let r = s.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().doc.name, "template"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "the source's pixels are pasted on a new layer"); + assert_eq!(pixel(&mut s), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(s.documents()[0].doc.layers.len(), 1, "the source is only copied from"); + + let first = s.actions.list.clone(); + let r = s.execute("actions.save", json!({"set": "Template"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + let offsets: Vec = file.actions[0] + .items + .iter() + .filter_map(|i| match get_ref(i.descriptor.as_ref()?, "null")? { + [ReferenceItem::Offset { class, offset }] if trimmed(&class.class_id) == "Dcmn" => Some(*offset), + _ => None, + }) + .collect(); + assert_eq!(offsets, [-1, 1]); + assert!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("DocI").is_none()); + let mut t = Session::new(); + t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(t.actions.list, first); + // An absolute tab index has no Photoshop equivalent: it stays out of a strict save. + assert!(export_item("document.activate", &json!({"document": 0}), "S").is_none()); + assert!(export_item("document.activate", &json!({"document": 0, "offset": 1}), "S").is_none()); + + let mut one = Session::new(); + one.execute("file.new", json!({"width": 8, "height": 8, "name": "template"})).unwrap(); + one.execute("actions.load", json!({"data": data})).unwrap(); + let r = one.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert_eq!(r["failed"]["step"], 0, "{r}"); + assert_eq!(r["failed"]["error"], "no document 1 before the active one, which is 1 of 1", "{r}"); + assert_eq!(one.active().unwrap().doc.layers.len(), 1); +} + fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { let file = AtnFile { name: UnicodeString::new_nul(name), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..9cb069ffbe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1248,12 +1248,33 @@ fn build() -> Vec { label: "Activate Document", menu: &[], shortcut: None, - params: r##"{"document":index}"##, + params: r##"{"document":index} or {"offset":int} (the open document that many tabs after the active one, before it when negative: a Photoshop action's switch to another document) → {document}"##, enabled: has_doc, run: |s, p| { - let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; - let idx = usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))?; - if s.set_active(idx) { Ok(Value::Null) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } + // A null key counts as omitted. + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let idx = match (given("document"), given("offset")) { + (Some(_), Some(_)) => return Err(bad("document.activate", "give `document` or `offset`, not both")), + (None, None) => return Err(bad("document.activate", "needs `document` (a tab index) or `offset`")), + // Past the first or last tab is refused rather than wrapped, so an action that + // copies from one document never silently pastes into the wrong one (#7103). + (None, Some(o)) => { + let o = o.as_i64().ok_or_else(|| bad("document.activate", "`offset` must be an integer"))?; + let (active, len) = (s.active_index().ok_or(EngineError::NoDocument)?, s.documents().len()); + let target = i64::try_from(active).ok().and_then(|a| a.checked_add(o)).and_then(|i| usize::try_from(i).ok()); + let Some(i) = target.filter(|&i| i < len) else { + let side = if o < 0 { "before" } else { "after" }; + let msg = format!("no document {} {side} the active one, which is {} of {len}", o.unsigned_abs(), active + 1); + return Err(EngineError::Other(msg)); + }; + i + } + (Some(d), None) => { + let i = d.as_u64().ok_or_else(|| bad("document.activate", "`document` must be a tab index"))?; + usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))? + } + }; + if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } }, journal: false, }, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0849cd941a..2acd70fcc6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1470,6 +1470,53 @@ fn document_inspect_and_activate_invalid_index() { assert_eq!(err_act.to_string(), "no document at index 9"); } +/// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks the open +/// documents in tab order from the active one. Past either end it is refused, not wrapped onto +/// another document, and bad params leave the active document as it was. +#[test] +fn document_activate_by_offset_walks_the_tabs() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width": 2, "height": 2, "name": name})).unwrap(); + } + let active = |s: &Session| s.active().unwrap().doc.name.clone(); + assert_eq!(active(&s), "c"); + assert_eq!(s.execute("document.activate", json!({"offset": -2})).unwrap(), json!({"document": 0})); + assert_eq!(active(&s), "a"); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 1})); + assert_eq!(active(&s), "b"); + s.execute("document.activate", json!({"offset": 0})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"document": 2})).unwrap(), json!({"document": 2})); + // A null key counts as omitted. + s.execute("document.activate", json!({"document": null, "offset": -1})).unwrap(); + assert_eq!(active(&s), "b"); + + let err = s.execute("document.activate", json!({"offset": 2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 after the active one, which is 2 of 3"); + let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 before the active one, which is 2 of 3"); + for bad in [ + json!({}), + json!({"offset": null}), + json!({"offset": 1.5}), + json!({"offset": "1"}), + json!({"offset": i64::MAX}), + json!({"offset": i64::MIN}), + json!({"offset": u64::MAX}), + json!({"document": 0, "offset": 1}), + json!({"document": -1}), + ] { + assert!(s.execute("document.activate", bad.clone()).is_err(), "{bad}"); + assert_eq!(active(&s), "b", "{bad}"); + } + // Tabs, not the order the documents were opened in: with "c" dragged to the front, two tabs + // before "b" is "c". + s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + s.execute("document.activate", json!({"offset": -2})).unwrap(); + assert_eq!(active(&s), "c"); +} + /// #2166, measured in Photoshop 25.1: a layer mask and the layer pixels keep separate /// foreground/background pairs, swapped when the edit target changes. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..81828282fd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G11: `.atn` steps that switch to another open document import, play and save back, #7103; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -98,6 +98,11 @@ what an agent can't finish alone. mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-19:** `.atn` steps that switch to another open document (a `select` of `Dcmn` by + offset: the previous, next or Nth tab from the active one) import as `document.activate {"offset"}`, + play and save back in the same shape, so actions that copy from one open document and paste into + another no longer stop at the switch (#7103). An offset past the first or last open document is + refused, not wrapped. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, @@ -270,6 +275,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | G11: `.atn` steps that select another open document by offset import as `document.activate {"offset"}`, play and save back (#7103); estimate unchanged | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | From 89a34d0d15b428dcac422afc72365b8fc20f3bef Mon Sep 17 00:00:00 2001 From: Cl4VD3 <344569071+Cl4VD3@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:26:42 +0200 Subject: [PATCH 0763/1452] Brush stepping: group like the panel, skip names it can't pick, test a refused step changes nothing Review: presets with no group share the panel's "My Brushes" group (UNGROUPED now lives in the engine, and brush_panel uses it); a preset whose name only differs in case from an earlier one can't be selected by name, so stepping skips it instead of sticking; the empty-library test checks the brush and current preset are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_key_cmds.rs | 34 ++++++++++++++++++++++++----- crates/ui-egui/src/brush_panel.rs | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index acb3c4f368..351f4df65f 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -37,8 +37,14 @@ fn size(s: &mut Session, up: bool) -> Result { crate::brush_cmds::set_brush(s, &json!({ "brush": { "size": size } })) } -/// Brush preset indices in the Brushes panel's order: groups in order of first appearance, and in -/// each group its folders where their first preset is, depth first (as `brushes_tab` draws them). +/// The Brushes panel's label for presets saved without a group: they share its group with presets +/// in a group of that name. +pub const UNGROUPED: &str = "My Brushes"; + +/// Brush preset indices in the Brushes panel's order: groups in order of first appearance (no +/// group is [`UNGROUPED`]), and in each group its folders where their first preset is, depth first +/// (as `brushes_tab` draws them). A preset its name can't select (an earlier one has the same name +/// up to case, and `tools.setBrush` picks that one) is left out, so stepping never gets stuck. fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { fn path(p: &photocraft_paint::BrushPreset) -> &[String] { p.folder.get(..p.folder.len().min(crate::brush_preset_cmds::MAX_FOLDER_DEPTH)).unwrap_or_default() @@ -60,9 +66,14 @@ fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { } let mut groups: Vec<(&str, Vec)> = Vec::new(); for (i, p) in presets.iter().enumerate() { - match groups.iter_mut().find(|(g, _)| *g == p.group) { + let selectable = photocraft_paint::presets::find(presets, &p.name).is_some_and(|q| std::ptr::eq(q, p)); + if !selectable { + continue; + } + let group = if p.group.is_empty() { UNGROUPED } else { p.group.as_str() }; + match groups.iter_mut().find(|(g, _)| *g == group) { Some((_, v)) => v.push(i), - None => groups.push((&p.group, vec![i])), + None => groups.push((group, vec![i])), } } let mut out = Vec::with_capacity(presets.len()); @@ -217,6 +228,14 @@ mod tests { let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); // G1: a, folder F (c, e, then Sub's f where its first preset is, g), d; then G2: b. assert_eq!(names, ["a", "c", "e", "f", "g", "d", "b"]); + // No group is the panel's "My Brushes": one group with presets named into it. + let presets = vec![preset("x", "", &[], 1.0), preset("y", UNGROUPED, &[], 1.0), preset("z", "", &[], 1.0), preset("w", "G", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["x", "y", "z", "w"]); + // A name only a case apart from an earlier one can't be picked by name: skipped. + let presets = vec![preset("Foo", "", &[], 1.0), preset("foo", "", &[], 1.0), preset("Bar", "", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["Foo", "Bar"]); } #[test] @@ -242,8 +261,13 @@ mod tests { [sc("tools.previousBrush"), sc("tools.nextBrush"), sc("tools.firstBrush"), sc("tools.lastBrush")], [Some(","), Some("."), Some("Shift+,"), Some("Shift+.")] ); + // No presets: an error, and the brush and the current preset stay as they were. s.tools.presets.clear(); - assert!(s.execute("tools.nextBrush", Value::Null).is_err()); + let (brush, current) = (s.tools.brush.clone(), s.tools.current_preset.clone()); + for id in ["tools.previousBrush", "tools.nextBrush", "tools.firstBrush", "tools.lastBrush"] { + assert!(s.execute(id, Value::Null).is_err(), "{id}"); + } + assert_eq!((s.tools.brush.clone(), s.tools.current_preset.clone()), (brush, current)); } #[test] diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 332c80c4b7..c015ab137a 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -211,7 +211,7 @@ pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &Bru } /// Group label for presets saved without a group. -pub const UNGROUPED: &str = "My Brushes"; +pub const UNGROUPED: &str = photocraft_engine::brush_key_cmds::UNGROUPED; /// Preset indices in panel order: groups in order of first appearance. pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec)> { From d1003c6191cccb40d8fb617db012ac3c9fe8e72d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 20:43:36 -0600 Subject: [PATCH 0764/1452] Fade the alpha channel or Quick Mask an edit changed (#7382) FadeSource only knew a layer and whether its mask was edited. With an alpha channel or the Quick Mask targeted, filters, adjustments and paint change that channel, but Fade blended the untouched layer back and left the channel as edited. It now records what the command changed, as a FadeTarget (a layer's pixels or mask, an alpha channel, or the Quick Mask), and fades that surface. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit_menu_cmds.rs | 65 ++++++++++++++++++----- crates/engine/src/edit_menu_cmds/tests.rs | 41 +++++++++++++- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index f9fab33e37..4cb4b2e606 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -13,6 +13,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use crate::channel_cmds::ChannelTarget; use crate::commands::{CommandSpec, blend_from_str, int, layer_param}; use crate::{EngineError, Result, Session}; @@ -24,11 +25,23 @@ pub struct FadeSource { pub doc: DocId, /// Document revision right after the command; any later change disables Fade. pub revision: u64, - pub layer: LayerId, - pub mask: bool, + pub target: FadeTarget, pub label: String, } +/// What the faded command changed, and so what Fade blends back. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FadeTarget { + /// A layer's pixels. + Pixels(LayerId), + /// A layer's layer mask. + Mask(LayerId), + /// An alpha or spot channel (index into [`Document::channels`]). + Channel(usize), + /// The Quick Mask. + QuickMask, +} + /// A custom shape (Edit › Define Custom Shape), usable by the Custom Shape tool. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CustomShape { @@ -60,8 +73,18 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { return; } let Some(st) = s.active() else { return }; - let Some(layer) = st.active_layer else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); + // A targeted alpha channel, or the Quick Mask, takes the edit instead of the layer (#7382). + let channel = match st.channel_view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)), + ChannelTarget::Composite if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), + _ => None, + }; + if let Some(target) = channel { + s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label }); + return; + } + let Some(layer) = st.active_layer else { return }; let mask = st.doc.layer(layer).is_some_and(|l| l.mask.is_some()) && st.history.state(st.history.past_len().wrapping_sub(1)).is_some_and(|prev| { // The mask changed and the pixels didn't: the command painted the mask. @@ -75,7 +98,8 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface())) && !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface))) }); - s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, layer, mask, label }); + let target = if mask { FadeTarget::Mask(layer) } else { FadeTarget::Pixels(layer) }; + s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label }); } /// Tile identity of a surface (pointer equality of its COW tiles). @@ -144,15 +168,31 @@ fn can_fade(s: &Session) -> std::result::Result<(), String> { if f.doc != st.doc.id || f.revision != st.revision || !st.history.can_undo() { return Err("Fade is only available right after a filter, adjustment or painting step".into()); } - if st.doc.layer(f.layer).is_none() { - return Err("the faded layer no longer exists".into()); + if surface_of(&st.doc, f.target).is_none() { + return Err(match f.target { + FadeTarget::Pixels(_) | FadeTarget::Mask(_) => "the faded layer no longer exists".into(), + FadeTarget::Channel(_) | FadeTarget::QuickMask => "the faded channel no longer exists".into(), + }); } Ok(()) } -fn surface_of(doc: &Document, id: LayerId, mask: bool) -> Option<&Surface> { - let l = doc.layer(id)?; - if mask { l.mask.as_ref().map(|m| &m.surface) } else { l.surface() } +fn surface_of(doc: &Document, target: FadeTarget) -> Option<&Surface> { + match target { + FadeTarget::Pixels(id) => doc.layer(id)?.surface(), + FadeTarget::Mask(id) => doc.layer(id)?.mask.as_ref().map(|m| &m.surface), + FadeTarget::Channel(i) => doc.channels.get(i).map(|c| &c.surface), + FadeTarget::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + } +} + +fn surface_of_mut(doc: &mut Document, target: FadeTarget) -> Option<&mut Surface> { + match target { + FadeTarget::Pixels(id) => doc.layer_mut(id)?.surface_mut(), + FadeTarget::Mask(id) => doc.layer_mut(id)?.mask.as_mut().map(|m| &mut m.surface), + FadeTarget::Channel(i) => doc.channels.get_mut(i).map(|c| &mut c.surface), + FadeTarget::QuickMask => doc.quick_mask.as_mut().map(|c| &mut c.surface), + } } /// Blend `after` back towards `before` over `area`: `before + opacity × (B(before, after) − before)` @@ -209,8 +249,8 @@ fn fade(s: &mut Session, p: &Value) -> Result { let mode = blend_from_str(str_or(p, "mode", "normal")).ok_or_else(|| bad(cmd, "unknown blend mode"))?; let st = s.active().ok_or(EngineError::NoDocument)?; let prev = st.history.state(st.history.past_len() - 1).ok_or_else(|| bad(cmd, "no previous state"))?; - let before = surface_of(&prev, src.layer, src.mask).cloned().ok_or_else(|| EngineError::Other("the layer did not exist before this step".into()))?; - let now = surface_of(&st.doc, src.layer, src.mask).ok_or(EngineError::NoLayer(src.layer))?; + let before = surface_of(&prev, src.target).cloned().ok_or_else(|| EngineError::Other("the layer or channel did not exist before this step".into()))?; + let now = surface_of(&st.doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } @@ -219,8 +259,7 @@ fn fade(s: &mut Session, p: &Value) -> Result { let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { - let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; - let surf = if src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(src.layer))?; + let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; fade_surface(&before, surf, area, mode, opacity)?; surf.prune(); Ok(()) diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 873061e462..34b42a3a46 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -72,7 +72,7 @@ fn fade_uniform_layer_mask_invert_at_all_depths() { assert!((mask_pixel(&s) - 1.0).abs() < 0.01); s.execute("image.adjustments.invert", json!({})).unwrap(); assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); - assert!(s.edit_state.fade.as_ref().is_some_and(|f| f.mask), "depth {depth}: Fade must target the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| matches!(f.target, FadeTarget::Mask(_))), "depth {depth}: Fade must target the mask"); let history_before_fade = s.active().unwrap().history.past_len(); s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); @@ -89,6 +89,45 @@ fn fade_uniform_layer_mask_invert_at_all_depths() { } } +/// #7382: with an alpha channel or the Quick Mask targeted, the edit changes it, not the layer, and +/// Fade blends it back: at 0 % to the channel as it was, at every depth. +#[test] +fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { + for depth in [8, 16, 32] { + for quick_mask in [false, true] { + for (opacity, k) in [(0, 0.0), (25, 0.25), (100, 1.0)] { + let mut s = session(depth); + if quick_mask { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + } else { + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + } + let channel = |s: &Session, x, y| { + let doc = &s.active().unwrap().doc; + let c = if quick_mask { doc.quick_mask.as_ref() } else { doc.channels.first() }; + c.unwrap().surface.pixel(x, y)[0] + }; + let was = channel(&s, 5, 5); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted"); + let expected = was + (1.0 - 2.0 * was) * k; + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let v = channel(&s, x, y); + assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}"); + } + assert_eq!(px(&s, 5, 5)[0], 1.0, "the layer is left alone"); + let st = s.active().unwrap(); + assert_eq!((st.history.past_len(), st.history.undo_label()), (steps + 1, Some("Fade Invert"))); + assert!(s.undo()); + assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "undo puts the inverted channel back"); + } + } + } +} + #[test] fn purge_commands() { let mut s = session(8); From 5470e2fa0aa5eef8cdfb0b428fcd86957ecb4c22 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 20:59:57 -0600 Subject: [PATCH 0765/1452] Fade the target the command ran on, not the Channels panel's An explicit target (edit.fill with "target": "pixels" while an alpha channel is targeted) edits the layer, but Fade recorded the channel from the Channels panel. after_command now resolves the target as the command did, with inject_target: the explicit target, else the panel's, and only for the commands it routes to channels. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit_menu_cmds.rs | 14 ++++++++------ crates/engine/src/edit_menu_cmds/tests.rs | 15 +++++++++++++++ crates/engine/src/jobs.rs | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 4cb4b2e606..b12c9aafb8 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -13,7 +13,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::channel_cmds::ChannelTarget; +use crate::channel_cmds::Target; use crate::commands::{CommandSpec, blend_from_str, int, layer_param}; use crate::{EngineError, Result, Session}; @@ -68,16 +68,18 @@ fn fadeable(id: &str) -> bool { } /// Called after every successful command: remember a fadeable result. -pub(crate) fn after_command(s: &mut Session, id: &str) { +pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) { if !fadeable(id) { return; } let Some(st) = s.active() else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); - // A targeted alpha channel, or the Quick Mask, takes the edit instead of the layer (#7382). - let channel = match st.channel_view.target { - ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)), - ChannelTarget::Composite if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), + // An alpha channel or the Quick Mask took the edit instead of the layer when the command ran + // on it (#7382): its explicit `target`, or the one the Channels panel gave it. + let run = crate::channel_cmds::inject_target(s, id, params.clone()); + let channel = match crate::channel_cmds::target_of(&run) { + Target::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)), + Target::QuickMask if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), _ => None, }; if let Some(target) = channel { diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 34b42a3a46..9298139d65 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -128,6 +128,21 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { } } +/// A command told to edit the layer (`"target": "pixels"`) does so with an alpha channel targeted, +/// and Fade then fades the layer, not the channel. +#[test] +fn fade_follows_the_target_the_command_ran_on() { + let mut s = session(8); + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000", "target": "pixels"})).unwrap(); + assert_eq!(px(&s, 5, 5)[0], 0.0, "the layer was filled"); + assert!(matches!(s.edit_state.fade.as_ref().map(|f| f.target), Some(FadeTarget::Pixels(_)))); + s.execute("edit.fade", json!({"opacity": 25})).unwrap(); + assert!((px(&s, 5, 5)[0] - 0.75).abs() < 0.01, "the layer faded"); + assert_eq!(s.active().unwrap().doc.channels[0].surface.pixel(5, 5)[0], 0.0, "the channel is left alone"); +} + #[test] fn purge_commands() { let mut s = session(8); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b30e56da3a..b3237e7661 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -789,7 +789,7 @@ impl Session { if let Some(st) = self.active_mut() { crate::mask_view_cmds::fix(st); } - crate::edit_menu_cmds::after_command(self, id); + crate::edit_menu_cmds::after_command(self, id, ¶ms); crate::automate_cmds::after_command(self, id); self.sync_preset_store(); if !journal { From a8e64ee938cd49cc8e5c93c8d1611a340cac6fde Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 21:13:39 -0600 Subject: [PATCH 0766/1452] Take Fade's channel target only from commands routed to channels image.autoTone accepts a target param but edits the active layer, so an explicit channel target recorded the unchanged channel for Fade. Only commands channel_cmds::routed sends to channels now record one. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/channel_cmds.rs | 2 +- crates/engine/src/edit_menu_cmds.rs | 4 +++- crates/engine/src/edit_menu_cmds/tests.rs | 9 +++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 991f40dc0b..c061ce59c7 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -701,7 +701,7 @@ pub fn follows_target(id: &str) -> bool { } /// Commands whose target follows the Channels panel. -fn routed(id: &str) -> bool { +pub(crate) fn routed(id: &str) -> bool { id.starts_with("filter.") || id == "plugin.run" || id.starts_with("image.adjustments.") diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index b12c9aafb8..1a84405382 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -75,9 +75,11 @@ pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) { let Some(st) = s.active() else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); // An alpha channel or the Quick Mask took the edit instead of the layer when the command ran - // on it (#7382): its explicit `target`, or the one the Channels panel gave it. + // on it (#7382): its explicit `target`, or the one the Channels panel gave it. Only commands + // routed to channels edit them; the others edit the layer whatever `target` says. let run = crate::channel_cmds::inject_target(s, id, params.clone()); let channel = match crate::channel_cmds::target_of(&run) { + _ if !crate::channel_cmds::routed(id) => None, Target::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)), Target::QuickMask if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), _ => None, diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 9298139d65..439fbba72c 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -143,6 +143,15 @@ fn fade_follows_the_target_the_command_ran_on() { assert_eq!(s.active().unwrap().doc.channels[0].surface.pixel(5, 5)[0], 0.0, "the channel is left alone"); } +/// A command that only edits layers (Auto Tone) fades the layer, even given a channel `target`. +#[test] +fn fade_ignores_a_target_the_command_doesnt_take() { + let mut s = session(8); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.autoTone", json!({"target": "quickMask"})).unwrap(); + assert!(matches!(s.edit_state.fade.as_ref().map(|f| f.target), Some(FadeTarget::Pixels(_)))); +} + #[test] fn purge_commands() { let mut s = session(8); From f2a487b08845e907d78368767f236fc478fb8ae4 Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Sun, 18 Oct 2026 23:48:55 -0300 Subject: [PATCH 0767/1452] Flatpak: a second launch hands its files to the running PhotoCraft over a Unix socket (#1540) #3755's hand-off listens on a loopback port, and every `flatpak run` gets its own network namespace, so inside the sandbox the second launch could never reach the first and opened a separate window. When FLATPAK_ID and XDG_RUNTIME_DIR are set the primary now listens on $XDG_RUNTIME_DIR/app//instance.sock (0600), a directory every sandbox of the app shares, and records "socket" instead of "port" in instance.json. The lock, token, client cap, deadlines and reply are unchanged; a crashed primary's socket file is unlinked under the lock, and shutdown removes the socket. --- apps/photocraft/src/single_instance.rs | 338 ++++++++++++++++++++++--- docs/development.md | 2 +- 2 files changed, 305 insertions(+), 35 deletions(-) diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index d2cd65c678..4b57fa6942 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -3,8 +3,9 @@ //! copy of the program. //! //! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS -//! drops it when the process ends, crash included), listens on a loopback port and writes the port -//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its +//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak +//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit +//! token to `instance.json`. A later launch that finds the lock taken sends its //! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and //! brings the window forward. Explorer starts one process per file when several are opened at once; //! the lock decides the race, so they all end up as tabs in one window. @@ -26,7 +27,11 @@ use std::fs::{File, OpenOptions}; use std::io::{BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{SyncSender, sync_channel}; use std::sync::{Arc, Mutex, PoisonError}; @@ -38,6 +43,9 @@ use serde_json::{Value, json}; const LOCK_FILE: &str = "instance.lock"; const INFO_FILE: &str = "instance.json"; +/// The Unix socket a sandboxed primary listens on, in the app's runtime directory. +#[cfg(unix)] +const SOCKET_FILE: &str = "instance.sock"; /// More paths than this in one request is not a file manager. const MAX_PATHS: usize = 4096; /// Launches served at once; a stalled client holds only its own slot, never the next launch. @@ -59,6 +67,45 @@ pub struct Timing { /// Generous enough for a primary that is still opening its window. pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) }; +/// How the primary listens for later launches. +#[derive(Clone, Debug, PartialEq)] +pub enum Transport { + /// A loopback port (the default): private to the machine, reachable by every process on it. + Loopback, + /// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each + /// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never + /// reaches the first (#1540). `$XDG_RUNTIME_DIR/app//` is the same directory in every + /// sandbox of the app, and the user's alone (0700). + #[cfg(unix)] + Socket(PathBuf), +} + +/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is +/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port. +pub fn transport() -> Transport { + #[cfg(unix)] + if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) { + return Transport::Socket(path); + } + Transport::Loopback +} + +/// `$XDG_RUNTIME_DIR/app//instance.sock`, when both are set. +#[cfg(unix)] +fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option { + let id = flatpak_id.filter(|v| !v.is_empty())?; + let dir = runtime_dir.filter(|v| !v.is_empty())?; + Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE)) +} + +/// Where the running primary listens, as `instance.json` records it. +#[derive(Clone, Debug, PartialEq)] +enum Endpoint { + Port(u16), + #[cfg(unix)] + Socket(PathBuf), +} + /// Outcome of [`claim`]. pub enum Launch { /// This process is the running instance; keep [`Primary`] alive and attach it to the app. @@ -110,6 +157,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option { pub struct Primary { lock: Mutex>, shared: Arc, + /// The socket file to remove on the first shutdown (a Unix socket transport only); taken + /// then, so the second call (`Drop`) can't remove the socket the next primary has bound. + #[cfg(unix)] + socket: Mutex>, } struct Shared { @@ -151,6 +202,10 @@ impl Primary { self.shared.closed.store(true, Ordering::Release); // Dropping the senders refuses every connection still waiting for the UI. self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear(); + #[cfg(unix)] + if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = std::fs::remove_file(path); + } drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take()); } } @@ -211,9 +266,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch { claim_with(dir, paths, TIMING) } -/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock -/// (free once a quitting or crashed primary is gone) and then the running primary. +/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]). pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { + claim_in(dir, paths, timing, &transport()) +} + +/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free +/// once a quitting or crashed primary is gone) and then the running primary, wherever +/// `instance.json` says it listens. +pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch { if let Err(e) = std::fs::create_dir_all(dir) { return Launch::Standalone(format!("{}: {e}", dir.display())); } @@ -227,7 +288,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { loop { match lock.try_lock() { Ok(()) => { - return match serve(dir, lock, timing) { + return match serve(dir, lock, timing, transport) { Ok(primary) => Launch::Primary(primary), Err(e) => Launch::Standalone(e), }; @@ -235,8 +296,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { Err(std::fs::TryLockError::WouldBlock) => {} Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())), } - if let Some((port, token)) = read_info(dir) { - match send(port, &token, paths, deadline) { + if let Some((endpoint, token)) = read_info(dir) { + match send(&endpoint, &token, paths, deadline) { Ok(()) => return Launch::Forwarded, Err(e) => last = e, } @@ -248,11 +309,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { } } -fn serve(dir: &Path, lock: File, timing: Timing) -> Result { - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?; - let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port(); +fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result { + let (listener, endpoint) = Listener::bind(transport)?; let token = generate_token().map_err(|e| e.to_string())?; - write_info(dir, port, &token)?; + write_info(dir, &endpoint, &token)?; + #[cfg(unix)] + let socket = match &endpoint { + Endpoint::Socket(path) => Some(path.clone()), + Endpoint::Port(_) => None, + }; let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) }); let listening = Arc::clone(&shared); let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || { @@ -260,7 +325,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result { let clients = Arc::new(AtomicUsize::new(0)); loop { let stream = match listener.accept() { - Ok((stream, _)) => stream, + Ok(stream) => stream, // Back off instead of spinning when accept keeps failing (e.g. out of descriptors). Err(_) => { std::thread::sleep(Duration::from_millis(100)); @@ -288,11 +353,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result { } }); spawned.map_err(|e| format!("single-instance listener thread: {e}"))?; - Ok(Primary { lock: Mutex::new(Some(lock)), shared }) + Ok(Primary { + lock: Mutex::new(Some(lock)), + shared, + #[cfg(unix)] + socket: Mutex::new(socket), + }) +} + +/// The primary's listener on either transport. +enum Listener { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), +} + +impl Listener { + fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> { + match transport { + Transport::Loopback => { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?; + let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port(); + Ok((Listener::Tcp(listener), Endpoint::Port(port))) + } + #[cfg(unix)] + Transport::Socket(path) => { + let at = |e: std::io::Error| format!("{}: {e}", path.display()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(at)?; + } + // This process holds the lock, so a file already there is a crashed primary's. + let _ = std::fs::remove_file(path); + let listener = UnixListener::bind(path).map_err(at)?; + // The runtime directory is the user's alone already; keep the socket so too. + std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?; + Ok((Listener::Unix(listener), Endpoint::Socket(path.clone()))) + } + } + } + + fn accept(&self) -> std::io::Result { + match self { + Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)), + #[cfg(unix)] + Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)), + } + } +} + +/// One connection, on either transport. +enum Stream { + Tcp(TcpStream), + #[cfg(unix)] + Unix(UnixStream), +} + +impl Stream { + /// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate). + fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result { + match endpoint { + Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp), + #[cfg(unix)] + Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix), + } + } + + fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.set_read_timeout(dur), + #[cfg(unix)] + Stream::Unix(s) => s.set_read_timeout(dur), + } + } + + fn set_write_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.set_write_timeout(dur), + #[cfg(unix)] + Stream::Unix(s) => s.set_write_timeout(dur), + } + } + + /// One read through a shared reference (both socket types read through `&self`). + fn read_once(&self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => (&*s).read(buf), + #[cfg(unix)] + Stream::Unix(s) => (&*s).read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => s.write(buf), + #[cfg(unix)] + Stream::Unix(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.flush(), + #[cfg(unix)] + Stream::Unix(s) => s.flush(), + } + } } /// Read one request, hand its paths to the UI and answer `ok` only if the UI took them. -fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) { +fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) { let taken = match read_request(&stream, timing.request) { Some(line) => match parse_request(&line, token) { Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)), @@ -308,7 +479,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) /// A reader that fails once `until` passes, however slowly the bytes trickle in. struct Deadline<'a> { - stream: &'a TcpStream, + stream: &'a Stream, until: Instant, } @@ -317,25 +488,30 @@ impl Read for Deadline<'_> { let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()); let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?; self.stream.set_read_timeout(Some(left))?; - let mut stream = self.stream; - stream.read(buf) + self.stream.read_once(buf) } } /// The request line, if it arrives complete within `total`. -fn read_request(stream: &TcpStream, total: Duration) -> Option { +fn read_request(stream: &Stream, total: Duration) -> Option { let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total }); let mut line = String::new(); matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line) } -/// Port and token, written to a temporary file and renamed so a reader never sees half of it. +/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it. /// On Unix the file is the user's alone (0600): another local user who read the token could make /// this app open any path. (Windows keeps the per-user ACL of the settings folder.) -fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> { +fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> { let path = dir.join(INFO_FILE); let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); - let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string(); + let mut body = json!({"token": token, "pid": std::process::id()}); + match endpoint { + Endpoint::Port(port) => body["port"] = json!(port), + #[cfg(unix)] + Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()), + } + let body = body.to_string(); // A leftover from a crash could carry wider permissions; the mode applies only on create. let _ = std::fs::remove_file(&tmp); let mut options = OpenOptions::new(); @@ -349,10 +525,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> { }) } -fn read_info(dir: &Path) -> Option<(u16, String)> { +fn read_info(dir: &Path) -> Option<(Endpoint, String)> { let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?; + let token = v.get("token")?.as_str()?.to_string(); + #[cfg(unix)] + if let Some(socket) = v.get("socket").and_then(Value::as_str) { + return Some((Endpoint::Socket(PathBuf::from(socket)), token)); + } let port = u16::try_from(v.get("port")?.as_u64()?).ok()?; - Some((port, v.get("token")?.as_str()?.to_string())) + Some((Endpoint::Port(port), token)) } /// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI. @@ -370,11 +551,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec, Duration)> { Some((paths, wait)) } -/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them. -fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> { +/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them. +fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> { let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?; - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); - let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?; + let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?; // The primary must answer before our deadline, so it waits a little less than we do. let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX); let request = json!({"token": token, "open": paths, "wait_ms": wait_ms}); @@ -435,6 +615,21 @@ mod tests { } } + /// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104). + #[cfg(unix)] + fn short_socket_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE) + } + + /// The loopback port the primary under `dir` listens on. + fn port_of(dir: &Path) -> u16 { + match read_info(dir).unwrap().0 { + Endpoint::Port(port) => port, + #[cfg(unix)] + Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()), + } + } + #[test] fn second_launch_hands_its_paths_to_the_first() { let dir = temp_dir("handoff"); @@ -496,7 +691,7 @@ mod tests { // instance.json from a process that is gone; the lock is free. std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap(); let primary = primary(&dir); - assert!(read_info(&dir).is_some_and(|(port, _)| port != 1)); + assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1))); drop(primary); // Released with the handle, as on process exit. assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_))); @@ -516,10 +711,10 @@ mod tests { primary.shutdown(); assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary"); // After shutdown new connections are refused too. - let (port, token) = read_info(&dir).unwrap(); + let (endpoint, token) = read_info(&dir).unwrap(); drop(primary); let short = Instant::now() + Duration::from_millis(300); - assert!(send(port, &token, &["b.psd".into()], short).is_err()); + assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err()); let _ = std::fs::remove_dir_all(&dir); } @@ -557,7 +752,7 @@ mod tests { let dir = temp_dir("stalled"); let primary = primary(&dir); let mut ui = primary.connect(&egui::Context::default()); - let (port, _) = read_info(&dir).unwrap(); + let port = port_of(&dir); // Connects and never sends a line (each would otherwise hold the listener). let stalled: Vec = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect(); let started = Instant::now(); @@ -575,7 +770,7 @@ mod tests { fn a_trickling_client_is_cut_off_at_the_request_deadline() { let dir = temp_dir("trickle"); let primary = primary(&dir); - let (port, _) = read_info(&dir).unwrap(); + let port = port_of(&dir); let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); let started = Instant::now(); let mut cut_off = false; @@ -602,7 +797,7 @@ mod tests { let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); std::fs::write(&tmp, "{}").unwrap(); std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap(); - write_info(&dir, 1, &generate_token().unwrap()).unwrap(); + write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap(); let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); let _ = std::fs::remove_dir_all(&dir); @@ -616,7 +811,7 @@ mod tests { let held = File::create(dir.join(LOCK_FILE)).unwrap(); held.lock().unwrap(); let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); - write_info(&dir, closed, &generate_token().unwrap()).unwrap(); + write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap(); assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); drop(held); let _ = std::fs::remove_dir_all(&dir); @@ -645,4 +840,79 @@ mod tests { let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); } + + /// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime + /// directory, which every sandbox of the app shares, with the same lock, token and reply. + #[cfg(unix)] + #[test] + fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("socket"); + // Short: a Unix socket path is limited to about 100 bytes (macOS: 104). + let socket = short_socket_path("a"); + let transport = Transport::Socket(socket.clone()); + let primary = match claim_in(&dir, &[], FAST, &transport) { + Launch::Primary(p) => p, + _ => panic!("first launch should be primary"), + }; + assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600); + let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap(); + assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap())); + assert!(info.get("port").is_none()); + let mut ui = primary.connect(&egui::Context::default()); + let paths = ["/home/a/one.psd".to_string()]; + let (d, p, t) = (dir.clone(), paths.clone(), transport.clone()); + let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t)); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]); + assert!(matches!(second.join().unwrap(), Launch::Forwarded)); + // The token still gates the socket. + let (endpoint, _) = read_info(&dir).unwrap(); + assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err()); + primary.shutdown(); + assert!(!socket.exists(), "shutdown removes the socket file"); + // The next primary binds the same path; the old one's drop (a second shutdown) must leave it. + let next = match claim_in(&dir, &[], FAST, &transport) { + Launch::Primary(p) => p, + _ => panic!("the lock is free again"), + }; + drop(primary); + assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket"); + drop(next); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced. + #[cfg(unix)] + #[test] + fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() { + use std::os::unix::fs::FileTypeExt; + let dir = temp_dir("stale-socket"); + let socket = short_socket_path("b"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(socket.parent().unwrap()).unwrap(); + std::fs::write(&socket, b"not a socket").unwrap(); + write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap(); + let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) { + Launch::Primary(p) => p, + Launch::Forwarded => panic!("nothing was listening"), + Launch::Standalone(e) => panic!("{e}"), + }; + assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket()); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(socket.parent().unwrap()); + } + + /// The socket transport is chosen only inside a Flatpak with a runtime directory. + #[cfg(unix)] + #[test] + fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() { + use std::ffi::OsStr; + let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000")); + assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock"))); + assert_eq!(sandbox_socket(None, Some(run)), None); + assert_eq!(sandbox_socket(Some(id), None), None); + assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None); + assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None); + } } diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..023fa49f6a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,7 +82,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa ## One instance per user -On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.lock` in the config directory and listens on a loopback port named in `instance.json` (with a fresh 256-bit token; the file is 0600 on Unix). A later launch hands its file arguments there and exits, so files opened from Explorer or a file manager become tabs in the running window (#3708). The primary answers `ok` only once its UI has taken the files; a launch that gets anything else (the primary is starting, hung or closing) keeps retrying the lock for up to 10 s, becoming the primary itself once the old one lets go, and then starts a separate instance, so a file is never acknowledged and lost. When the event loop ends (and before the CPU-compatibility relaunch) the primary refuses new files and frees the lock. It applies only with the per-user settings folder: a portable copy (`PhotoCraftData` beside the executable) or `PHOTOCRAFT_CONFIG_DIR` may be shared between users, so those launches run separately. So do `--new-instance` (or `PHOTOCRAFT_NEW_INSTANCE=1`), every `--control` launch, and launches given a per-process option (`--safe-gpu`, `--present-mode`, `--frame-latency`, `--stroke-trace`, `--in-window-menus`, `--automation-*-root`). A `cargo run` while an installed PhotoCraft is running forwards to it: pass `--new-instance` or set `PHOTOCRAFT_CONFIG_DIR`. macOS gets the same behaviour from LaunchServices (`apple_events.rs`). On native Wayland the running window can't bring itself forward (that takes the launcher's `XDG_ACTIVATION_TOKEN`, and winit 0.30 can't activate an existing window with one), so it asks for attention instead and the taskbar highlights it; X11, XWayland and Windows bring it to the front. Flatpak needs its own follow-up (#1540). Code: `apps/photocraft/src/single_instance.rs`. +On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.lock` in the config directory and listens on a loopback port named in `instance.json` (with a fresh 256-bit token; the file is 0600 on Unix). Inside a Flatpak it listens on a Unix socket instead, `$XDG_RUNTIME_DIR/app//instance.sock` (0600), because Flatpak isolates each sandbox's network namespace, loopback included, unless the app is granted `--share=network`, while that runtime directory is shared by all sandboxes of the app (#1540); the lock, token and protocol are the same. A later launch hands its file arguments there and exits, so files opened from Explorer or a file manager become tabs in the running window (#3708). The primary answers `ok` only once its UI has taken the files; a launch that gets anything else (the primary is starting, hung or closing) keeps retrying the lock for up to 10 s, becoming the primary itself once the old one lets go, and then starts a separate instance, so a file is never acknowledged and lost. When the event loop ends (and before the CPU-compatibility relaunch) the primary refuses new files and frees the lock. It applies only with the per-user settings folder: a portable copy (`PhotoCraftData` beside the executable) or `PHOTOCRAFT_CONFIG_DIR` may be shared between users, so those launches run separately. So do `--new-instance` (or `PHOTOCRAFT_NEW_INSTANCE=1`), every `--control` launch, and launches given a per-process option (`--safe-gpu`, `--present-mode`, `--frame-latency`, `--stroke-trace`, `--in-window-menus`, `--automation-*-root`). A `cargo run` while an installed PhotoCraft is running forwards to it: pass `--new-instance` or set `PHOTOCRAFT_CONFIG_DIR`. macOS gets the same behaviour from LaunchServices (`apple_events.rs`). On native Wayland the running window can't bring itself forward (that takes the launcher's `XDG_ACTIVATION_TOKEN`, and winit 0.30 can't activate an existing window with one), so it asks for attention instead and the taskbar highlights it; X11, XWayland and Windows bring it to the front. Code: `apps/photocraft/src/single_instance.rs`. ## Graphics startup and device loss From 8caa09c6573b6d1f9a1eebe6b60150d22f71cd95 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 21:23:30 -0600 Subject: [PATCH 0768/1452] Test Fade on a channel after a background filter and a tab switch Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs_tests.rs | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 2bcdda76fc..0c5a392f53 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -322,6 +322,56 @@ fn background_job_finishes_bookkeeping_on_edited_document_after_tab_switch() { assert!(s.is_enabled("edit.fade"), "Fade must be available on the document that was edited"); } +/// #7382: a background filter on a targeted alpha channel or the Quick Mask, finished while another +/// document is shown, leaves Fade on that channel of the edited document: at 0 % it restores the +/// channel exactly and leaves the layer alone. +#[test] +fn background_filter_on_a_channel_fades_that_channel_after_a_tab_switch() { + use crate::edit_menu_cmds::FadeTarget; + for quick_mask in [false, true] { + let mut s = session(32, 24); + let edited_doc = s.active().unwrap().doc.id; + if quick_mask { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + } else { + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + } + // A pattern for the blur to change. + s.edit("pattern", |doc, _| { + let c = if quick_mask { doc.quick_mask.as_mut() } else { doc.channels.first_mut() }; + let c = c.ok_or_else(|| EngineError::Other("no channel".into()))?; + c.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 24), &[0.0]); + c.surface.fill_rect(photocraft_geom::Rect::new(8, 6, 20, 16), &[1.0]); + Ok(()) + }) + .unwrap(); + let channel = |s: &Session, d: usize| { + let doc = &s.documents()[d].doc; + let c = if quick_mask { doc.quick_mask.as_ref() } else { doc.channels.first() }; + let c = c.unwrap(); + (0..24).flat_map(|y| (0..32).map(move |x| (x, y))).map(|(x, y)| c.surface.pixel(x, y)[0]).collect::>() + }; + let layer = |s: &Session| { + let st = &s.documents()[0]; + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(5, 5) + }; + let (pattern, pixels) = (channel(&s, 0), layer(&s)); + let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let event = wait_event(&mut s, id); + assert!(matches!(event.outcome, JobOutcome::Done(_)), "{event:?}"); + assert_ne!(channel(&s, 0), pattern, "quick mask {quick_mask}: the blur changed the channel"); + let fade = s.edit_state.fade.clone().unwrap(); + assert_eq!(fade.doc, edited_doc); + assert_eq!(fade.target, if quick_mask { FadeTarget::QuickMask } else { FadeTarget::Channel(0) }); + assert!(s.set_active(0)); + s.execute("edit.fade", json!({"opacity": 0})).unwrap(); + assert_eq!(channel(&s, 0), pattern, "quick mask {quick_mask}: Fade at 0 % restores the channel"); + assert_eq!(layer(&s), pixels, "quick mask {quick_mask}: the layer is left alone"); + } +} + #[test] fn closing_the_document_cancels_its_job() { let mut s = session(1600, 1200); From afc817d1fae968465bff78a557d0806c499fd292 Mon Sep 17 00:00:00 2001 From: wwewtech Date: Mon, 19 Oct 2026 07:51:51 +0400 Subject: [PATCH 0769/1452] test(engine): a one-shot failure does not spend the counted failpoint --- crates/engine/src/allocation.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index fc59a13730..5d58cfae88 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -66,11 +66,14 @@ pub(crate) fn fail_after_for_test(skip: usize) { } /// Whether a test failpoint trips here: the single-shot flag, or the counted flag -/// once its `skip` attempts have passed cleanly earlier. +/// once its `skip` attempts have passed cleanly earlier. The shot does not advance the +/// counted flag: a one-shot failure is not itself a clean attempt. #[cfg(test)] fn failpoint_hits() -> bool { - let one_shot = FAIL_NEXT.with(|flag| flag.replace(false)); - let after = FAIL_AFTER.with(|flag| match flag.get() { + if FAIL_NEXT.with(|flag| flag.replace(false)) { + return true; + } + FAIL_AFTER.with(|flag| match flag.get() { Some(0) => { flag.set(None); true @@ -80,8 +83,7 @@ fn failpoint_hits() -> bool { false } None => false, - }); - one_shot || after + }) } #[cfg(test)] @@ -104,4 +106,17 @@ mod tests { // The flag is consumed by the trip: later attempts are clean again. assert!(filled::(4, 0, "four").is_ok()); } + + #[test] + fn the_one_shot_does_not_spend_the_counted_failpoint() { + // One-shot and counted flags both armed: the shot trips the first attempt, and that + // failure is not a clean attempt, so the counter still has one to go past (review note + // on #7353). Afterwards the counted attempt trips as usual. + fail_after_for_test(1); + fail_next_for_test(); + assert!(filled::(4, 0, "shot").is_err_and(|e| e.to_string().contains("not enough memory for shot"))); + assert!(filled::(4, 0, "skipped").is_ok(), "the counted attempt has not run out of clean attempts"); + assert!(filled::(4, 0, "counted").is_err_and(|e| e.to_string().contains("not enough memory for counted"))); + assert!(filled::(4, 0, "done").is_ok()); + } } From 5a5de0e73d9d44225c730e9e2f6f3ac5e75fbeb5 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 22:46:14 -0600 Subject: [PATCH 0770/1452] Show Technique and Range in the glow Layer Style pages (#6698) Inner Glow lacked Photoshop's Technique and Range, Outer Glow its Technique, though the engine takes both. The pages now list them in Photoshop 2025's order (Technique before the spread or choke, Range after the contour), the dialog loads a glow's technique so OK keeps it, and both glows' defaults carry Photoshop's factory technique and range. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 6 ++-- crates/ui-egui/src/layer_style.rs | 52 +++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 8933f838b9..bfaa756eb7 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -86,10 +86,10 @@ pub fn effect_defaults(kind: &str) -> Value { json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) } "outerGlow" => { - json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) + json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) } "innerGlow" => { - json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) + json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "source": "edge", "choke": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) } "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), @@ -1007,7 +1007,7 @@ pub fn specs() -> Vec { style_cmd!( "innerGlow", "Inner Glow…", - r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## ), style_cmd!( "stroke", diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..f7be218b6d 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -14,7 +14,7 @@ //! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::effects::{FxPaint, GlowSource, StrokePosition}; +use photocraft_doc::effects::{FxPaint, GlowSource, GlowTechnique, StrokePosition}; use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; @@ -89,6 +89,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { const TECHNIQUE: &[(&str, &str)] = &[("smooth", "Smooth"), ("chiselHard", "Chisel Hard"), ("chiselSoft", "Chisel Soft")]; const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; + const GLOW_TECHNIQUE: &[(&str, &str)] = &[("softer", "Softer"), ("precise", "Precise")]; const CONTOUR: P = P::Contour(Some("antiAlias")); match kind { "dropShadow" => &[ @@ -116,24 +117,28 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("contour", "Contour", CONTOUR), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], + // Photoshop's order (#6698): Technique before the spread or choke, Range after the contour. "outerGlow" => &[ ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), + ("technique", "Technique", P::Choice(GLOW_TECHNIQUE)), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), - ("range", "Range", P::Slider(1.0, 100.0, "%")), ("contour", "Contour", CONTOUR), + ("range", "Range", P::Slider(1.0, 100.0, "%")), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], "innerGlow" => &[ ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), + ("technique", "Technique", P::Choice(GLOW_TECHNIQUE)), ("source", "Source", P::Choice(SRC)), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("contour", "Contour", CONTOUR), + ("range", "Range", P::Slider(1.0, 100.0, "%")), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], "stroke" => &[ @@ -243,6 +248,7 @@ fn values_of(e: &Effect, light: f32) -> Value { } else { drop_keys(&mut v, &["color"]); } + set(&mut v, "technique", json!(if g.technique == GlowTechnique::Precise { "precise" } else { "softer" })); set(&mut v, if matches!(e, Effect::OuterGlow(_)) { "spread" } else { "choke" }, json!((g.spread * 100.0).round())); set(&mut v, "size", json!(g.size)); set(&mut v, "range", json!((g.range * 100.0).round())); @@ -1886,6 +1892,48 @@ mod tests { assert_eq!(source(&s), Some(GlowSource::Center), "OK keeps the source"); } + /// #6698: Inner Glow lacked Photoshop's Technique and Range, Outer Glow its Technique. They + /// show in Photoshop's order, the dialog loads them, and OK keeps them. + #[test] + fn glows_show_and_keep_technique_and_range() { + let keys = |kind: &str| spec(kind).iter().map(|(k, _, _)| *k).collect::>(); + assert_eq!(keys("innerGlow"), ["blend", "opacity", "color", "technique", "source", "choke", "size", "contour", "range", "noise"]); + assert_eq!(keys("outerGlow"), ["blend", "opacity", "color", "technique", "spread", "size", "contour", "range", "noise"]); + let mut s = session(); + s.execute("layer.layerStyle.innerGlow", json!({"technique": "precise", "range": 30})).unwrap(); + s.execute("layer.layerStyle.outerGlow", json!({"technique": "precise"})).unwrap(); + let glows = |s: &photocraft_engine::Session| { + let d = s.active().unwrap(); + let id = d.active_layer.unwrap(); + d.doc + .layer(id) + .unwrap() + .effects + .items + .iter() + .filter_map(|e| match e { + Effect::InnerGlow(g) | Effect::OuterGlow(g) => Some((kind_of(e), g.technique, (g.range * 100.0).round())), + _ => None, + }) + .collect::>() + }; + let expected = [("outerGlow", GlowTechnique::Precise, 50.0), ("innerGlow", GlowTechnique::Precise, 30.0)]; + let mut have = glows(&s); + have.sort_by_key(|g| g.0); + let mut want = expected.to_vec(); + want.sort_by_key(|g| g.0); + assert_eq!(have, want); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let inner = effects_of(&f).iter().find(|e| e["kind"] == json!("innerGlow")).unwrap().clone(); + assert_eq!((&inner["params"]["technique"], &inner["params"]["range"]), (&json!("precise"), &json!(30.0)), "the dialog shows them"); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let mut have = glows(&s); + have.sort_by_key(|g| g.0); + assert_eq!(have, want, "OK keeps them"); + } + #[test] fn drop_shadow_angle_reaches_the_effect() { // #350: the Angle slider was dead — the dialog never sent `useGlobalLight`, so the engine From a42e831698eb495a39a8c7226ab1a59b76062e23 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 23:01:11 -0600 Subject: [PATCH 0771/1452] Map the bevel's shading through its gloss contour, not its light (#7360) The gloss contour was applied to the highlight and shadow amounts after the lighting split, so a contour that starts high (Rolling Slope - Descending) turned every unlit pixel into a full highlight: a bevelled glyph's flat face painted (255, 40, 42) over its (151, 22, 23) fill. It now maps the shading before the split, with the flat face's mapped shading as the neutral, on the CPU and the GPU. Linear is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/effects.rs | 17 ++++++------ crates/compose/src/tests.rs | 50 +++++++++++++++++++++++++++++++++++ crates/gpu/src/compose.wgsl | 7 ++--- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..d7bc223b70 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -998,12 +998,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) { fn apply_contour(m: Map, c: &Contour) -> Map { match contour_lut(c) { None => m, - Some(l) => m.map(|v| { - let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32; - let i = x.floor() as usize; - let j = (i + 1).min(l.len() - 1); - l[i] + (l[j] - l[i]) * (x - i as f32) - }), + Some(l) => m.map(|v| lut_at(&l, v)), } } @@ -1623,6 +1618,12 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let on_bevel = |x: i64, y: i64| { hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS)) }; + // The gloss contour remaps the shading `n · light` before it splits into highlight (above + // what it maps a flat face's `sin(altitude)` to) and shadow. Applied to the highlight and shadow amounts + // instead, a contour that starts high (Rolling Slope - Descending) lit every pixel the light + // missed, so a bevelled glyph's flat face took the full highlight (#7360) where Photoshop + // leaves it at the layer's colour. The Linear contour changes nothing either way. + let gloss = contour_lut(&b.gloss_contour); let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) { let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); for y in 0..shape.h as i64 { @@ -1634,7 +1635,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; - let k = shade - se; + let k = gloss.as_ref().map_or(shade - se, |l| lut_at(l, shade) - lut_at(l, se)); if k > 0.0 { hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; } else { @@ -1642,7 +1643,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat } } } - (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) + (hi, sh) }; let maps = match g.paint { BevelPaint::Inner | BevelPaint::Outer => { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..d77afda731 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1138,6 +1138,56 @@ fn satin_and_bevel_stay_inside_shape() { assert!(top[1] > bot[1], "{top:?} {bot:?}"); } +/// #7360: a gloss contour that starts high (Rolling Slope - Descending, as the reporter's PSD +/// uses) left a bevelled shape's flat face at the full Overlay highlight, (255, 40, 42) on a +/// (151, 22, 23) fill, where Photoshop leaves the face at the fill colour. The contour maps the +/// shading, so the flat face stays neutral and only the slopes are shaded. +#[test] +fn a_gloss_contour_leaves_the_flat_face_of_a_bevel_unlit() { + let fill = [151.0 / 255.0, 22.0 / 255.0, 23.0 / 255.0, 1.0]; + let rolling = photocraft_doc::Contour::Custom { + name: "Rolling Slope - Descending".into(), + points: [(0.0078, 1.0), (0.2078, 0.3922), (0.3804, 0.4196), (0.7020, 0.6), (0.9882, 0.0)] + .iter() + .map(|&(input, output)| CurvePoint { input, output }) + .collect(), + corners: vec![], + }; + let render = |gloss_contour: photocraft_doc::Contour| { + let mut d = doc_white(40, 40); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), fill); + l.effects.items = vec![Effect::BevelEmboss(photocraft_doc::Bevel { + enabled: true, + style: photocraft_doc::BevelStyle::InnerBevel, + technique: photocraft_doc::BevelTechnique::ChiselHard, + depth: 1.47, + up: false, + size: 3.0, + soften: 0.0, + angle: 90.0, + altitude: 30.0, + use_global_light: false, + gloss_contour, + highlight: FxCommon::new(photocraft_color::BlendMode::Overlay, 1.0), + highlight_color: Color::WHITE, + shadow: FxCommon::new(photocraft_color::BlendMode::Overlay, 0.16), + shadow_color: Color::BLACK, + contour: None, + texture: None, + })]; + d.layers.push(l); + d + }; + let d = render(rolling); + for (x, y) in [(20, 20), (16, 22), (24, 16)] { + let p = px(&d, x, y); + assert!(close4(p, fill), "flat face ({x},{y}) keeps the fill: {:?}", p.map(|v| (v * 255.0).round())); + } + let linear = render(photocraft_doc::Contour::Linear); + assert!(close4(px(&linear, 20, 20), fill), "Linear leaves the flat face too"); + assert!(!close4(px(&d, 20, 11), fill), "the slopes are still shaded"); +} + #[test] fn clipped_layer_effects_are_clipped_to_base() { let mut d = doc_white(40, 40); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..ee225c75fc 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1329,7 +1329,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4 { } // One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for -// height-gradient scale `depth`, times `region`, through the gloss contour. +// height-gradient scale `depth`, times `region`, with the shading through the gloss contour. fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth; let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth; @@ -1337,14 +1337,15 @@ fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let len = sqrt(n.x * n.x + n.y * n.y + 1.0); let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len; let se = op.p0.w; - let k = shade - se; + var k = shade - se; + // effects::bevel_maps: the gloss contour maps the shading, a flat face's as the neutral (#7360). + if (op.p1.w > 0.5) { k = lut(0, shade) - lut(0, se); } var v = 0.0; if (op.p1.z > 0.5) { if (k <= 0.0) { v = clamp(-k / max(se, 1e-3), 0.0, 1.0) * region; } } else if (k > 0.0) { v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region; } - if (op.p1.w > 0.5) { v = lut(0, v); } return v; } From 0c27aea23a93cf7f9418dcbdb79b48d6ab3c4776 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 23:12:13 -0600 Subject: [PATCH 0772/1452] Open the selection menu on a Quick Selection right-click (#7268) A right-click with the Quick Selection tool opened the Brush Preset picker; Photoshop opens the selection menu, as for the other selection tools. The canvas menu now covers Quick Selection, and the right-click opens the picker only for tools without one. The options-bar brush chip still opens the picker. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas_menu_tests.rs | 41 ++++++++++++++++++++++--- crates/ui-egui/src/canvas_tool_menu.rs | 3 ++ crates/ui-egui/src/paint_mouse.rs | 12 ++++++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index 839dc1f765..2e211ca083 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -119,7 +119,18 @@ fn check_reopening(tool: Tool, modifiers: Modifiers, popup: Popup) { #[test] fn secondary_taps_keep_selection_menus_open() { - for tool in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso, Tool::MagicWand, Tool::ObjectSelection] { + // Quick Selection too, though it has a brush: Photoshop's right-click there is the selection + // menu (#7268), the brush is set from the options bar. + for tool in [ + Tool::RectMarquee, + Tool::EllipseMarquee, + Tool::Lasso, + Tool::PolygonLasso, + Tool::MagneticLasso, + Tool::MagicWand, + Tool::ObjectSelection, + Tool::QuickSelection, + ] { check_reopening(tool, Modifiers::NONE, Popup::Tool); } } @@ -229,7 +240,8 @@ fn control_channel_opening_uses_the_same_dismissal_guard() { (Tool::RectMarquee, Popup::Tool, true), (Tool::Brush, Popup::Brush, false), (Tool::Eraser, Popup::Brush, false), - (Tool::QuickSelection, Popup::Brush, false), + // Its right-click is the selection menu (#7268). + (Tool::QuickSelection, Popup::Tool, false), ] { for scale in [1.0, 2.0] { let mut h = harness(tool, true, scale); @@ -266,14 +278,14 @@ fn control_channel_right_click_on_committed_type_opens_the_type_layer_menu() { #[test] fn brush_picker_taps_preserve_pixels_selection_and_history_after_reopening() { - for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { + for tool in [Tool::Brush, Tool::Eraser] { check_reopening(tool, Modifiers::NONE, Popup::Brush); } } #[test] fn brush_chip_toggles_after_canvas_and_control_opening() { - for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { + for tool in [Tool::Brush, Tool::Eraser] { for scale in [1.0, 2.0] { let mut h = harness(tool, true, scale); let before = document_state(&h); @@ -299,6 +311,27 @@ fn brush_chip_toggles_after_canvas_and_control_opening() { } } +/// #7268: the Quick Selection tool's brush chip still opens and closes the Brush Preset picker; +/// only its canvas right-click is the selection menu. +#[test] +fn quick_selection_chip_toggles_the_picker_and_right_click_opens_the_selection_menu() { + let mut h = harness(Tool::QuickSelection, true, 1.0); + let before = document_state(&h); + for _ in 0..2 { + h.get_by_label(crate::i18n::t("Brush Preset picker")).click(); + h.run_steps(3); + assert!(h.state().ui.brush_picker.is_some(), "the chip opens the picker"); + h.get_by_label(crate::i18n::t("Brush Preset picker")).click(); + h.run_steps(3); + assert!(h.state().ui.brush_picker.is_none(), "the chip closes it"); + } + let at = fractional_point(&h, 1.0); + tap(&mut h, at, Modifiers::NONE, true); + assert!(h.state().ui.brush_picker.is_none(), "no picker on a right-click"); + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.has_selection), "the selection menu"); + assert!(document_state(&h) == before); +} + #[test] fn brush_preset_menu_action_outside_the_picker_keeps_it_open() { let mut h = harness(Tool::Brush, true, 1.0); diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index f7f9892d2f..024a81c1b5 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -315,6 +315,9 @@ pub fn applies(tool: Tool) -> bool { | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection + // Its brush is set from the options bar's chip; the canvas right-click is the + // selection menu, as in Photoshop (#7268). + | Tool::QuickSelection ) || path_menu(tool) } diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a45c016c59..43b10d2b2f 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -86,11 +86,17 @@ pub struct Buttons { pub clicked: bool, } -/// Tools whose right-click opens the Brush Preset picker (those with the options-bar brush chip). +/// Tools with the options-bar brush chip, which opens the Brush Preset picker. pub fn has_brush_picker(tool: Tool) -> bool { tool.is_brushlike() || tool == Tool::QuickSelection } +/// Tools whose canvas right-click opens the Brush Preset picker: the painting tools. The Quick +/// Selection tool has the picker too, but its right-click opens the selection menu (#7268). +pub fn right_click_picker(tool: Tool) -> bool { + has_brush_picker(tool) && !crate::canvas_tool_menu::applies(tool) +} + /// Does a right-button drag with `tool` erase (rather than open the picker)? pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { matches!(tool, Tool::Brush | Tool::Eraser) && app.session.prefs().tools.right_click_with_painting_tools == RightClickPaint::Erase @@ -119,7 +125,7 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) if right_click && !erase && !layer_menu - && has_brush_picker(tool) + && right_click_picker(tool) && let Some(p) = response.interact_pointer_pos() { crate::brush_picker::open(&mut app.ui, &response.ctx, [p.x, p.y]); @@ -149,7 +155,7 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo app.secondary_erase = down; return true; } - if down && has_brush_picker(tool) { + if down && right_click_picker(tool) { crate::brush_picker::open(&mut app.ui, ctx, at); } false From f99e12ecbdf486dc19192ec0a3212f40f13f5362 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 18 Oct 2026 23:49:40 -0600 Subject: [PATCH 0773/1452] Carry on an open path from its end with the Pen (#6593) A Pen click on an endpoint of a finished open path started a new subpath there; Photoshop carries on drawing that path. A press on the first or last anchor of an open subpath of the selected (or work) path now resumes it, reversed from its first anchor, and the commit replaces that subpath in one step. Shape mode and a targeted vector mask, which take new paths, are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 40 +++++++++++++++ crates/ui-egui/src/vector_ui.rs | 64 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index eae88384fe..1992b07c8d 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -169,3 +169,43 @@ fn new_pens_with_command_edit_paths_as_direct_selection() { assert_eq!(app.ui.tool, tool); } } + +/// #6593: a Pen press on an endpoint of an open path carries on drawing it, as in Photoshop, +/// from its last anchor or (reversed) its first; the commit extends that subpath in one step +/// instead of adding a second one. Elsewhere the press starts a new subpath as before. +#[test] +fn the_pen_carries_on_an_open_path_from_either_end() { + let mut app = app(Tool::Pen); + let anchors = |app: &PhotocraftApp| -> Vec> { + work_path(app).subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect()).collect() + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[20.0, 20.0], [80.0, 20.0]]]); + // From the last anchor. + let before = steps(&app); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]], "the same subpath, extended"); + assert_eq!(steps(&app), before + 1, "one History step"); + // From the first anchor: the subpath runs the other way and carries on past it. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new subpath next to it. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app).len(), 2, "a second subpath"); + // Closing on the far end of a resumed path closes that subpath. + click(&mut app, 200.0, 150.0); + click(&mut app, 175.0, 190.0); + click(&mut app, 150.0, 150.0); + assert!(app.ui.pen.is_none(), "closed and committed"); + let work = work_path(&app); + assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..111ee6fd90 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -33,6 +33,10 @@ pub struct PenPath { /// Curvature Pen: the anchor being dragged. #[serde(skip)] pub moving: Option, + /// Drawing carries on from an endpoint of this open subpath (path name, subpath index) of a + /// document path (#6593): the commit replaces that subpath instead of adding one. + #[serde(default)] + pub resumes: Option<(String, usize)>, } impl PenPath { @@ -223,6 +227,13 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; + // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the + // press then acts on its last anchor, so a drag pulls out the next segment's handle. + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) + && let Some(resumed) = resume_at(app, x, y, tol) + { + app.ui.pen = Some(resumed); + } let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) @@ -454,6 +465,12 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) + } else if let Some(p) = + pen.resumes.as_ref().and_then(|(name, i)| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, name, *i, &path)).map(|p| (name.clone(), p))) + { + // The resumed subpath, carried on, takes its old place: one step, no second subpath. + let (name, p) = p; + app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. let mut p = photocraft_engine::vector_cmds::path_json(&existing); @@ -481,7 +498,52 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { /// Does `doc` have the path the Paths panel calls `name`: "work", or a saved path's name? fn path_exists(doc: &Document, name: &str) -> bool { - if name == "work" { doc.work_path.is_some() } else { doc.paths.iter().any(|p| p.name == name) } + doc_path(doc, name).is_some() +} + +/// The path the Paths panel calls `name`: "work", or a saved path's name. +fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { + if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } +} + +/// The open subpath of the path a new Pen path would join (the one selected in the Paths panel, +/// else the work path) that has its first or last anchor at `(x, y)`, as a Pen path to carry on +/// drawing from there (#6593). Its knots run towards that anchor: reversed, handles swapped, when +/// it is the first. Shape mode and a targeted vector mask take new paths, so they resume nothing. +fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { + if app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some() { + return None; + } + let st = app.session.active()?; + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; + doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { + let reversed = if s.knots.last().is_some_and(near) { + false + } else if s.knots.first().is_some_and(near) { + true + } else { + return None; + }; + let mut knots: Vec<_> = s.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); + if reversed { + knots.reverse(); + for (k, _) in &mut knots { + k.swap(1, 2); + } + } + let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); + Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some((name.clone(), i)), ..Default::default() }) + }) +} + +/// `name`'s path with its subpath `index` replaced by the one subpath of `drawn`, for a resumed +/// Pen path's commit; `None` when that subpath is gone. +fn with_resumed_subpath(doc: &Document, name: &str, index: usize, drawn: &Value) -> Option { + let mut p = photocraft_engine::vector_cmds::path_json(doc_path(doc, name)?); + let slot = p.get_mut("subpaths")?.as_array_mut()?.get_mut(index)?; + *slot = drawn.get("subpaths")?.get(0)?.clone(); + Some(p) } // --------------------------------------------------------------------------------------------- From cd440429211973d6d010ae984af31b4f8629820c Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 00:06:20 -0600 Subject: [PATCH 0774/1452] Pen resume: keep the subpath's operation, add a stale draft as a new subpath A resumed subpath takes only the drawn anchors and closing, so its path operation survives. The draft remembers its document and the subpath as it was; if either changed before the commit, the drawing is added as a new subpath instead of overwriting another one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 23 ++++++++++++ crates/ui-egui/src/vector_ui.rs | 46 +++++++++++++++++------- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 1992b07c8d..1f786c939a 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -209,3 +209,26 @@ fn the_pen_carries_on_an_open_path_from_either_end() { let work = work_path(&app); assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); } + +/// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed +/// meanwhile is added as a new subpath instead of overwriting another one. +#[test] +fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { + let mut app = app(Tool::Pen); + let open = |ax: f64, bx: f64, op: &str| json!({"closed": false, "op": op, "knots": [{"anchor": [ax, 20.0]}, {"anchor": [bx, 20.0]}]}); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(20.0, 80.0, "subtract")]}})).unwrap(); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!((work.subpaths.len(), work.subpaths[0].knots.len()), (1, 3)); + assert_eq!(work.subpaths[0].op, photocraft_doc::vector::PathOp::Subtract, "the operation is kept"); + // The work path is replaced while the draft is open: the draft becomes a new subpath. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 180.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 2, "a new subpath, the replaced one untouched"); + assert_eq!(work.subpaths[0].knots.iter().map(|k| k.anchor.x).collect::>(), [120.0, 180.0]); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 111ee6fd90..566164e5c1 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -33,10 +33,21 @@ pub struct PenPath { /// Curvature Pen: the anchor being dragged. #[serde(skip)] pub moving: Option, - /// Drawing carries on from an endpoint of this open subpath (path name, subpath index) of a - /// document path (#6593): the commit replaces that subpath instead of adding one. + /// Drawing carries on from an endpoint of this open subpath of a document path (#6593): the + /// commit replaces that subpath instead of adding one. #[serde(default)] - pub resumes: Option<(String, usize)>, + pub resumes: Option, +} + +/// The subpath a Pen path carries on: where it is and what it was when drawing resumed. The +/// commit replaces it only while it is still that subpath of that document; otherwise (another +/// document, or the path changed meanwhile) the drawing is added as a new subpath. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Resumed { + pub doc: u64, + pub path: String, + pub index: usize, + pub was: photocraft_doc::vector::Subpath, } impl PenPath { @@ -465,11 +476,10 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) - } else if let Some(p) = - pen.resumes.as_ref().and_then(|(name, i)| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, name, *i, &path)).map(|p| (name.clone(), p))) + } else if let Some((name, p)) = + pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r.path.clone(), p))) { // The resumed subpath, carried on, takes its old place: one step, no second subpath. - let (name, p) = p; app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. @@ -515,6 +525,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { return None; } let st = app.session.active()?; + let doc = st.doc.id.0; let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { @@ -533,16 +544,25 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } } let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some((name.clone(), i)), ..Default::default() }) + let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) }) } -/// `name`'s path with its subpath `index` replaced by the one subpath of `drawn`, for a resumed -/// Pen path's commit; `None` when that subpath is gone. -fn with_resumed_subpath(doc: &Document, name: &str, index: usize, drawn: &Value) -> Option { - let mut p = photocraft_engine::vector_cmds::path_json(doc_path(doc, name)?); - let slot = p.get_mut("subpaths")?.as_array_mut()?.get_mut(index)?; - *slot = drawn.get("subpaths")?.get(0)?.clone(); +/// The resumed path with its subpath carried on: the one subpath of `drawn` takes the old one's +/// anchors and closing, which keeps its other settings (its path operation). `None` when the +/// subpath is no longer the one drawing resumed (another document, or the path changed). +fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option { + let path = doc_path(doc, &r.path).filter(|_| doc.id.0 == r.doc)?; + if path.subpaths.get(r.index) != Some(&r.was) { + return None; + } + let mut p = photocraft_engine::vector_cmds::path_json(path); + let slot = p.get_mut("subpaths")?.as_array_mut()?.get_mut(r.index)?.as_object_mut()?; + let new = drawn.get("subpaths")?.get(0)?; + for key in ["closed", "knots"] { + slot.insert(key.into(), new.get(key)?.clone()); + } Some(p) } From 199b0a122c1c7099f155a5221b50330cbf15713a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 00:21:34 -0600 Subject: [PATCH 0775/1452] Pen resume: a stale draft joins its source path; undo stops at its anchors A draft whose subpath changed meanwhile is added to the path it came from even with nothing selected in the Paths panel, instead of replacing the Work Path. Undoing points in a resumed draft never removes the path's own anchors: past the added ones it drops the draft. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 25 ++++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 12 +++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 1f786c939a..257e4f591e 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -231,4 +231,29 @@ fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { let work = work_path(&app); assert_eq!(work.subpaths.len(), 2, "a new subpath, the replaced one untouched"); assert_eq!(work.subpaths[0].knots.iter().map(|k| k.anchor.x).collect::>(), [120.0, 180.0]); + // The same with nothing selected in the Paths panel: still added to the Work Path it came from. + click(&mut app, 180.0, 20.0); + click(&mut app, 180.0, 90.0); + app.ui.selected_path = None; + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 160.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "added, not replacing the Work Path"); +} + +/// #6593 review: undoing points in a resumed draft never removes the path's own anchors; past the +/// added ones it drops the draft and the path stays as it was. +#[test] +fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { + let mut app = app(Tool::Pen); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let before = work_path(&app); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + assert!(crate::vector_ui::pen_undo_last_point(&mut app), "takes back the added anchor"); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + assert!(crate::vector_ui::pen_undo_last_point(&mut app), "drops the draft"); + assert!(app.ui.pen.is_none()); + assert_eq!(work_path(&app), before, "the path is as it was"); } diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 566164e5c1..0161703c87 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -308,6 +308,12 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { let Some(pen) = app.ui.pen.as_mut() else { return false }; + // A resumed path's own anchors are not the draft's to take back: undoing past the ones added + // since it resumed drops the draft and leaves the path as it was (#6593). + if pen.resumes.as_ref().is_some_and(|r| pen.knots.len() <= r.was.knots.len()) { + app.ui.pen = None; + return true; + } if pen.knots.pop().is_none() { return false; } @@ -492,7 +498,11 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // Photoshop adds the new subpath to the path selected in the Paths panel, which is the // Work Path right after drawing one, so path after path builds up there (#4721). With no // path selected, a new Work Path replaces the old one. The drawn path ends up selected. - let target = app.ui.selected_path.clone().filter(|n| app.session.active().is_some_and(|st| path_exists(&st.doc, n))); + // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new + // subpath, even with nothing selected in the Paths panel. + let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); + let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + let target = app.ui.selected_path.clone().filter(exists).or_else(|| resumed_from.filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); if target.is_some() { From dfb84a686d9358348542979e002f943a06cb9e6e Mon Sep 17 00:00:00 2001 From: denstu473 <63150090+denstu473@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:25:00 +0200 Subject: [PATCH 0776/1452] ci: trigger checks with current contributor status From 00697fb7779988759fcbd16b0dde29e10beaef80 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 00:25:38 -0600 Subject: [PATCH 0777/1452] Move typed text with a drag from just outside it (#5956) While typing, a press outside the text committed it. Photoshop moves the text instead when the press is just outside it (measured: 35 pt off the glyphs moves it, 48 pt commits), and typing carries on. A plain press within 40 pt of the text frame now starts the existing type transform as a move, and the cursor shows Move there; further out a press commits as before, and a paragraph box's handles still resize it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool_tests.rs | 31 +++++++++++++++ crates/ui-egui/src/type_transform.rs | 55 +++++++++++++++++++++------ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 20e59a01d6..09aaff253e 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -777,6 +777,37 @@ fn assert_affine(a: Affine, b: Affine) { } } +/// #5956: while typing, a plain drag from just outside the text moves it (Photoshop's Move cursor +/// there), and typing carries on; a press further out commits the text, as before. +#[test] +fn a_drag_just_outside_the_typed_text_moves_it() { + let (app, id) = transform_app(false, false, Affine::translate(500.0, 350.0)); + let mut h = harness(1.0, app); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let zoom = f64::from(h.state().point_zoom()); + let mid = (f.quad[1][1] + f.quad[2][1]) / 2.0; + let near = frame_point(h.state(), [f.quad[1][0] + 20.0 / zoom, mid]); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + let cursor = crate::type_transform::cursor(h.state_mut(), &ctx, &view, near, Modifiers::NONE); + assert_eq!(cursor, Some(egui::CursorIcon::Move), "the Move cursor just outside the text"); + let before = text(h.state(), id); + let count = h.state().session.active().unwrap().history.entries().len(); + transform_drag(&mut h, near, near + egui::vec2(60.0, 30.0), Modifiers::NONE, false); + let after = text(h.state(), id); + assert_affine(after.transform, Affine::translate(60.0 / zoom, 30.0 / zoom).mul(&before.transform)); + assert_eq!(after.text, before.text); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| e.layer), Some(id.0), "still typing"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1); + // Further out: the press commits the text. + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let far = frame_point(h.state(), [f.quad[1][0] + 100.0 / zoom, (f.quad[1][1] + f.quad[2][1]) / 2.0]); + let view = xf(h.state()); + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, far, Modifiers::NONE), None); + click(&mut h, far); + assert!(h.state().ui.text_edit.is_none(), "committed"); + assert_affine(text(h.state(), id).transform, after.transform); +} + /// #1091: modifier changes alone draw/hide the oriented frame without touching editing/history. #[test] fn temporary_type_transform_visibility_and_cursors() { diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 0c1b4837ed..e139edcf64 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -17,6 +17,10 @@ use crate::transform_tool::{Gesture, Hit}; const PREVIEW_BASE: u64 = 1 << 37; const ROTATE_PX: f64 = 28.0; +/// How far outside the text being typed a plain press moves it, as the Move tool (#5956), in +/// screen points. Photoshop 2025, measured at 100 %: a drag from 35 pt off the glyphs moved the +/// text without ending typing, one from 48 pt committed it instead. +const MOVE_REACH_PT: f64 = 40.0; /// Cached rendered preview and bounded damage history. Unchanged pointer positions render nothing. pub(crate) struct Preview { @@ -140,6 +144,28 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { }) } +/// The text's frame when `p` is just outside it, where a plain press moves the text (#5956). +fn move_zone(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { + let t = frame(app)?; + let reach = MOVE_REACH_PT / f64::from(app.point_zoom().max(0.01)); + (crate::transform_tool::hit(&t, p, 0.0) == Hit::Outside && near_border(&t, p, reach)).then_some(t) +} + +/// Start a temporary drag of `frame` from `p` on `h`. Returns false when it can't start. +fn start(app: &mut PhotocraftApp, mut frame: TransformSession, h: Hit, p: [f64; 2]) -> bool { + let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return false }; + let Some(st) = app.session.active() else { return false }; + let (document, revision) = (st.doc.id.0, st.revision); + frame.session = app.ui.alloc_id(); + let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot }; + app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture }); + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.dragging = false; + ed.resize = None; + } + true +} + fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option { let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)); (h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.point_zoom().max(0.01)))).then_some(h) @@ -242,20 +268,22 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) - if !p.iter().all(|v| v.is_finite()) { return true; } - let Some(mut frame) = frame(app) else { return true }; + let Some(frame) = frame(app) else { return true }; let Some(h) = hit(app, &frame, p) else { return true }; - let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return true }; - let Some(st) = app.session.active() else { return true }; - let (document, revision) = (st.doc.id.0, st.revision); - frame.session = app.ui.alloc_id(); - let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot }; - app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture }); - if let Some(ed) = app.ui.text_edit.as_mut() { - ed.dragging = false; - ed.resize = None; - } + start(app, frame, h, p); true } + // Just outside the text, a plain press moves it as the Move tool would, without ending + // typing, as in Photoshop (#5956). Further out the press commits (`type_tool`), and a + // paragraph box's handles still resize it. + ToolEvent::Down { x, y, .. } if x.is_finite() && y.is_finite() => { + let id = app.ui.text_edit.as_ref().map(|e| LayerId(e.layer)); + if id.is_some_and(|id| crate::type_tool::box_handle_at(app, id, x, y).is_some()) { + return false; + } + let Some(frame) = move_zone(app, [x, y]) else { return false }; + start(app, frame, Hit::Inside, [x, y]) + } ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } if active(app) => { update(app, [x, y], mods); if matches!(ev, ToolEvent::Up { .. }) { @@ -380,7 +408,10 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor if !visible(app, mods) { let id = LayerId(app.ui.text_edit.as_ref()?.layer); let q = xf.to_doc(p); - let handle = crate::type_tool::box_handle_at(app, id, q[0], q[1])?; + let Some(handle) = crate::type_tool::box_handle_at(app, id, q[0], q[1]) else { + // Just outside the text a press moves it (#5956): the Move cursor, as in Photoshop. + return move_zone(app, q).map(|_| CursorIcon::Move); + }; let t = frame(app)?; let direction = if handle < 4 { let a = *t.quad.get(usize::from(handle))?; From be9f0c250a19891496ccd71942bc200964627c52 Mon Sep 17 00:00:00 2001 From: Ashsajal Date: Mon, 19 Oct 2026 12:35:45 +0600 Subject: [PATCH 0778/1452] File Info: print a slow shutter speed as the fraction a camera writes (#6958) The `1/n s` test in `exposure_text` was relative to the reciprocal, so the tolerance shrank as the exposure slowed. Under 1/50 s the reciprocal drops below 50, which makes 1 % of it smaller than half a rounding step, and nearly every fraction fell through to the decimal form: a stored 0.031714 s printed as "0.03 s" (5.4 % off) and 0.0215 s as "0.02 s" (7 % off). Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the shown time within 5 % of the stored one and is the form a photographer expects. At or above 1/10 s the existing near-whole rule is kept, so 0.5 s is still "1/2 s" and 0.4 s still "0.4 s". The decimal fallback moves from two decimals to three significant digits ("0.105 s", not "0.11 s"). The two fixtures from the report now read "1/32 s" and "1/47 s" through `photocraft-cli run ... --cmd file.fileInfo`. --- crates/engine/src/file_cmds.rs | 26 +++++++++++++-- crates/engine/src/file_cmds/tests.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 1fb3fcf300..e86e4252a7 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -758,6 +758,18 @@ fn short_num(v: f64, decimals: usize) -> String { if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } +/// `v` to three significant digits, with no trailing zeros (0.031714 → "0.0317", 0.4 → "0.4"). +/// Two decimals threw away most of a slow shutter speed: a stored 0.031714 s printed as "0.03 s", +/// which is 5 % off, and a stored 0.0215 s printed as "0.02 s", 7 % off (#6958). +fn sig3(v: f64) -> String { + let magnitude = v.abs().log10().floor(); + // Clamped at both ends: a value at the large end must not ask for a negative field width, and + // at the small end the field is widened rather than truncated, so a positive exposure never + // reads as "0 s". 320 is beyond f64's decimal range, so it always terminates. + let decimals = if magnitude.is_finite() { (2.0 - magnitude).clamp(0.0, 320.0) } else { 0.0 }; + short_num(v, decimals as usize) +} + /// Exposure time as cameras print it: "1/250 s" for fractions of a second, "0.4 s" or "2 s" /// when the reciprocal isn't a whole number or the exposure is a second or longer. fn exposure_text(t: f64) -> Option { @@ -765,10 +777,18 @@ fn exposure_text(t: f64) -> Option { return None; } let r = 1.0 / t; - if t < 1.0 && (r - r.round()).abs() <= r * 0.01 { - return Some(format!("1/{} s", r.round())); + let n = r.round(); + // Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the shown time + // within 5 % of the stored one, and is the form a photographer expects ("1/32 s"). The test + // used to be relative to the reciprocal, so it shrank as the exposure slowed: under 1/50 s it + // fell below half a rounding step and nearly every fraction fell through to the decimal form + // (#6958). Above 1/10 s the fraction is only right when the reciprocal is very nearly whole + // (1/2 s, 1/4 s) — 0.4 s stays "0.4 s" rather than becoming "1/3 s". + let fraction = n.is_finite() && (1.0..=1e9).contains(&n) && (t < 0.1 || (t < 1.0 && (r - n).abs() <= r * 0.01)); + if fraction { + return Some(format!("1/{} s", n as u64)); } - Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 }))) + Some(format!("{} s", sig3(t))) } /// EXIF (`2024:02:17 12:40:19`) or XMP (`2024-02-17T12:40:19.00+01:00`) dates as diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3ab50c4a77..bd8ccc9925 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -494,6 +494,54 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() { assert_eq!(camera_data(Some(b"II*\0\xff\xff\xff\xff"), Some("0/0 exif:ExposureTime=\"-1/0\"")), json!({})); } +/// A slow shutter speed whose reciprocal is not whole prints as the nearest fraction a camera +/// would write, not as two decimals. The old 1 %-of-the-reciprocal test shrank as the exposure +/// slowed, so below 1/50 s it fell below half a rounding step and nearly every such fraction fell +/// through to the decimal form (#6958). +#[test] +fn camera_data_prints_a_slow_exposure_as_the_fraction_a_camera_writes() { + let text = |t: f64| exposure_text(t).expect("an exposure"); + // The two values from the report: 31714/1000000 s and 215/10000 s. + assert_eq!(text(0.031_714), "1/32 s", "a stored 0.031714 s is about 1/32 s"); + assert_eq!(text(0.0215), "1/47 s", "a stored 0.0215 s is about 1/47 s"); + // The rest of a slow shutter scale, and the exact reciprocals that already worked. + assert_eq!(text(0.125), "1/8 s"); + assert_eq!(text(1.0 / 60.0), "1/60 s"); + assert_eq!(text(1.0 / 4000.0), "1/4000 s"); + assert_eq!(text(0.04), "1/25 s"); + assert_eq!(text(0.09), "1/11 s"); + // Every shown time stays within 5 % of the stored one — the point of the report. + for t in [0.031_714, 0.0215, 0.04, 0.09, 0.125, 1.0 / 60.0, 1.0 / 4000.0] { + let n: f64 = text(t).split('/').nth(1).and_then(|d| d.split(' ').next()).expect("1/n s").parse().expect("a number"); + assert!((1.0 / n / t - 1.0).abs() <= 0.05, "{t} shown as {} is more than 5 % off", text(t)); + } + // A tenth of a second and longer keeps the Photoshop forms: a near-whole reciprocal is a + // fraction, anything else is the decimal at three significant digits. + assert_eq!(text(0.5), "1/2 s"); + assert_eq!(text(0.25), "1/4 s"); + assert_eq!(text(0.4), "0.4 s"); + assert_eq!(text(0.105), "0.105 s", "two decimals would read 0.11 s"); + assert_eq!(text(1.25), "1.25 s", "one decimal would read 1.3 s"); + assert_eq!(text(2.0), "2 s"); + // A hostile value cannot panic, and a positive exposure never reads as "0 s" (never-crash, rule 9). + assert_ne!(exposure_text(f64::MIN_POSITIVE).as_deref(), Some("0 s")); + assert_ne!(exposure_text(f64::MIN_POSITIVE), None); + assert_eq!(exposure_text(0.0), None); + assert_eq!(exposure_text(-1.0), None); + assert_eq!(exposure_text(f64::NAN), None); + assert_eq!(exposure_text(f64::INFINITY), None); + assert_ne!(exposure_text(f64::MAX), None); + // The same through a real EXIF block, so the round trip to File Info is covered too. + let camera = |t: f64| { + let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { exposure_time: Some(t), ..Default::default() }); + camera_data(Some(&exif), None)["exposure"].as_str().map(str::to_owned) + }; + assert_eq!(camera(0.031_714), Some("1/32 s".to_owned())); + assert_eq!(camera(0.0215), Some("1/47 s".to_owned())); + // An absurd exposure is capped to one dialog line, as every other camera field is. + assert!(camera(f64::MAX).expect("an exposure").len() <= 200, "a huge exposure must stay on one dialog line"); +} + const MULTILINGUAL_XMP: &str = r#" Date: Mon, 19 Oct 2026 00:39:40 -0600 Subject: [PATCH 0779/1452] =?UTF-8?q?Pen=20resume:=20undo=20that=20drops?= =?UTF-8?q?=20a=20resumed=20draft=20clears=20its=20=E2=8C=98=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 5 +++++ crates/ui-egui/src/vector_ui.rs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 257e4f591e..f82d049ea2 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -253,7 +253,12 @@ fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { click(&mut app, 80.0, 80.0); assert!(crate::vector_ui::pen_undo_last_point(&mut app), "takes back the added anchor"); assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + // A ⌘/Ctrl selection on the draft's anchors. + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; assert!(crate::vector_ui::pen_undo_last_point(&mut app), "drops the draft"); assert!(app.ui.pen.is_none()); + assert_eq!(app.ui.direct_selection.target, None, "the selection on the dropped draft goes with it"); + assert!(app.ui.direct_selection.anchors.is_empty()); assert_eq!(work_path(&app), before, "the path is as it was"); } diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 0161703c87..8a18ad8f45 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -311,6 +311,10 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { // A resumed path's own anchors are not the draft's to take back: undoing past the ones added // since it resumed drops the draft and leaves the path as it was (#6593). if pen.resumes.as_ref().is_some_and(|r| pen.knots.len() <= r.was.knots.len()) { + // A ⌘/Ctrl selection on the draft goes with it. + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } app.ui.pen = None; return true; } From 060a8cced7174f2c4160f092f27f1def8bd6199b Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 00:48:31 -0600 Subject: [PATCH 0780/1452] Pen resume: a stale draft prefers the path it came from over the selected one Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 11 +++++++++++ crates/ui-egui/src/vector_ui.rs | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index f82d049ea2..cee8e37bbd 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -238,6 +238,17 @@ fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 160.0, "combine")]}})).unwrap(); crate::vector_ui::pen_commit(&mut app, false); assert_eq!(work_path(&app).subpaths.len(), 2, "added, not replacing the Work Path"); + // And with another path selected: the draft still goes back to the path it came from. + app.run("path.set", json!({"name": "Other", "path": {"subpaths": [open(10.0, 30.0, "combine")]}})).unwrap(); + click(&mut app, 160.0, 20.0); + click(&mut app, 160.0, 90.0); + app.ui.selected_path = Some("Other".into()); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 150.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "joined the Work Path it came from"); + let st = app.session.active().unwrap(); + let other = st.doc.paths.iter().find(|p| p.name == "Other").unwrap(); + assert_eq!(other.path.subpaths.len(), 1, "the selected path is untouched"); } /// #6593 review: undoing points in a resumed draft never removes the path's own anchors; past the diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8a18ad8f45..f693fa8d70 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -506,7 +506,7 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // subpath, even with nothing selected in the Paths panel. let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); - let target = app.ui.selected_path.clone().filter(exists).or_else(|| resumed_from.filter(exists)); + let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); if target.is_some() { From b8b8907f726379f227ea2234e00eacf7694f939e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 00:48:33 -0600 Subject: [PATCH 0781/1452] Type move reach: measure on screen with pixel aspect; leave other type to the Type tool Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool_tests.rs | 47 +++++++++++++++++++++++++++ crates/ui-egui/src/type_transform.rs | 39 ++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 09aaff253e..632d7ccdd6 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -808,6 +808,53 @@ fn a_drag_just_outside_the_typed_text_moves_it() { assert_affine(text(h.state(), id).transform, after.transform); } +/// #5956 review: the reach is screen points as the view draws them, so a pixel-aspect stretch +/// doesn't widen it, and a press on another type layer near the text is still the Type tool's. +#[test] +fn the_move_reach_is_on_screen_and_leaves_other_type_alone() { + let (mut app, id) = transform_app(false, false, Affine::translate(500.0, 350.0)); + app.ui.view.pixel_aspect_correction = true; + app.ui.view.pixel_aspect = "anamorphic2To1".into(); + let mut h = harness(1.0, app); + h.run_steps(1); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let mid = (f.quad[1][1] + f.quad[2][1]) / 2.0; + let edge = frame_point(h.state(), [f.quad[1][0], mid]); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, edge + egui::vec2(30.0, 0.0), Modifiers::NONE), Some(egui::CursorIcon::Move)); + // 50 points out is 25 document points across a 2:1 pixel: out of reach. + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, edge + egui::vec2(50.0, 0.0), Modifiers::NONE), None); + let before = text(h.state(), id); + transform_drag(&mut h, edge + egui::vec2(50.0, 0.0), edge + egui::vec2(110.0, 30.0), Modifiers::NONE, false); + assert_affine(text(h.state(), id).transform, before.transform); +} + +/// #5956 review: a press on another type layer within reach of the text being typed is the Type +/// tool's (it commits and moves to that text), not a move of the edited text. +#[test] +fn a_press_on_other_type_near_the_text_does_not_move_it() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hi", "size": 32, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let right = app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds().x1; + let other = LayerId(app.run("type.create", json!({"text": "Other", "size": 32, "x": right + 6, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": id.0})).unwrap(); + super::edit_active(&mut app).unwrap(); + let mut h = harness(1.0, app); + h.run_steps(1); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let ob = h.state().session.active().unwrap().doc.layer(other).unwrap().surface().unwrap().content_bounds(); + let mid = f64::from(ob.y0 + ob.y1) / 2.0; + let on_other = frame_point(h.state(), [f64::from(ob.x0) + 4.0, mid]); + let edge = frame_point(h.state(), [f.quad[1][0], mid]); + assert!(on_other.x - edge.x > 0.0 && on_other.x - edge.x < 40.0, "the other text is within reach: {} pt", on_other.x - edge.x); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + assert_ne!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, on_other, Modifiers::NONE), Some(egui::CursorIcon::Move)); + let before = text(h.state(), id); + transform_drag(&mut h, on_other, on_other + egui::vec2(60.0, 30.0), Modifiers::NONE, false); + assert_affine(text(h.state(), id).transform, before.transform); + assert_ne!(h.state().ui.text_edit.as_ref().map(|e| e.layer), Some(id.0), "the edit moved on"); +} + /// #1091: modifier changes alone draw/hide the oriented frame without touching editing/history. #[test] fn temporary_type_transform_visibility_and_cursors() { diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index e139edcf64..51ae9a608a 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -144,11 +144,44 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { }) } -/// The text's frame when `p` is just outside it, where a plain press moves the text (#5956). +/// Screen points from `p` to the frame's outline, as the view draws it (zoom, rotation and pixel +/// aspect). +fn border_points(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> f64 { + let xf = ViewXform::active(app); + let zoom = f64::from(app.point_zoom().max(0.01)); + let on_screen = |q: [f64; 2]| { + let d = [q[0] - p[0], q[1] - p[1]]; + match xf { + Some(xf) => { + let v = xf.map_vec(egui::vec2(d[0] as f32, d[1] as f32)); + [f64::from(v.x), f64::from(v.y)] + } + None => [d[0] * zoom, d[1] * zoom], + } + }; + t.quad + .iter() + .zip(t.quad.iter().cycle().skip(1)) + .take(4) + .map(|(a, b)| { + let (a, b) = (on_screen(*a), on_screen(*b)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx * dx + dy * dy; + let f = if len > 0.0 { (-a[0] * dx - a[1] * dy) / len } else { 0.0 }.clamp(0.0, 1.0); + (a[0] + f * dx).hypot(a[1] + f * dy) + }) + .fold(f64::INFINITY, f64::min) +} + +/// The text's frame when `p` is just outside it, where a plain press moves the text (#5956). Over +/// another type layer the press is the Type tool's, as before. fn move_zone(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { let t = frame(app)?; - let reach = MOVE_REACH_PT / f64::from(app.point_zoom().max(0.01)); - (crate::transform_tool::hit(&t, p, 0.0) == Hit::Outside && near_border(&t, p, reach)).then_some(t) + if crate::transform_tool::hit(&t, p, 0.0) != Hit::Outside || border_points(app, &t, p) > MOVE_REACH_PT { + return None; + } + let other = crate::type_tool::hit_layer(app, p[0], p[1]).is_some_and(|id| id.0 != t.layer); + (!other).then_some(t) } /// Start a temporary drag of `frame` from `p` on `h`. Returns false when it can't start. From c1f20c239dc7e52cbd6a6554a9b52ff339c85fc1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 01:07:47 -0600 Subject: [PATCH 0782/1452] Move tool: a selection lifts up to a pixel's own coverage (#7655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading a layer's transparency as the selection and dragging it with the Move tool left a ghost of a·(1−a). Photoshop lifts the selection's share of a pixel's coverage, up to all of it, and leaves the rest, so its own transparency lifts it whole. Opaque pixels split as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/float_cmds.rs | 8 ++++-- crates/engine/src/float_cmds/tests.rs | 41 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index e5450b2339..0b357a7775 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -70,13 +70,17 @@ impl CutParts { let w = b.width() as usize; for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0); + // The selection lifts its share of a pixel's coverage, up to all of it, and the + // rest stays (Photoshop): a layer's own transparency, loaded as the selection, + // lifts it whole and leaves no ghost behind (#7655). An opaque pixel splits as k + // and 1 − k, as a product would. if k <= 0.0 { p.fill(0.0); } else { - p[a] *= k; + p[a] = p[a].min(k); } if !copy && !fill { - r[a] *= 1.0 - k; + r[a] = (r[a] - k).max(0.0); } } if !mask { diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index bb2ba5b91c..79e295f3c0 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -189,3 +189,44 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour"); assert_eq!(mask_at(&s, 40, 12), 0.0); } + +/// #7655: a selection loaded from the layer's own transparency lifts its half-transparent pixels +/// whole, leaving nothing behind (Photoshop); a half selection of an opaque pixel still splits it. +#[test] +fn a_layers_own_transparency_floats_its_soft_pixels_whole() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(10, 10, 20, 30), &[1.0, 0.0, 0.0, 0.5]); + surf.fill_rect(Rect::new(20, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 25})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(alpha(d, id, 15, 20), 0.0, "no ghost where the soft pixels were"); + assert!((alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "they moved whole: {}", alpha(d, id, 15, 45)); + assert_eq!((alpha(d, id, 25, 20), alpha(d, id, 25, 45)), (0.0, 1.0)); + + // A half selection of an opaque pixel splits it half and half, as before. + let (mut s, id) = session(); + s.execute("select.all", json!({})).unwrap(); + s.edit("half", |doc, _| { + let sel = doc.selection.as_mut().unwrap(); + sel.fill_rect(doc_rect(80, 60), &[0.5]); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert!((alpha(d, id, 15, 15) - 0.5).abs() < 0.01 && (alpha(d, id, 55, 15) - 0.5).abs() < 0.01); +} + +fn doc_rect(w: i32, h: i32) -> Rect { + Rect::new(0, 0, w, h) +} From 9e22dda917d9b2c22c946b73de992bca351168fa Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 01:26:36 -0600 Subject: [PATCH 0783/1452] Test: a copy floated by the layer's own transparency is as solid as the original Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/float_cmds/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index 79e295f3c0..aad136fefc 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -212,6 +212,15 @@ fn a_layers_own_transparency_floats_its_soft_pixels_whole() { assert!((alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "they moved whole: {}", alpha(d, id, 15, 45)); assert_eq!((alpha(d, id, 25, 20), alpha(d, id, 25, 45)), (0.0, 1.0)); + // A copy (⌥) of them is as solid as they are, as Photoshop's Layer via Copy of the same + // selection is, and leaves them where they were. + s.undo(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 25, "copy": true})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert!((alpha(d, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "{} {}", alpha(d, id, 15, 20), alpha(d, id, 15, 45)); + // A half selection of an opaque pixel splits it half and half, as before. let (mut s, id) = session(); s.execute("select.all", json!({})).unwrap(); From 249a3baabe447f1e79de6c74bc8d7a9eb34fc204 Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:26:45 -0600 Subject: [PATCH 0784/1452] ui: tolerate finger jitter on New Document double-tap egui's 6 px click distance rejects typical touchscreen double-taps; use a looser touch slop in second_click while keeping mouse tight. Co-authored-by: Cursor --- crates/ui-egui/src/new_doc_ui.rs | 6 +- crates/ui-egui/src/widgets.rs | 124 ++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..07727abdb9 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -424,7 +424,8 @@ fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map< let response = ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name); if response.clicked() { apply_saved_preset(f, preset); - create |= response.double_clicked() || response.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &response); } if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() { match app.run("document.presets.delete", json!({"name":preset.name})) { @@ -594,7 +595,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map apply_preset(f, p, &units), Card::Recent(settings) => apply_settings(f, settings), } - create |= resp.double_clicked() || resp.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &resp); } } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b393c0787d..2d84a8461b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -975,26 +975,37 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch /// Windows' default double-click time (Photoshop's; egui's own default is 0.3 s). const DOUBLE_CLICK_S: f64 = 0.5; -/// Whether this click on `resp` completes a double-click, counted the Windows way (Photoshop): -/// a click soon after a lone click at the same spot; the click after a double-click starts over. -/// (egui would call a third quick click a triple-click even when the first two weren't a double.) -fn second_click(ui: &Ui, resp: &Response) -> bool { +/// Finger jitter between taps easily exceeds egui's 6 px `max_click_dist`; ~32 px is about a third of an inch at 96 DPI and matches common touch hit-slop (Windows' mouse SM_CXDOUBLECLK is only 4 px and is not used for touch). +const TOUCH_MAX_CLICK_DIST: f32 = 32.0; + +/// True when this frame's pointer work came from a touch contact (still down, or a Touch event including End on the click frame). +fn pointer_from_touch(ui: &Ui) -> bool { + ui.input(|i| i.any_touches() || i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. }))) +} + +/// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. +/// +/// Time window is [`DOUBLE_CLICK_S`] (0.5 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; } let key = resp.id.with("pc-last-click"); let (time, pos) = ui.input(|i| (i.time, i.pointer.interact_pos())); - let dist = ui.ctx().options(|o| o.input_options.max_click_dist); - let last: Option<(f64, Pos2)> = ui.data(|d| d.get_temp(key)); + let touch = pointer_from_touch(ui); + let mouse_dist = ui.ctx().options(|o| o.input_options.max_click_dist); + let last: Option<(f64, Pos2, bool)> = ui.data(|d| d.get_temp(key)); + // Looser distance if either tap in the pair was touch (End clears `any_touches` but leaves Touch in `events`). + let dist = if touch || last.is_some_and(|(_, _, t)| t) { TOUCH_MAX_CLICK_DIST.max(mouse_dist) } else { mouse_dist }; let double = match (last, pos) { - (Some((t, p)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, + (Some((t, p, _)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, _ => false, }; ui.data_mut(|d| { if let (false, Some(q)) = (double, pos) { - d.insert_temp(key, (time, q)); + d.insert_temp(key, (time, q, touch)); } else { - d.remove::<(f64, Pos2)>(key); + d.remove::<(f64, Pos2, bool)>(key); } }); double @@ -1902,6 +1913,101 @@ mod tests { out.textures_delta.clear(); } } + + /// One primary press+release at `pos`. When `touch`, emit Touch Start/End with the pointer events (as egui-winit does) so [`super::second_click`] uses the touch distance. + fn pointer_click(ctx: &egui::Context, time: f64, pos: egui::Pos2, touch: bool) -> bool { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let button = |pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + let touch_ev = |phase| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase, + pos, + force: None, + }; + let mut saw = false; + for (t, mut events) in [ + (time, { + let mut v = vec![egui::Event::PointerMoved(pos), button(true)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::Start)); + } + v + }), + (time + 0.02, { + let mut v = vec![button(false)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::End)); + } + v + }), + ] { + let mut raw = egui::RawInput { + time: Some(t), + screen_rect: Some(area), + ..Default::default() + }; + raw.events.append(&mut events); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(area, egui::Sense::click()); + saw |= super::second_click(ui, &r); + }); + out.textures_delta.clear(); + } + saw + } + + /// #2274 touch: two taps ~20 px apart (past egui's 6 px mouse limit, inside our touch slop) within 0.5 s count as a double; the same mouse path does not. + #[test] + fn second_click_tolerates_touch_jitter_but_keeps_mouse_tight() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let a = egui::pos2(50.0, 50.0); + let b = egui::pos2(70.0, 50.0); // 20 px — past max_click_dist (6), inside TOUCH_MAX_CLICK_DIST (32) + for touch in [false, true] { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(0.0), + screen_rect: Some(area), + ..Default::default() + }, + |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }, + ); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, a, touch), "first click only arms the pair"); + let second = pointer_click(&ctx, 0.25, b, touch); + assert_eq!(second, touch, "touch={touch}: 20 px jitter is a double only for touch"); + } + } + + /// A mouse double past the time window is not a double, even at the same pixel. + #[test] + fn second_click_respects_the_half_second_window() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let p = egui::pos2(40.0, 40.0); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(0.0), + screen_rect: Some(area), + ..Default::default() + }, + |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }, + ); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, p, false)); + assert!(!pointer_click(&ctx, 0.1 + super::DOUBLE_CLICK_S + 0.05, p, false), "past Windows' 0.5 s window"); + } + use egui_kittest::{Harness, kittest::Queryable}; /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays From dc3a5c3b59e9380264f2a87a22073b6eaec1351d Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:43:13 -0600 Subject: [PATCH 0785/1452] test(ui): cover New Document touch double-tap with finger jitter Harness taps share Touch+pointer events on one frame so second_click sees touch; mouse path still rejects a 20 px gap. Co-authored-by: Cursor --- crates/ui-egui/src/new_doc_ui.rs | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 07727abdb9..c7c08754e0 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1210,6 +1210,28 @@ mod tests { h.run_steps(2); } + /// A finger tap at `at`. Events for one phase share a frame (`input_mut`), because `Harness::step` would otherwise run one frame per queued event and drop Touch before the click release (so `second_click` would not see touch). + fn touch_tap_at(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { + let touch = |phase| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase, + pos: at, + force: None, + }; + let button = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + h.input_mut().events.extend([egui::Event::PointerMoved(at), touch(egui::TouchPhase::Start), button(true)]); + h.step(); + h.input_mut().events.extend([touch(egui::TouchPhase::End), button(false)]); + h.step(); + h.run_steps(1); + } + /// The Width (0), Height (1) and Resolution (2) fields. fn field(h: &Harness<'static, PhotocraftApp>, i: usize) -> egui::Rect { h.query_all_by_role(Role::SpinButton).nth(i).map(|n| n.rect()).expect("a size field") @@ -1299,6 +1321,33 @@ mod tests { assert_pixel(&mut h, [0.0; 4]); } + /// Framework-style touch: the second tap lands ~20 px off (past egui's 6 px) and still creates. + #[test] + fn touch_double_tap_with_jitter_creates_from_builtin_preset() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + touch_tap_at(&mut h, at); + assert_eq!(fields(&h)["__preset"], "Portrait, 4 x 6"); + assert!(h.state().session.documents().is_empty(), "first tap only selects"); + touch_tap_at(&mut h, egui::pos2(at.x + 20.0, at.y)); + assert_eq!(created(&h), (1200, 1800, 300.0)); + } + + /// Mouse keeps egui's tight distance: 20 px between clicks is two singles, not a create. + #[test] + fn mouse_double_click_with_large_jitter_does_not_create() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + click_at(&mut h, at); + click_at(&mut h, at + egui::vec2(20.0, 0.0)); + assert!(h.state().session.documents().is_empty()); + assert_eq!(h.state().ui.dialogs.len(), 1, "dialog stays open"); + } + #[test] fn double_click_clipboard_preset_creates_its_size_and_current_details() { let mut h = harness(); From 155700deae81d992ad18e6ba5fe28286681e0fcd Mon Sep 17 00:00:00 2001 From: Raincloud Date: Mon, 19 Oct 2026 01:43:25 -0600 Subject: [PATCH 0786/1452] ui: read double-click pos from the response hit Prefer interact_pointer_pos on the response so release-only frames still arm second_click. Co-authored-by: Cursor --- crates/ui-egui/src/widgets.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2d84a8461b..d05a6f492f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -985,13 +985,15 @@ fn pointer_from_touch(ui: &Ui) -> bool { /// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. /// -/// Time window is [`DOUBLE_CLICK_S`] (0.5 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +/// Time window is 0.5 s (Windows / Photoshop; egui's default is 0.3 s). Mouse uses egui's `max_click_dist` (6 px); touch uses 32 px so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; } let key = resp.id.with("pc-last-click"); - let (time, pos) = ui.input(|i| (i.time, i.pointer.interact_pos())); + // Prefer the response's hit pos: raw `pointer.interact_pos` can be unset on a release-only frame. + let pos = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())); + let time = ui.input(|i| i.time); let touch = pointer_from_touch(ui); let mouse_dist = ui.ctx().options(|o| o.input_options.max_click_dist); let last: Option<(f64, Pos2, bool)> = ui.data(|d| d.get_temp(key)); From 0604f39d97f211541528d62d52dcc26f5f8c5ba8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 09:43:36 +0200 Subject: [PATCH 0787/1452] Performance: hand single-part tiles to put without a copy (#5108) Only CMYK and Lab tiles are split into bands; every other format's one part went through concat, an extra allocation and copy per tile on the UI thread (8-bit RGBA, 64 tiles: 2.7 -> 3.3 ms). Now it passes straight through. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 42a0364e6e..37aaea8e57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -2181,7 +2181,11 @@ fn convert_tiles(surface: &Surface, tiles: Vec<(TileCoord, Arc)>, kind: Te }) }); for ((t, cs), bytes) in batch.iter().zip(parts.chunks(bands as usize)) { - put(t, cs, &bytes.concat()); + // One part (every format but CMYK and Lab) goes through as is: no copy per tile. + match bytes { + [one] => put(t, cs, one), + _ => put(t, cs, &bytes.concat()), + } } } groups.len() From b81c1725b5daf64d5a901fe01501079837c54981 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 01:52:03 -0600 Subject: [PATCH 0788/1452] Export As: show the size the file will have (#6595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estimate encoded a ≤512 px proxy and scaled its bytes by the pixel ratio, which reads low for detailed images (0.36–0.81× here). Photoshop's Export As shows the encoded size. Up to 40 MP the full export is now encoded off the UI thread with the exporter's own settings; the proxy figure shows until then. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_dialog.rs | 129 +++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 275221e050..eecb7bdf44 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -526,6 +526,76 @@ fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Some(scaled_estimate(bytes.len(), doc, proxy, f)) } +/// Exports up to this many pixels get an exact size: the full image encoded as Export will +/// write it, as Photoshop's Export As reports (#6595). Larger ones keep the proxy estimate, which +/// reads low for detailed images (a downscaled proxy compresses better). +#[cfg(not(target_arch = "wasm32"))] +const EXACT_ESTIMATE_PX: f64 = 40_000_000.0; + +/// The exported file's size in bytes, from encoding the full export, or `None` when it is too +/// large to encode for an estimate or can't be encoded. +#[cfg(not(target_arch = "wasm32"))] +fn exact_estimate(doc: &Document, f: &Map) -> Option { + let scale = n(f, "scale", 100.0) / 100.0; + let px = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, + _ => doc.size.width as f64 * scale * doc.size.height as f64 * scale, + }; + if !px.is_finite() || px > EXACT_ESTIMATE_PX { + return None; + } + let full = export_document(doc, f, None).ok()?; + let bytes = photocraft_io::export(&full, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes; + u64::try_from(bytes.len()).ok() +} + +/// The exact size for `signature`, worked out off the UI thread: `None` until it is ready (the +/// proxy estimate shows meanwhile), then `Some(None)` when there is no exact size. +#[cfg(not(target_arch = "wasm32"))] +fn poll_or_start_exact_estimate(ui: &mut egui::Ui, key: egui::Id, signature: &str, doc: Arc, fields: &Map) -> Option> { + type Done = Arc>>>; + let done_key = key.with("exact-estimate"); + if let Some((sig, size)) = ui.data(|d| d.get_temp::<(String, Option)>(done_key)) + && sig == signature + { + return Some(size); + } + let pending_key = key.with("exact-estimate-pending"); + if let Some((sig, result)) = ui.data(|d| d.get_temp::<(String, Done)>(pending_key)) { + if sig == signature { + let size = result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + ui.data_mut(|d| { + d.remove::<(String, Done)>(pending_key); + d.insert_temp(done_key, (sig, size)); + }); + return Some(size); + } + // Settings changed meanwhile: that worker's answer is for other settings. + if result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none() { + return None; + } + } + let result: Done = Arc::new(Mutex::new(None)); + let shared = result.clone(); + let ctx = ui.ctx().clone(); + let fields = fields.clone(); + let started = std::thread::Builder::new().name("export-estimate".into()).spawn(move || { + let size = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); + exact_estimate(&source, &fields) + })) + .unwrap_or(None); + *shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(size); + ctx.request_repaint(); + }); + if started.is_ok() { + ui.data_mut(|d| d.insert_temp(pending_key, (signature.to_string(), result))); + None + } else { + Some(None) + } +} + fn write_size(f: &mut Map, size: ExportSize, sizing: &str) { f.insert("width".into(), json!(size.width)); f.insert("height".into(), json!(size.height)); @@ -814,7 +884,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; + let est = + exact.or_else(|| preview.as_ref().and_then(|preview| preview.estimate)).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); ui.label(egui::RichText::new(format!("{} ≈ {est}", s_fmt(f).to_uppercase())).color(t.text_dim).size(11.5)); }); }); @@ -1048,6 +1123,56 @@ mod tests { } } + /// #6595: Export As shows the size the file will have, as Photoshop does. The proxy estimate + /// read 36–81 % of it for a detailed image; up to `EXACT_ESTIMATE_PX` the full export is + /// encoded off the UI thread instead. + #[test] + fn the_estimate_is_the_exported_files_size() { + let mut doc = Document::with_background( + "x", + photocraft_doc::Size::new(900, 300), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let id = doc.layers[0].id; + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + let mut seed = 12345u32; + for y in (0..300).step_by(2) { + for x in (0..900).step_by(2) { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let v = (seed >> 24) as f32 / 255.0; + surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 2, y + 2), &[v, x as f32 / 900.0, y as f32 / 300.0, 1.0]); + } + } + let doc = Arc::new(doc); + let f = Map::from_iter([("format".into(), json!("jpg")), ("scale".into(), json!(100)), ("quality".into(), json!(32))]); + let actual = photocraft_io::export(&export_document(&doc, &f, None).unwrap(), "x.jpg", &settings(&f).io_options()).unwrap().bytes.len() as u64; + let proxy = preview_proxy(&doc, &f).unwrap(); + let old = estimate_native(&doc, &proxy, &f).unwrap(); + assert!((old as f64 / actual as f64 - 1.0).abs() > 0.1, "the proxy estimate was off: {old} vs {actual}"); + + let ctx = egui::Context::default(); + let key = egui::Id::new("exact-estimate-test"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let size = loop { + let mut size = None; + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| size = poll_or_start_exact_estimate(ui, key, "jpg32", doc.clone(), &f)); + }); + output.textures_delta.clear(); + if let Some(size) = size { + break size; + } + assert!(std::time::Instant::now() < deadline, "the exact estimate did not arrive"); + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + assert_eq!(size, Some(actual)); + // Over the limit there is no exact size; the proxy estimate stays. + let huge = Map::from_iter([("format".into(), json!("jpg")), ("width".into(), json!(10_000)), ("height".into(), json!(10_000))]); + assert_eq!(exact_estimate(&doc, &huge), None); + } + #[test] fn export_document_scales_and_flattens() { let doc = Document::with_background( From 9aa9634a22765b7532fbc149d2bc3560cf30c060 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 02:01:39 -0600 Subject: [PATCH 0789/1452] Export As estimate: size the 40 MP guard as the export does Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_dialog.rs | 40 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index eecb7bdf44..145fe60049 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -536,11 +536,9 @@ const EXACT_ESTIMATE_PX: f64 = 40_000_000.0; /// large to encode for an estimate or can't be encoded. #[cfg(not(target_arch = "wasm32"))] fn exact_estimate(doc: &Document, f: &Map) -> Option { - let scale = n(f, "scale", 100.0) / 100.0; - let px = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { - (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, - _ => doc.size.width as f64 * scale * doc.size.height as f64 * scale, - }; + // The size `export_document` will make: exact pixels only in pixel mode, else the scale. + let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, None).ok()?; + let px = w * h; if !px.is_finite() || px > EXACT_ESTIMATE_PX { return None; } @@ -1169,8 +1167,38 @@ mod tests { }; assert_eq!(size, Some(actual)); // Over the limit there is no exact size; the proxy estimate stays. - let huge = Map::from_iter([("format".into(), json!("jpg")), ("width".into(), json!(10_000)), ("height".into(), json!(10_000))]); + let huge = Map::from_iter([ + ("format".into(), json!("jpg")), + ("sizing".into(), json!("pixels")), + ("width".into(), json!(10_000)), + ("height".into(), json!(10_000)), + ]); assert_eq!(exact_estimate(&doc, &huge), None); + // In scale mode the size comes from the scale, as the export's does, not from stale pixels. + let big = Arc::new(Document::with_background( + "big", + photocraft_doc::Size::new(1000, 1000), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + )); + let stale = Map::from_iter([ + ("format".into(), json!("png")), + ("sizing".into(), json!("scale")), + ("scale".into(), json!(1000)), + ("width".into(), json!(100)), + ("height".into(), json!(100)), + ]); + assert_eq!(exact_estimate(&big, &stale), None, "10000 × 10000 at 1000 %, over the limit"); + let small = Map::from_iter([ + ("format".into(), json!("png")), + ("sizing".into(), json!("scale")), + ("scale".into(), json!(10)), + ("width".into(), json!(9000)), + ("height".into(), json!(9000)), + ]); + let at_10 = photocraft_io::export(&export_document(&big, &small, None).unwrap(), "x.png", &settings(&small).io_options()).unwrap().bytes.len() as u64; + assert_eq!(exact_estimate(&big, &small), Some(at_10), "100 × 100 at 10 %, within it"); } #[test] From 8534f0c935266a5b4e09de17195647a2f77f8c7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 10:03:54 +0200 Subject: [PATCH 0790/1452] Compose: a clipped adjustment with no base inside a pass-through group reaches everything (#6535) The compositor draws a clipped adjustment that has no unclipped layer beneath it as an ordinary adjustment, but change_bounds counted it as contained in a pass-through group. Hiding the layer clipped to such a group (which ends its isolation) or the group itself then recomposited only the group's pixels and left the rest of the canvas stale. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 9 +++++++-- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6ee2b6a2ce..f2733d157e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1156,9 +1156,14 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option { pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option { fn reaches_below(l: &Layer) -> bool { match &l.content { - // Clipped, it changes only its base's pixels. LayerContent::Adjustment(_) => !l.clipped, - LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below), + // Clipped to a base, an adjustment changes only the base's pixels; one with no base + // beneath it in its list composites as an ordinary adjustment (#6535). + LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().enumerate().any(|(k, c)| { + let orphan = + c.clipped && matches!(c.content, LayerContent::Adjustment(_)) && g.children.get(..k).is_none_or(|below| below.iter().all(|b| b.clipped)); + orphan || reaches_below(c) + }), _ => false, } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 42bbea272e..9bbbaaf2a6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -500,6 +500,32 @@ fn clipped_adjustment_changes_only_its_base() { assert_eq!(change_bounds_in(&on_group, 2, canvas), None); } +/// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites +/// as an ordinary adjustment, so hiding the group or the layer clipped to it (which ends its +/// isolation) changes the whole canvas, not just the group's pixels. +#[test] +fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() { + let canvas = Rect::new(0, 0, 8, 6); + let mut d = doc_white(8, 6); + let mut orphan = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + orphan.clipped = true; + let mut g = Layer::group("G", vec![orphan, solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0])]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let mut x = Layer::new("X", LayerContent::Adjustment(Adjustment::Invert)); + x.clipped = true; + d.layers.push(x); + let shown = px(&d, 0, 0); + d.layers[2].visible = false; + assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group"); + assert_eq!(change_bounds_in(&d.layers, 2, canvas), None); + assert_eq!(change_bounds(&d.layers[1], canvas), None); + // With a base beneath it the clipped adjustment stays inside the group. + let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") }; + g.children.swap(0, 1); + assert_eq!(change_bounds_in(&d.layers, 2, canvas), Some(Rect::new(2, 1, 4, 3))); +} + #[test] fn threshold_posterize_levels_curves() { let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]); From 49d48fc43c4fa0ab20544c52b7dc665f414b6529 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:11:51 +0800 Subject: [PATCH 0791/1452] Fix blend preview coverage for periodic tiles and multiple views --- crates/ui-egui/src/blend_preview.rs | 27 +++++++----- crates/ui-egui/src/blend_preview/tests.rs | 34 ++++++++++++++ crates/ui-egui/src/canvas.rs | 7 ++- .../src/canvas/blend_viewport_tests.rs | 44 +++++++++++++++++++ 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index fb8b6f3177..06e7a208e1 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -29,8 +29,8 @@ pub(crate) struct BlendPreview { area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, - /// GPU preview key, affected region (including effects), and the area last refreshed. - covered: Option<(u64, Rect, Rect)>, + /// GPU preview key, affected region (including effects), and rectangles already refreshed. + covered: Option<(u64, Rect, Vec)>, } /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the @@ -106,26 +106,31 @@ pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, ke return region; } let shown = region.intersect(&visible); - // `covered` records only this refresh, including an empty intersection; it is not cumulative. - // Keep `region` so `uncovered` can recompute the next visible area. Forgetting older coverage - // can cause a redundant refresh, but cannot skip newly exposed pixels for this key. - p.covered = Some((key, region, shown)); + p.covered = Some((key, region, vec![shown])); shown } /// A pan or zoom can expose pixels not yet rendered for the current preview key. pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option { let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?; - let (covered_key, region, covered) = p.covered?; - if covered_key != key { + let (covered_key, region, covered) = p.covered.as_mut()?; + if *covered_key != key { return None; } let want = region.intersect(&visible); - if want.is_empty() || covered.contains_rect(&want) { + if want.is_empty() || covered.iter().any(|r| r.contains_rect(&want)) { return None; } - p.covered = Some((key, region, want)); - Some(want) + // Keep disjoint views without marking the unrendered gaps between them as covered. + // Bound bookkeeping during long pans: after eight rectangles, render the whole affected + // region once. A different mode/revision resets coverage through `clip_damage`. + covered.retain(|r| !want.contains_rect(r)); + let refresh = if covered.len() < 8 { want } else { *region }; + if refresh == *region { + covered.clear(); + } + covered.push(refresh); + Some(refresh) } fn is_preview_key(key: u64) -> bool { diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 22908ac248..72e809f786 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -138,3 +138,37 @@ fn a_hovered_mode_previews_on_every_selected_layer() { hover(&mut app, &[top], Some(BlendMode::Multiply)); assert_ne!(display_doc(&mut app, 0).unwrap().1, key); } + +#[test] +fn blend_hover_disjoint_coverage_retains_views_without_hiding_gaps() { + let (s, top) = session(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top], Some(BlendMode::Multiply)); + let (_, key) = display_doc(&mut app, 0).unwrap(); + let st = app.session.active().unwrap(); + let (id, revision, region) = (st.doc.id, st.revision, st.doc.bounds()); + let a = photocraft_geom::Rect::new(0, 0, 2, 2); + let b = photocraft_geom::Rect::new(4, 0, 6, 2); + assert_eq!(super::clip_damage(&mut app, id, revision, key, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, key, b), Some(b)); + assert_eq!(super::uncovered(&mut app, id, revision, key, a), None); + let gap = photocraft_geom::Rect::new(2, 0, 4, 2); + assert_eq!(super::uncovered(&mut app, id, revision, key, gap), Some(gap)); + // Repeated distinct views have bounded bookkeeping, then the whole region is current. + let mut fell_back = false; + for x in (8..40).step_by(4) { + let visible = photocraft_geom::Rect::new(x, 0, x + 2, 2); + if super::uncovered(&mut app, id, revision, key, visible) == Some(region) { + fell_back = true; + break; + } + } + assert!(fell_back); + assert_eq!(super::uncovered(&mut app, id, revision, key, region), None); + hover(&mut app, &[top], Some(BlendMode::Screen)); + let (_, next) = display_doc(&mut app, 0).unwrap(); + assert_eq!(super::clip_damage(&mut app, id, revision, next, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, next, b), Some(b)); + hover(&mut app, &[top], None); + assert_eq!(super::clip_damage(&mut app, id, revision, 0, region, a), region); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 99aa686904..dee9e1388e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1575,12 +1575,15 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: }; let (doc, raw_key) = display_doc(app, idx); let (display, _) = canvas_display(app, &doc, None); + // Repeated pattern tiles sample the same texture, including pixels outside the unwrapped + // viewport. Refresh the affected document region while Pattern Preview is enabled. + let blend_visible = if app.ui.view.pattern_preview { doc.bounds() } else { visible }; // The texture is shared by every display: only what it stores counts, not the monitor. let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); if let Some(region) = damage { - damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, visible)); + damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, blend_visible)); } // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() @@ -1619,7 +1622,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: } // Blend previews keep offscreen pixels untouched until the view reaches them. // Use the normal refresh path so CPU fallback and pending redraws stay synchronized. - damage = crate::blend_preview::uncovered(app, id, revision, raw_key, visible); + damage = crate::blend_preview::uncovered(app, id, revision, raw_key, blend_visible); if damage.is_none() { return true; } diff --git a/crates/ui-egui/src/canvas/blend_viewport_tests.rs b/crates/ui-egui/src/canvas/blend_viewport_tests.rs index b1b4872aa6..ae6a01742d 100644 --- a/crates/ui-egui/src/canvas/blend_viewport_tests.rs +++ b/crates/ui-egui/src/canvas/blend_viewport_tests.rs @@ -30,6 +30,50 @@ fn blend_hover_multiple_layers_tracks_visible_canvas() { check_blend_viewport(false, true); } +#[test] +fn blend_hover_pattern_tiles_and_multiple_views_reuse_current_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + let ctx = egui::Context::default(); + let a = DRect::new(724, 200, 1324, 500); + let b = DRect::new(0, 200, 300, 500); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + app.ui.view.pattern_preview = true; + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, b); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + + app.ui.view.pattern_preview = false; + for mode in [BlendMode::Multiply, BlendMode::Screen] { + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(mode)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert!(ensure_gpu(&mut app, &ctx, 0, b)); + for visible in [a, b, a, b] { + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged views must retain coverage together"); + assert_visible_pixels(&mut app, visible.intersect(&bounds)); + } + } + // Enabling periodic display during an existing hover must catch up the unseen gap too. + app.ui.view.pattern_preview = true; + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); +} + #[test] fn blend_hover_during_background_redraw_restores_committed_pixels() { let _guard = photocraft_testkit::gpu_lock(); From 75fe81865fedf40b1252ec89533eeaaeaeae2aae Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 14:23:45 +0530 Subject: [PATCH 0792/1452] CLI: say that the input-overwrite check guards against mistakes, not races The paths are compared before the save reopens the output, so a link swapped in meanwhile is not caught (CodeRabbit nitpick on #7455). Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 69bc1a9632..a9c6b5b5ea 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -244,7 +244,9 @@ fn automation_workspace(args: &Args) -> Result { /// An output that is the input file itself, however it is spelt (relative, with `./`, through a /// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it /// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` -/// folder (#7437). +/// folder (#7437). The paths are compared when the command starts and the save opens `output` +/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards +/// against mistakes, it is not a security boundary. fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); From 0acb707fe1708147670b87259724f570196b9dec Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Mon, 19 Oct 2026 12:28:43 +0300 Subject: [PATCH 0793/1452] fix(actions): preserve effective Stroke width in ATN export --- crates/engine/src/atn_cmds.rs | 22 +++- crates/engine/src/extra_cmds.rs | 9 +- crates/engine/tests/atn_stroke_width.rs | 167 ++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/atn_stroke_width.rs diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..db636e533b 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2105,6 +2105,16 @@ fn bucket_color(v: Option<&Value>) -> Option> { Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0)))) } +/// Preserve the command's effective integer width instead of rounding its input again. +fn stroke_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) { + return None; + } + let mut params = if p.is_null() { Map::new() } else { p.as_object()?.clone() }; + params.insert("width".into(), Value::from(crate::extra_cmds::stroke_width(p))); + fields_to(&Value::Object(params), STROKE, desc("null")) +} + const STROKE: &[F] = &[ F("Wdth", "width", Kind::Long), F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), @@ -2925,7 +2935,17 @@ const EVENTS: &[Event] = &[ ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE), // Edit. Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, - ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), + Event { + code: "Strk", + name: "stroke", + label: "Stroke", + ids: &["edit.stroke"], + from: |d| match fields_from(d, STROKE) { + Some(m) => step("edit.stroke", Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: stroke_to, + }, ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), ev!("cut ", "cut", "Cut", "edit.cut", &[]), diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 08ed6cfa0f..090b356bc4 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -131,10 +131,15 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut Ok(Value::Null) } +/// The effective whole-pixel Stroke width, shared by execution and ATN export. +pub(crate) fn stroke_width(p: &Value) -> usize { + // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). + p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize +} + /// Edit › Stroke: a band along the selection edge (or the layer's opaque edge without a selection). fn stroke(s: &mut Session, p: &Value) -> Result { - // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). - let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; + let width = stroke_width(p); let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); diff --git a/crates/engine/tests/atn_stroke_width.rs b/crates/engine/tests/atn_stroke_width.rs new file mode 100644 index 0000000000..742359cf38 --- /dev/null +++ b/crates/engine/tests/atn_stroke_width.rs @@ -0,0 +1,167 @@ +//! #7671: exported Stroke widths must have the same effect as command execution. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +/// Synthetic colours and partial transparency make lost edge pixels observable at every depth. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 0.6])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 4, "width": 12, "height": 10})).unwrap(); + s +} + +/// Compare the flattened document, including alpha, rather than only imported JSON. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Direct execution, native playback and strict binary ATN playback must agree exactly. +fn check_pixels(widths: &[(f64, u32)]) { + let mut lost = Vec::new(); + for depth in [8, 16, 32] { + for location in ["inside", "center", "outside"] { + for &(width, effective) in widths { + let params = json!({"width": width, "color": "#d03060", "location": location, "opacity": 63, "mode": "multiply"}); + let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]); + let mut direct = fixture(depth); + let before = pixels(&direct); + direct.execute("edit.stroke", params.clone()).unwrap(); + let expected = pixels(&direct); + assert_ne!(expected, before, "ineffective fixture: {depth}, {params}"); + + let mut whole = fixture(depth); + let mut whole_params = params.clone(); + whole_params["width"] = json!(effective); + whole.execute("edit.stroke", whole_params).unwrap(); + assert_eq!(pixels(&whole), expected, "command width normalization: {depth}, {params}"); + if width == 2.4 { + let mut rounded = fixture(depth); + let mut rounded_params = params.clone(); + rounded_params["width"] = json!(2); + rounded.execute("edit.stroke", rounded_params).unwrap(); + assert_ne!(pixels(&rounded), expected, "2.4 must differ from 2: {depth}, {location}"); + } + + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + assert_eq!(pixels(&native), expected); + + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "edit.stroke"); + let imported_width = imported.actions[0].steps[0].1["width"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + if pixels(&replay) != expected { + lost.push(format!("depth {depth}, {location}, width {width} -> {imported_width}, expected {effective}")); + } + } + } + } + assert!(lost.is_empty(), "ATN changed Stroke pixels:\n{}", lost.join("\n")); +} + +/// In particular 2.4 renders as 3 px, while the old exporter serialized 2 px. +#[test] +fn fractional_stroke_width_preserves_pixels_at_every_depth_and_location() { + check_pixels(&[(0.5, 1), (1.01, 2), (2.01, 3), (2.4, 3), (2.5, 3), (2.6, 3), (3.1, 4)]); +} + +/// Existing whole-pixel strokes remain supported, with their original rendering. +#[test] +fn integer_stroke_width_controls_still_preserve_pixels() { + check_pixels(&[(1.0, 1), (2.0, 2), (3.0, 3), (4.0, 4)]); +} + +/// Export the command's bounded/default width as an integer, retaining the other options. +#[test] +fn descriptor_width_matches_defaults_limits_and_other_stroke_options() { + for (width, effective) in [ + (None, 1), + (Some(Value::Null), 1), + (Some(json!("2.4")), 1), + (Some(json!(true)), 1), + (Some(json!([])), 1), + (Some(json!({})), 1), + (Some(json!(-100)), 1), + (Some(json!(0)), 1), + (Some(json!(249.01)), 250), + (Some(json!(249.5)), 250), + (Some(json!(250)), 250), + (Some(json!(250.1)), 250), + (Some(json!(1e300)), 250), + ] { + let mut params = + json!({"color": "#d03060", "location": "outside", "opacity": 63, "mode": "multiply", "preserveTransparency": true, "target": "pixels"}); + if let Some(width) = width { + params["width"] = width; + } + let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("Wdth"), Some(&D::Integer(effective)), "{params}"); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + let p = &imported.actions[0].steps[0].1; + assert_eq!(p["width"].as_f64(), Some(f64::from(effective))); + for key in ["color", "location", "preserveTransparency"] { + assert_eq!(p[key], params[key]); + } + // Import uses the canonical blend-mode label ("Multiply"); execution accepts both cases. + assert!(p["mode"].as_str().unwrap().eq_ignore_ascii_case(params["mode"].as_str().unwrap())); + assert_eq!(p["opacity"].as_f64(), Some(63.0)); + let mut direct = fixture(8); + direct.execute("edit.stroke", params).unwrap(); + let mut replay = fixture(8); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct)); + } + // A null parameter object is also a valid call to the command with its default width. + let action = Action::new("Default", vec![("edit.stroke".into(), Value::Null)]); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + assert_eq!(imported.actions[0].steps[0].1["width"].as_f64(), Some(1.0)); + let mut direct = fixture(8); + direct.execute("edit.stroke", Value::Null).unwrap(); + let mut replay = fixture(8); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Default"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct)); +} + +/// A custom Stroke converter must retain the plain-event mask/channel target guard. +#[test] +fn stroke_mask_and_channel_targets_are_still_refused_and_reported() { + for target in [json!("mask"), json!("quickMask"), json!({"channel": 0})] { + let action = Action::new("Targeted", vec![("edit.stroke".into(), json!({"width": 2.4, "target": target}))]); + assert!(atn_cmds::export("Widths", &[&action], &[], false).is_err()); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Targeted".into(), step: 0, id: "edit.stroke".into() }]); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + } +} From 850bf9fdaa7540d5c352cbddd97adfe793fc485a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 0794/1452] perf(codecs): Avoid redundant PNG filter passes Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression. --- crates/codecs/src/codecs/png.rs | 132 +++++++++++++++++++---- docs/png-filter-selection-performance.md | 38 +++++++ 2 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 docs/png-filter-selection-performance.md diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..34ae84acc0 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -505,8 +505,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -515,27 +522,14 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -659,6 +653,106 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md new file mode 100644 index 0000000000..34938b6d84 --- /dev/null +++ b/docs/png-filter-selection-performance.md @@ -0,0 +1,38 @@ +# PNG filter selection + +Only adaptive PNG filter selection changes: stop evaluating filters when their partial cost cannot win, and stop after a zero-cost winner. The kernel processes 4000 rows of 6000 RGBA8 pixels (24 MP) for zero, identical and varied rows; deflate is outside the timer. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| zero | 448.138 | 5.734 | -98.7% | +| identical | 447.020 | 59.819 | -86.6% | +| varied | 450.469 | 277.254 | -38.5% | + +These are serial filter-kernel timings, not end-to-end PNG encoding timings. + +The committed oracle is the previous main-branch filter function. The unit test compares exact output bytes, including the selected filter/tie order, across six bytes-per-pixel layouts, 32 patterns and absent/identical/shifted previous rows. Existing encoder round trips and deterministic worker-count tests remain in place. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-codecs --lib --no-run +# Run the printed executable at normal priority: + benchmark_filter_selection --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-codecs --locked`: passed (782 passed, 4 ignored). +- `cargo clippy -p photocraft-codecs --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-codecs --locked --target wasm32-unknown-unknown`: passed. +- `cargo xtask test-corpus -p codecs`: passed (796 passed, 4 ignored). From f6e7fc3c2a7e72afe23f7fce8d30871c303039f2 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 0795/1452] perf(raster): Avoid redundant default pixel work Preserve default sample bits while skipping absent zero tiles and redundant default-pixel copies. Add the previous read path as the oracle. --- crates/raster/src/lib.rs | 118 +++++++++++++++++++++--- docs/sparse-raster-reads-performance.md | 40 ++++++++ 2 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 docs/sparse-raster-reads-performance.md diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 20b809764a..1ea317d955 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -239,11 +239,19 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } + fn default_samples(&self) -> [f32; 8] { + let mut px = [0.0; 8]; + for (i, v) in px.iter_mut().enumerate().take(self.channels()) { + *v = read_sample(&self.default_pixel, self.format.sample, i); + } + px + } + /// Mutable access to a tile, allocating it (filled with the default pixel) or un-sharing it. pub fn tile_mut(&mut self, c: TileCoord) -> &mut Tile { let fmt = self.format; - let dp = self.default_pixel.clone(); - let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, &dp))); + let dp = &self.default_pixel; + let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, dp))); Arc::make_mut(arc) } @@ -319,9 +327,9 @@ impl Surface { /// Drop tiles that are entirely the default pixel. pub fn prune(&mut self) { - let dp = self.default_pixel.clone(); + let dp = self.default_pixel.as_ref(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp)); + self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp)); } #[inline] @@ -411,10 +419,15 @@ impl Surface { let w = r.width() as usize; out.clear(); out.resize(w * r.height() as usize * n, 0.0); - let dp = self.default_pixel(); + let dp = self.default_samples(); + let dp = &dp[..n]; + let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); + if tile.is_none() && zero { + continue; + } for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; @@ -426,9 +439,10 @@ impl Surface { *d = read_sample(&t.data, self.format.sample, base + i); } } + None if n == 1 => dst.fill(dp[0]), None => { for px in dst.chunks_exact_mut(n) { - px.copy_from_slice(&dp); + px.copy_from_slice(dp); } } } @@ -445,7 +459,7 @@ impl Surface { debug_assert_eq!(out.len(), w * r.height() as usize); let fmt = self.format; let n = fmt.channels(); - let dp = to_rgba(&fmt, &self.default_pixel()); + let dp = to_rgba(&fmt, &self.default_samples()); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -510,7 +524,7 @@ impl Surface { let fmt = self.format; let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - let dp = to_rgba(&fmt, &self.default_pixel()); + let dp = to_rgba(&fmt, &self.default_samples()); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; let mut row = Vec::new(); for tc in r.tiles() { @@ -551,10 +565,15 @@ impl Surface { let n = self.channels(); let w = r.width() as usize; let mut out = vec![0.0f32; w * r.height() as usize * n]; - let dp = self.default_pixel(); + let dp = self.default_samples(); + let dp = &dp[..n]; + let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); + if tile.is_none() && zero { + continue; + } for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; @@ -565,7 +584,8 @@ impl Surface { out[o + i] = read_sample(&t.data, self.format.sample, base + i); } } - None => out[o..o + n].copy_from_slice(&dp), + None if n == 1 => out[o] = dp[0], + None => out[o..o + n].copy_from_slice(dp), } } } @@ -1102,6 +1122,58 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_sparse_reads() { + use std::{hint::black_box, time::Instant}; + let rect = Rect::new(0, 0, 6000, 4000); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for default in [0.0, 0.25] { + let mut s = Surface::with_default(PixelFormat::GRAY8.with_sample(depth), &[default]); + s.fill_rect(Rect::new(257, 257, 266, 266), &[0.75]); + assert_eq!(s.read_region(rect), previous_read_region(&s, rect)); + for trial in 0..6 { + let mut paths = [("main", previous_read_region as fn(&Surface, Rect) -> Vec), ("split", Surface::read_region)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&s, rect); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("sparse,{depth:?}-{default},{trial},{name},{elapsed:.6}"); + } + } + } + } + } + fn previous_read_region(surface: &Surface, r: Rect) -> Vec { + let n = surface.channels(); + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize * n]; + let dp = surface.default_pixel(); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let tile = surface.tiles.get(&tc); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; + match tile { + Some(t) => { + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; + for i in 0..n { + out[o + i] = read_sample(&t.data, surface.format.sample, base + i); + } + } + None => out[o..o + n].copy_from_slice(&dp), + } + } + } + } + out + } /// Distinct tile allocations in `s`. fn distinct_tiles(s: &Surface) -> usize { let mut seen: Vec<*const Tile> = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); @@ -1237,27 +1309,47 @@ mod tests { #[test] fn zero_alloc_accessors_match_read_region() { - for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::GRAY8] { - let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + for (fmt, default) in [ + PixelFormat::RGBA8, + PixelFormat::RGBA16, + PixelFormat::RGBA32F, + PixelFormat::GRAYA8, + PixelFormat::CMYKA8, + PixelFormat::GRAY8, + PixelFormat::GRAY8.with_sample(SampleType::F32), + PixelFormat::new(ColorMode::Lab, SampleType::U16, true), + ] + .into_iter() + .flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default))) + { + let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); let r = Rect::new(-300, -20, 300, 40); for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() { let px: Vec = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect(); s.write_pixel(x, y, &px); } let region = s.read_region(r); + assert!(region.iter().map(|v| v.to_bits()).eq(previous_read_region(&s, r).into_iter().map(f32::to_bits))); let mut into = vec![9.0; 3]; s.read_region_into(r, &mut into); assert_eq!(region, into); let mut rgba = vec![[0.0; 4]; (r.width() * r.height()) as usize]; s.read_rgba_into(r, &mut rgba); + let mut rgba8 = vec![[0; 4]; rgba.len()]; + s.read_rgba8_into(r, &mut rgba8); let n = fmt.channels(); for (i, p) in region.chunks_exact(n).enumerate() { let want = to_rgba(&fmt, p); + assert_eq!(rgba8[i], want.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); for c in 0..4 { assert!((rgba[i][c] - want[c]).abs() < 1e-6, "{fmt:?} px {i}"); } let (x, y) = (r.x0 + (i % r.width() as usize) as i32, r.y0 + (i / r.width() as usize) as i32); - assert_eq!(s.sample_channel(x, y, 0), p[0]); + for c in 0..n { + let bits = s.sample_channel(x, y, c).to_bits(); + assert_eq!(bits, p[c].to_bits()); + assert_eq!(bits, into[i * n + c].to_bits()); + } } assert!(s.has_tiles_in(Rect::new(0, 0, 1, 1))); assert!(!s.has_tiles_in(Rect::new(1000, 1000, 1001, 1001))); diff --git a/docs/sparse-raster-reads-performance.md b/docs/sparse-raster-reads-performance.md new file mode 100644 index 0000000000..6a76244a99 --- /dev/null +++ b/docs/sparse-raster-reads-performance.md @@ -0,0 +1,40 @@ +# Sparse raster reads + +Only default-pixel work changes: skip absent zero-default tiles and avoid temporary default-pixel allocations/copies. Read 6000×4000 single-channel pixels (24 MP) with one 9×9 painted patch, at three depths and two defaults. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| U8-0 | 48.484 | 0.430 | -99.1% | +| U8-0.25 | 48.934 | 4.466 | -90.9% | +| U16-0 | 48.517 | 0.441 | -99.1% | +| U16-0.25 | 48.475 | 4.461 | -90.8% | +| F32-0 | 49.066 | 0.479 | -99.0% | +| F32-0.25 | 48.511 | 4.503 | -90.7% | + +These are read_region timings including output allocation. ZIP CRC-32 acceleration is already on main and is not duplicated here. + +The committed oracle is the previous read_region implementation. Unit coverage compares exact float bits and checks read_region_into, RGBA/RGBA8 reads and individual samples across sparse tile boundaries, negative coordinates, signed zero, nonzero defaults and eight pixel formats. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-raster --lib --no-run +# Run the printed executable at normal priority: + benchmark_sparse_reads --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored). +- `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-raster --locked --target wasm32-unknown-unknown`: passed. From 76a6405f9b25491cc1016ed2861e0976f6455b7a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 0796/1452] perf(io): Interleave PSD pixels in the final buffer Remove intermediate band buffers while retaining byte-for-byte equivalence with the previous path at every sample depth. --- crates/io/src/pixels.rs | 142 ++++++++++++++++++++--------- docs/psd-interleave-performance.md | 38 ++++++++ 2 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 docs/psd-interleave-performance.md diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 0167eb3b3f..cca2a8dfd4 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -43,40 +43,43 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl let ch = planes.len(); let row = ch * bps; let mut out = vec![0u8; n * row]; - // One band of rows per core, written straight into its slice of the shared output: the - // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations - // instead of all of them. + if out.is_empty() { + return out; + } let bs = bands(n); - let parts: Vec> = par_map(bs.clone(), |band| { - let mut part = vec![0u8; band.len() * row]; - for (i, p) in band.clone().enumerate() { - for (c, plane) in planes.iter().enumerate() { - let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; - match plane { - Some(pl) if pl.len() >= (p + 1) * bps => { - let src = &pl[p * bps..(p + 1) * bps]; - for k in 0..bps { - dst[k] = src[bps - 1 - k]; - } - if cfg!(target_endian = "big") { - dst.copy_from_slice(src); - } - if invert[c] { - invert_sample(dst, s); - } - } - _ => dst.copy_from_slice(&fill[c]), - } + let step = bs.first().map_or(n, |b| b.len()) * row; + par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| { + for (c, plane) in planes.iter().enumerate() { + let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default(); + let inv = src.len() >= bps && invert[c]; + let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default(); + match s { + SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]), + SampleType::U16 => write_channel(part, ch, c, src, fill, |v| { + let v = u16::from_be_bytes(v); + (if inv { 65535 - v } else { v }).to_ne_bytes() + }), + SampleType::F32 => write_channel(part, ch, c, src, fill, |v| { + // Preserve float bits when no inversion is requested. + if inv { (1.0 - f32::from_be_bytes(v)).to_ne_bytes() } else { u32::from_be_bytes(v).to_ne_bytes() } + }), } } - part }); - for (band, part) in bs.into_iter().zip(parts) { - out[band.start * row..band.end * row].copy_from_slice(&part); - } out } +fn write_channel(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) { + let mut samples = src.as_chunks::().0.iter(); + for pixel in out.chunks_exact_mut(ch * N) { + let dst = &mut pixel[c * N..(c + 1) * N]; + match samples.next() { + Some(sample) => dst.copy_from_slice(&decode(*sample)), + None => dst.copy_from_slice(fill), + } + } +} + /// Splits interleaved native-endian bytes into big-endian planes. pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); @@ -155,6 +158,7 @@ pub fn zero_sample(s: SampleType) -> Vec { } /// Quantizes a normalized value to big-endian bytes. +#[inline] pub fn encode_be(v: f32, s: SampleType, out: &mut Vec) { match s { SampleType::U8 => out.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8), @@ -217,21 +221,29 @@ pub fn bands(n: usize) -> Vec> { mod tests { use super::*; - #[test] - fn banded_shuffles_match_a_sequential_reference() { - fn seq_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { - let bps = s.bytes(); - let ch = planes.len(); - let mut out = vec![0u8; n * ch * bps]; - for (c, plane) in planes.iter().enumerate() { - for i in 0..n { - let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + pub(super) fn previous_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + let bps = s.bytes(); + let ch = planes.len(); + let row = ch * bps; + let mut out = vec![0u8; n * row]; + // One band of rows per core, written straight into its slice of the shared output: the + // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations + // instead of all of them. + let bs = bands(n); + let parts: Vec> = par_map(bs.clone(), |band| { + let mut part = vec![0u8; band.len() * row]; + for (i, p) in band.clone().enumerate() { + for (c, plane) in planes.iter().enumerate() { + let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; match plane { - Some(p) if p.len() >= (i + 1) * bps => { - let src = &p[i * bps..(i + 1) * bps]; + Some(pl) if pl.len() >= (p + 1) * bps => { + let src = &pl[p * bps..(p + 1) * bps]; for k in 0..bps { dst[k] = src[bps - 1 - k]; } + if cfg!(target_endian = "big") { + dst.copy_from_slice(src); + } if invert[c] { invert_sample(dst, s); } @@ -240,8 +252,16 @@ mod tests { } } } - out + part + }); + for (band, part) in bs.into_iter().zip(parts) { + out[band.start * row..band.end * row].copy_from_slice(&part); } + out + } + + #[test] + fn banded_shuffles_match_a_sequential_reference() { fn seq_deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); let n = bytes.len() / (ch * bps).max(1); @@ -260,16 +280,17 @@ mod tests { planes } // Odd pixel counts cross band boundaries; a missing plane exercises the fill path. - for n in [1usize, 7, 64, 1000] { + for n in [0usize, 1, 7, 64, 1000] { for s in [SampleType::U8, SampleType::U16, SampleType::F32] { let bps = s.bytes(); let planes: Vec> = (0..4).map(|c| (0..n * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); let mut refs: Vec> = planes.iter().map(|p| Some(&p[..])).collect(); refs[2] = None; + refs[3] = Some(&planes[3][..(n * bps).saturating_sub(1)]); let fill: Vec> = (0..4).map(|c| vec![c as u8 * 40; bps]).collect(); let invert = [false, true, false, true]; let got = interleave(&refs, &fill, n, s, &invert); - let want = seq_interleave(&refs, &fill, n, s, &invert); + let want = previous_interleave(&refs, &fill, n, s, &invert); assert_eq!(got, want, "interleave n={n}"); let planes_back = deinterleave(&got, 4, s, &invert); let want_back = seq_deinterleave(&got, 4, s, &invert); @@ -287,6 +308,7 @@ mod tests { let fill = vec![zero_sample(s); 3]; let inv = [false, true, false]; let il = interleave(&refs, &fill, 4, s, &inv); + assert_eq!(interleave(&refs, &[], 4, s, &inv), il); let back = deinterleave(&il, 3, s, &inv); if s == SampleType::F32 { // 1 - (1 - v) may not be exact for floats; only check non-inverted planes. @@ -302,6 +324,12 @@ mod tests { fn missing_planes_use_fill() { let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]); assert_eq!(il, vec![9, 5, 9, 6]); + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + for n in [1, 1000] { + let fill = vec![vec![9; s.bytes()]; 2]; + assert_eq!(interleave(&[None, Some(&[1, 2, 3][..s.bytes() - 1])], &fill, n, s, &[]), vec![9; n * 2 * s.bytes()]); + } + } } #[test] @@ -343,3 +371,35 @@ mod tests { assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); } } + +#[cfg(test)] +mod measurement { + use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_interleave() { + use std::{hint::black_box, time::Instant}; + const N: usize = 24_000_000; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let bps = sample.bytes(); + let planes: Vec> = (0..4).map(|c| (0..N * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); + let refs: Vec<_> = planes.iter().map(|p| Some(p.as_slice())).collect(); + let fill = vec![vec![0; bps]; 4]; + let invert = [false, true, false, true]; + assert_eq!(tests::previous_interleave(&refs, &fill, N, sample, &invert), interleave(&refs, &fill, N, sample, &invert)); + for trial in 0..6 { + let mut paths = [("main", tests::previous_interleave as fn(_, _, _, _, _) -> _), ("split", interleave)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&refs, &fill, N, sample, &invert); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("interleave,{sample:?},{trial},{name},{elapsed:.6}"); + } + } + } + } +} diff --git a/docs/psd-interleave-performance.md b/docs/psd-interleave-performance.md new file mode 100644 index 0000000000..99ae70824f --- /dev/null +++ b/docs/psd-interleave-performance.md @@ -0,0 +1,38 @@ +# PSD pixel buffer allocation + +Only PSD pixel shuffling and sample-codec inlining change. Interleave writes into the final buffer rather than allocating an intermediate buffer per band. The kernel processes 24 million pixels with four full planes at 8-bit, 16-bit and float depth. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| U8 | 20.493 | 5.097 | -75.1% | +| U16 | 31.827 | 6.312 | -80.2% | +| F32 | 41.915 | 11.305 | -73.0% | + +These are interleave-kernel timings, not whole PSD import/export timings. Direct RLE output is already on main and is not part of this PR. + +The committed equivalence test uses the literal previous main-branch interleave implementation. It compares raw bytes across U8/U16/F32, empty and partial bands, missing/truncated planes, fill pixels and inversion. Existing deinterleave reference and big-endian sample-codec round trips cover the companion paths. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-io --lib --no-run +# Run the printed executable at normal priority: + benchmark_interleave --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored). +- `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-io --locked --target wasm32-unknown-unknown`: passed. +- `cargo xtask test-corpus -p io`: passed (646 passed, 6 ignored). From 02a63c94c2d43d5f272837154a5451a9d58eb6ed Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 0797/1452] perf(format): Reuse Rust Zstandard workspaces Keep fresh compression for partial blocks and verify independent frames against the previous encoder. Record the isolated allocation-reuse effect. --- crates/format/src/store.rs | 77 +++++++++++++++++++++++++---- docs/zstd-workspaces-performance.md | 35 +++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 docs/zstd-workspaces-performance.md diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 7da5dd6b53..96647d0961 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -26,9 +26,7 @@ fn blob_path(h: &str) -> String { format!("blobs/{h}.zst") } -fn compress(data: &[u8]) -> Vec { - ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) -} +type Compressor<'a> = ruzstd::encoding::FrameCompressor>, Vec, ruzstd::encoding::MatchGeneratorDriver>; /// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the /// decoded length must be exactly that (`Corrupt` otherwise). @@ -416,8 +414,16 @@ enum Object { } impl Object { - fn compressed(&self) -> Vec { - compress(&self.content()) + fn compressed<'a>(&'a self, compressor: &mut Compressor<'a>) -> Vec { + let bytes = self.content(); + // ruzstd's pooled suffix tables retain the first block's size. + if !bytes.len().is_multiple_of(128 << 10) { + return ruzstd::encoding::compress_to_vec(bytes.as_ref(), ruzstd::encoding::CompressionLevel::Fastest); + } + compressor.set_source(std::io::Cursor::new(bytes)); + compressor.set_drain(Vec::new()); + compressor.compress(); + compressor.set_drain(Vec::new()).unwrap_or_default() } /// The bytes this object stores (tiles little-endian). @@ -689,18 +695,20 @@ type Compressed<'a> = (&'a String, Arc>); fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result>> { let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }; if threads < 2 || objects.len() < 2 { - return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect()); + let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); + return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed(&mut compressor)))).collect()); } let next = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|sc| { let hs: Vec<_> = (0..threads.min(objects.len())) .map(|_| { sc.spawn(|| { + let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); let mut out = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let Some((p, o)) = objects.get(i) else { break }; - out.push((*p, Arc::new(o.compressed()))); + out.push((*p, Arc::new(o.compressed(&mut compressor)))); } out }) @@ -793,7 +801,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed())) + fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed(&mut Compressor::new(ruzstd::encoding::CompressionLevel::Fastest)))) } }; z.add(path, &data)?; @@ -828,6 +836,7 @@ impl PcraftWriter { None => 0, }; let recheck = self.rolling_recheck(&p.objects); + let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); for (path, obj) in &p.objects { let object_path = dir.join(path); // bundle_root and list_objects validated the fixed parent directories once. Check @@ -862,7 +871,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed())?; + write_bundle_file(dir, &object_path, &obj.compressed(&mut compressor))?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -1011,3 +1020,53 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { crate::atomic::atomic_write(path, data)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "manual 24 MP RGBA8-equivalent release benchmark"] + fn benchmark_workspace_reuse() { + use std::{hint::black_box, time::Instant}; + let data: Vec<_> = (0..367).map(|seed| Arc::new((0..262_144).map(|i| ((i * 37 + i / 97 + seed * 13) % 251) as u8).collect())).collect(); + let objects: Vec<_> = data.into_iter().map(Object::Blob).collect(); + for trial in 0..6 { + let mut paths = ["main", "split"]; + if trial % 2 == 1 { + paths.reverse(); + } + for name in paths { + let t = Instant::now(); + let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); + let bytes: usize = objects + .iter() + .map(|o| { + let encoded = if name == "main" { + ruzstd::encoding::compress_to_vec(o.content().as_ref(), ruzstd::encoding::CompressionLevel::Fastest) + } else { + o.compressed(&mut compressor) + }; + black_box(encoded).len() + }) + .sum(); + println!("workspace,24mp-rgba8,{trial},{name},{:.6},{bytes}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + + #[test] + fn reused_compressor_produces_independent_frames() { + let data: Vec>> = [0, 1, 8, 131_072, 0, 262_144, 262_147, 131_072, 1] + .into_iter() + .map(|n| Arc::new((0..n).map(|i| ((i * 37 + i / 97) % 251) as u8).collect())) + .collect(); + let objects: Vec<_> = data.iter().cloned().map(Object::Blob).collect(); + let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); + for (object, bytes) in objects.iter().zip(&data) { + let encoded = object.compressed(&mut compressor); + assert_eq!(encoded, ruzstd::encoding::compress_to_vec(bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + assert_eq!(decompress(&encoded, bytes.len(), "test").unwrap(), **bytes); + } + } +} diff --git a/docs/zstd-workspaces-performance.md b/docs/zstd-workspaces-performance.md new file mode 100644 index 0000000000..48f35252df --- /dev/null +++ b/docs/zstd-workspaces-performance.md @@ -0,0 +1,35 @@ +# Rust Zstandard workspace reuse + +Only the existing pure-Rust compressor workspace is reused per worker/save. No new backend or C dependency. The kernel compresses 367 independent 256 KiB objects, approximately 24 MP of RGBA8 data, with the fresh main path and reused path alternating order. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| 24mp-rgba8 | 125.373 | 121.006 | -3.5% | + +Both paths produced 1,352,031 encoded bytes. These are serial compression-kernel timings; they do not measure ZIP assembly, hashing, filesystem work or parallel save latency. + +The committed unit test compares each frame byte-for-byte with the previous fresh compress_to_vec path and decodes it, across empty/short/full/partial block inputs and size transitions, including empty input after nonempty input. Partial-block objects keep the fresh-compressor fallback. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-format --lib --no-run +# Run the printed executable at normal priority: + benchmark_workspace_reuse --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-format --locked`: passed (147 passed, 1 ignored). +- `cargo clippy -p photocraft-format --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-format --locked --target wasm32-unknown-unknown`: passed. From cb1b47945261043edf1696d4c8575d0a163347c5 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:37:54 +0300 Subject: [PATCH 0798/1452] fix(web): start the app when the server already inflated its gzip part (#7639) --- apps/photocraft-web-boot/Cargo.toml | 2 +- apps/photocraft-web-boot/src/main.rs | 105 +++++++++++++++++++++++++-- packaging/web/README.md | 4 + 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml index dd12341af2..7443ead089 100644 --- a/apps/photocraft-web-boot/Cargo.toml +++ b/apps/photocraft-web-boot/Cargo.toml @@ -21,4 +21,4 @@ flate2 = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs index 56c2bf1517..c8ba439208 100644 --- a/apps/photocraft-web-boot/src/main.rs +++ b/apps/photocraft-web-boot/src/main.rs @@ -8,7 +8,10 @@ //! one part, however large the app grows. //! //! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80). -//! In one that can't, this module inflates them itself, a little slower. +//! In one that can't, this module inflates them itself, a little slower. Some servers recognise +//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic` +//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then +//! taken as it is, by its first bytes. //! //! A download that fails is tried again, the later tries past the browser's cache (a bad copy //! there would otherwise fail forever). When every try fails the page says so and offers a reload. @@ -36,6 +39,34 @@ fn parts(list: &str) -> Option> { (!parts.is_empty() && parts.iter().all(plain)).then_some(parts) } +/// What the joined parts hold, told by their first bytes. +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Payload { + /// The gzip packaging made, still to be inflated. + Gzip, + /// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser + /// already undid. + Wasm, +} + +/// Enough leading bytes to tell the payloads apart. +#[cfg(any(target_arch = "wasm32", test))] +const HEAD: usize = 4; + +/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or +/// `None` when it is neither, such as a web page or nothing at all. +#[cfg(any(target_arch = "wasm32", test))] +fn payload(head: &[u8]) -> Option { + if head.starts_with(&[0x1f, 0x8b]) { + Some(Payload::Gzip) + } else if head.starts_with(b"\0asm") { + Some(Payload::Wasm) + } else { + None + } +} + /// Gzip inflated a chunk at a time, for browsers that can't do it themselves. #[cfg(any(target_arch = "wasm32", test))] struct Inflater(flate2::write::GzDecoder>); @@ -69,11 +100,11 @@ mod boot { use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ - Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream, - TransformStreamDefaultController, Transformer, WritableStream, + Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, + TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter, }; - use super::Inflater; + use super::{HEAD, Inflater, Payload}; /// Where packaging puts the app, next to this module's own files. const PARTS: &str = "./app/parts.txt"; @@ -172,11 +203,71 @@ mod boot { Ok(compressed.pipe_through(&pair)) } + /// The next chunk `reader` gives, or `None` at the end of the stream. + async fn read(reader: &ReadableStreamDefaultReader) -> Result, JsValue> { + let result = JsFuture::from(reader.read()).await?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() { + return Ok(None); + } + Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?)) + } + + /// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up + /// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending. + async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await { + let _ = reader.cancel_with_reason(&e); + return Err(e); + } + Ok(()) + } + + /// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it. + async fn pass_on(held: Vec, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + for chunk in &held { + write(chunk, reader, writer).await?; + } + while let Some(chunk) = read(reader).await? { + write(&chunk, reader, writer).await?; + } + JsFuture::from(writer.close()).await?; + Ok(()) + } + + /// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the + /// browser has already done that. + async fn unpacked(joined: &ReadableStream) -> Result { + let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into(); + let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new()); + while head.len() < HEAD { + let Some(chunk) = read(&reader).await? else { break }; + let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length()); + head.extend(chunk.subarray(0, wanted).to_vec()); + held.push(chunk); + } + let Some(payload) = super::payload(&head) else { + let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly"); + // Stops the rest of the download, which nothing will read. + let _ = reader.cancel_with_reason(&e); + return Err(e); + }; + let rest = TransformStream::new()?; + let writer = rest.writable().get_writer()?; + spawn_local(async move { + if let Err(e) = pass_on(held, &reader, &writer).await { + let _ = writer.abort_with_reason(&e); + } + }); + match payload { + Payload::Gzip => inflated(&rest.readable()), + Payload::Wasm => Ok(rest.readable()), + } + } + async fn run(fresh: bool) -> Result<(), JsValue> { let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default(); let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?; let joined = TransformStream::new()?; - let wasm = inflated(&joined.readable())?; // Why the download stopped: the app's loader only reports that its stream broke. let cause = Rc::new(RefCell::new(None)); let failed = cause.clone(); @@ -187,6 +278,10 @@ mod boot { failed.replace(Some(e)); } }); + let wasm = match unpacked(&joined.readable()).await { + Ok(wasm) => wasm, + Err(e) => return Err(cause.take().unwrap_or(e)), + }; let headers = Headers::new()?; headers.set("Content-Type", "application/wasm")?; let init = ResponseInit::new(); diff --git a/packaging/web/README.md b/packaging/web/README.md index e96dc38257..1f62de0a90 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -73,6 +73,10 @@ save/download work before closing or reloading the page. A browser with WebGPU o required; `?webgl` forces the fallback when troubleshooting. In a browser that can't inflate a stream itself (no `DecompressionStream`: before Safari 16.4 and Firefox 113), the release zip's site inflates the app's parts in its own code, a little slower. +A server that recognises the gzip in a part and sends it with `Content-Encoding: gzip` or +`x-gzip` (Apache with `mod_mime_magic` does) works too: the browser inflates it on the way, and +the page then uses it as it is. (That holds while the app ships as one part, as it does today: +such a server labels only the first part, the one that starts with gzip's header.) To verify the browser deployment, open the page, open an image and save/export it. From af0857372ba55522dc1b557a28c18e4efb46db9d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:37:55 +0300 Subject: [PATCH 0799/1452] test(web): the boot module tells gzip from wasm the browser already inflated (#7639) --- apps/photocraft-web-boot/src/main.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs index c8ba439208..c7dc50d9d5 100644 --- a/apps/photocraft-web-boot/src/main.rs +++ b/apps/photocraft-web-boot/src/main.rs @@ -353,7 +353,7 @@ fn main() { #[cfg(test)] mod tests { - use super::{Inflater, MARKER, parts}; + use super::{HEAD, Inflater, MARKER, Payload, parts, payload}; fn gzip(bytes: &[u8]) -> Vec { let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); @@ -412,6 +412,22 @@ mod tests { assert!(run(b"").is_err(), "nothing at all"); } + /// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with + /// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts + /// are then the app's WebAssembly, which must start as it is, not be inflated a second time. + #[test] + fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() { + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + wasm.extend(sample()); + let packed = gzip(&wasm); + assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged"); + assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser"); + assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD"); + assert_eq!(payload(b""), None, "a web page"); + assert_eq!(payload(b"\0as"), None, "a cut-off wasm header"); + assert_eq!(payload(b""), None, "nothing at all"); + } + #[test] fn a_parts_list_gives_its_names_in_order() { let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n"); From caca15106e2c301813657922517fdfeeeb5de77f Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Mon, 19 Oct 2026 13:27:37 +0300 Subject: [PATCH 0800/1452] fix(actions): refuse lossy fractional Ripple ATN export --- crates/engine/src/atn_cmds.rs | 14 +- crates/engine/tests/atn_ripple_amount.rs | 156 +++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/atn_ripple_amount.rs diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..31519677d4 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -389,6 +389,8 @@ enum Kind { Pct, Ang, Long, + /// An integer field whose value must survive conversion without rounding or saturation. + ExactLong, Dbl, Bool, /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs. @@ -406,7 +408,7 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { let mut m = Map::new(); for F(key, param, kind) in fields { let v = match kind { - Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from), + Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::ExactLong | Kind::Dbl => get_num(d, key).map(Value::from), Kind::Bool => get_bool(d, key).map(Value::from), Kind::Enum(_, table) => match get_enum(d, key) { Some(e) => Some(Value::from(lookup(table, &e)?)), @@ -436,6 +438,14 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { Kind::Pct => pct(v.as_f64()?), Kind::Ang => ang(v.as_f64()?), Kind::Long => long(v.as_f64()?), + Kind::ExactLong => { + let number = v.as_f64()?; + let integer = number as i32; + if f64::from(integer) != number { + return None; + } + D::Integer(integer) + } Kind::Dbl => D::Double(v.as_f64()?), Kind::Bool => D::Boolean(v.as_bool()?), Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), @@ -2657,7 +2667,7 @@ const EVENTS: &[Event] = &[ "filter.other.offset", &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), - ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), + ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). diff --git a/crates/engine/tests/atn_ripple_amount.rs b/crates/engine/tests/atn_ripple_amount.rs new file mode 100644 index 0000000000..8149a838c6 --- /dev/null +++ b/crates/engine/tests/atn_ripple_amount.rs @@ -0,0 +1,156 @@ +//! #7670: integer ATN Ripple amounts must never silently replace native fractions. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const RIPPLE: &str = "filter.distort.ripple"; + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 1.0])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Fractions are valid native edits, but the existing integer event cannot carry them. +#[test] +fn fractional_amounts_remain_native_and_are_refused_by_strict_export() { + let mut silently_exported = Vec::new(); + for depth in [8, 16, 32] { + for size in ["small", "medium", "large"] { + for amount in [81.4, -81.4, 0.5, -0.5, 998.9, -998.9] { + let params = json!({"amount": amount, "size": size}); + let action = Action::new("Fractional Ripple", vec![(RIPPLE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(RIPPLE, params).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Fractional Ripple"})).unwrap(); + assert_eq!(pixels(&direct), pixels(&native)); + + if amount == 81.4 { + let mut rounded = fixture(depth); + rounded.execute(RIPPLE, json!({"amount": 81, "size": size})).unwrap(); + assert_ne!(pixels(&direct), pixels(&rounded), "fixture must expose rounding: {depth}, {size}"); + } + if let Ok((bytes, skipped)) = atn_cmds::export("Ripple", &[&action], &[], false) { + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); + let exported = imported.actions[0].steps[0].1["amount"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Fractional Ripple"})).unwrap(); + let changed = pixels(&replay) != pixels(&direct); + if amount == 81.4 { + assert!(changed, "baseline must expose the reported pixel loss: {depth}, {size}"); + } + silently_exported.push(format!("depth {depth}, {size}: {amount} -> {exported}, pixels changed: {changed}")); + } + } + } + } + assert!(silently_exported.is_empty(), "strict export silently accepted fractional amounts:\n{}", silently_exported.join("\n")); +} + +/// Whole amounts and omitted defaults still round-trip through binary ATN and actual pixels. +#[test] +fn integer_amounts_sizes_and_defaults_preserve_playback() { + for depth in [8, 16, 32] { + let mut cases = vec![Value::Null, json!({}), json!({"target": "pixels"})]; + for size in ["small", "medium", "large"] { + for amount in [-999, -81, 0, 81, 999] { + cases.push(json!({"amount": amount, "size": size})); + } + } + for params in cases { + let action = Action::new("Integer Ripple", vec![(RIPPLE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(RIPPLE, params.clone()).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Integer Ripple"})).unwrap(); + assert_eq!(pixels(&native), pixels(&direct)); + + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + if let Some(amount) = params.get("amount").and_then(Value::as_i64) { + assert!(matches!(descriptor.get("Amnt"), Some(D::Integer(n)) if i64::from(*n) == amount)); + } + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, RIPPLE); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Integer Ripple"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "depth {depth}: {params}"); + } + } +} + +/// Opt-in skipping reports precisely the lost edits, keeping the supported steps in order. +#[test] +fn skipping_fractional_amounts_reports_action_and_step() { + let action = + Action::new("Mixed Ripple", [10.0, 81.4, -20.0, -0.5].into_iter().map(|amount| (RIPPLE.into(), json!({"amount": amount, "size": "small"}))).collect()); + let error = atn_cmds::export("Ripple", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2 of `Mixed Ripple`"), "{error}"); + assert!(error.contains(RIPPLE) && error.contains("skipUnsupported"), "{error}"); + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); + assert_eq!( + skipped, + vec![ + atn_cmds::Skipped { action: "Mixed Ripple".into(), step: 1, id: RIPPLE.into() }, + atn_cmds::Skipped { action: "Mixed Ripple".into(), step: 3, id: RIPPLE.into() }, + ] + ); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), 2); + for (step, amount) in steps.iter().zip([10.0, -20.0]) { + assert_eq!(step.0, RIPPLE); + assert_eq!(step.1["amount"].as_f64(), Some(amount)); + assert_eq!(step.1["size"], "small"); + } +} + +/// A dedicated Ripple mapping must retain the plain-event target restriction. +#[test] +fn non_pixel_targets_and_malformed_fields_are_still_refused() { + for params in [ + json!({"amount": 81, "target": "mask"}), + json!({"amount": 81, "target": "quickMask"}), + json!({"amount": 81, "target": "channel"}), + json!({"amount": 81, "target": null}), + json!({"amount": null}), + json!({"amount": "81.4"}), + json!({"amount": 2147483648_i64}), + json!({"amount": -2147483649_i64}), + json!({"amount": 1e100}), + json!({"amount": 81, "size": "unknown"}), + ] { + let action = Action::new("Unsupported Ripple", vec![(RIPPLE.into(), params.clone())]); + assert!(atn_cmds::export("Ripple", &[&action], &[], false).is_err(), "{params}"); + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Ripple".into(), step: 0, id: RIPPLE.into() }]); + assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); + } +} From 4d4ed4f0adfaa5f0a4260d6c8b777af68ed84862 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:33:33 +0800 Subject: [PATCH 0801/1452] ui: the keyboard font step uses the stable order too --- crates/ui-egui/src/type_tool.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 22db7516a9..6b86204a23 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1607,9 +1607,10 @@ pub(crate) fn family_picker_in( // the frame. Cancel it, so the arrow only steps the font. ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } - // The wheel's step: a negative notch is the next family. + // The wheel's step: a negative notch is the next family. Steps use a stable order (no + // recents hoisting), so recording the picked family cannot make the next step jump back. let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); - let rows = font_menu_rows(families, labels, ""); + let rows = font_menu_rows(families, labels, "", &[]); if let Some(i) = rows.iter().position(|(f, _)| *f == current) && let Some(n) = wheel_family_index(i, rows.len(), notches) && let Some((f, _)) = rows.get(n) From b0408549b7de9ed6c4e354b571365abcc2f45ec9 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:33:33 +0800 Subject: [PATCH 0802/1452] ui: adapt the new font picker test to the recents argument --- crates/ui-egui/src/type_font_picker_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 91c22496ea..0f3c1871e9 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -290,14 +290,15 @@ fn arrows_on_the_field_step_through_the_families_after_a_pick() { fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); - let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let state = + State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off, recents: Vec::new() }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { let _ = ui.button("Above"); s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); s.popup = s.button.with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &s.recents); let _ = ui.button("Below"); }, state, From 80f896210a6871c9bc3b258f30e318fca2a2cd7a Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Mon, 19 Oct 2026 13:34:32 +0300 Subject: [PATCH 0803/1452] perf(compose): write band tiles as they finish, not after the band Parallel bands each collected their finished tiles until the band's last one was done, so with work stealing many bands could hold most of their tiles at once, close to a second copy of the output. Each tile is now put into its band behind a lock as soon as it is rendered; only the tiles in flight are held, as before the bands ran in parallel. --- crates/compose/src/lib.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5d5a733e2b..00cd5f4d00 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -194,13 +194,16 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer }); } else { // Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's - // slowest tile doesn't leave the other cores idle until the next band starts. + // slowest tile doesn't leave the other cores idle until the next band starts. Each + // tile is written as soon as it is done, so only the tiles in flight are held. out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { let y0 = rect.y0 + i as i32 * tile; - let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); - for part in &parts { - put(band, y0, part); - } + let band = std::sync::Mutex::new(band); + band_tiles(y0).into_par_iter().for_each(|t| { + let part = run(t); + let mut band = band.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + put(&mut band, y0, &part); + }); }); } } From 9f5559e51511f60e53ff3c3ca666659b08297960 Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Mon, 19 Oct 2026 14:13:05 +0300 Subject: [PATCH 0804/1452] fix(actions): refuse lossy fractional Crystallize ATN export --- crates/engine/src/atn_cmds.rs | 16 +- .../engine/tests/atn_crystallize_cell_size.rs | 151 ++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/atn_crystallize_cell_size.rs diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..aaf869225f 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -389,6 +389,8 @@ enum Kind { Pct, Ang, Long, + /// An integer field whose value must survive conversion without rounding or saturation. + ExactLong, Dbl, Bool, /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs. @@ -402,11 +404,12 @@ enum Kind { /// One descriptor key ↔ one command param. struct F(&'static str, &'static str, Kind); +/// Read the supported descriptor fields into native command parameters. fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { let mut m = Map::new(); for F(key, param, kind) in fields { let v = match kind { - Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from), + Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::ExactLong | Kind::Dbl => get_num(d, key).map(Value::from), Kind::Bool => get_bool(d, key).map(Value::from), Kind::Enum(_, table) => match get_enum(d, key) { Some(e) => Some(Value::from(lookup(table, &e)?)), @@ -428,6 +431,7 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { Some(m) } +/// Encode supported parameters, refusing a provided field that cannot be represented. fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { for F(key, param, kind) in fields { let Some(v) = p.get(param) else { continue }; @@ -436,6 +440,14 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { Kind::Pct => pct(v.as_f64()?), Kind::Ang => ang(v.as_f64()?), Kind::Long => long(v.as_f64()?), + Kind::ExactLong => { + let number = v.as_f64()?; + let integer = number as i32; + if f64::from(integer) != number { + return None; + } + D::Integer(integer) + } Kind::Dbl => D::Double(v.as_f64()?), Kind::Bool => D::Boolean(v.as_bool()?), Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), @@ -2658,7 +2670,7 @@ const EVENTS: &[Event] = &[ &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), - ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), + ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). Event { diff --git a/crates/engine/tests/atn_crystallize_cell_size.rs b/crates/engine/tests/atn_crystallize_cell_size.rs new file mode 100644 index 0000000000..7b72a6ff70 --- /dev/null +++ b/crates/engine/tests/atn_crystallize_cell_size.rs @@ -0,0 +1,151 @@ +//! #7669: strict ATN export must not replace a native fractional Crystallize cell size. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const CRYSTALLIZE: &str = "filter.pixelate.crystallize"; + +/// Procedural colours expose cell-boundary changes without relying on external images. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 1.0])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +/// Compare the actual flattened document rather than just the imported parameters. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Fractions remain valid native edits; strict integer ATN conversion must refuse them. +#[test] +fn fractional_cell_sizes_remain_native_and_are_refused_by_strict_export() { + let mut silently_exported = Vec::new(); + for depth in [8, 16, 32] { + for cell_size in [3.1, 5.4, 5.5, 5.6, 10.25, 299.9] { + let params = json!({"cellSize": cell_size}); + let action = Action::new("Fractional Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params).unwrap(); + let expected = pixels(&direct); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + assert_eq!(pixels(&native), expected); + + if cell_size == 5.4 { + let mut rounded = fixture(depth); + rounded.execute(CRYSTALLIZE, json!({"cellSize": 5})).unwrap(); + assert_ne!(pixels(&rounded), expected, "fixture must expose 5.4 vs 5: {depth}"); + } + if let Ok((bytes, skipped)) = atn_cmds::export("Crystallize", &[&action], &[], false) { + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); + let exported = imported.actions[0].steps[0].1["cellSize"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + let changed = pixels(&replay) != expected; + if cell_size == 5.4 { + assert!(changed, "baseline must expose the reported pixel loss: {depth}"); + } + silently_exported.push(format!("depth {depth}: {cell_size} -> {exported}, pixels changed: {changed}")); + } + } + } + assert!(silently_exported.is_empty(), "strict export silently accepted fractional cell sizes:\n{}", silently_exported.join("\n")); +} + +/// Integer sizes, bounds and omitted defaults retain their binary descriptor and playback. +#[test] +fn integer_cell_sizes_and_defaults_preserve_playback() { + for depth in [8, 16, 32] { + let mut cases = vec![Value::Null, json!({}), json!({"target": "pixels"}), json!({"cellSize": 5.0})]; + cases.extend([3, 5, 10, 100, 300].map(|cell_size| json!({"cellSize": cell_size}))); + for params in cases { + let action = Action::new("Integer Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params.clone()).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&native), pixels(&direct)); + + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + if let Some(cell_size) = params.get("cellSize").and_then(Value::as_f64) { + assert!(matches!(descriptor.get("ClSz"), Some(D::Integer(n)) if f64::from(*n) == cell_size)); + } + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, CRYSTALLIZE); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "depth {depth}: {params}"); + } + } +} + +/// Skipping is explicit and reports exactly which edits were lost, preserving supported order. +#[test] +fn skipping_fractional_cell_sizes_reports_action_and_step() { + let action = + Action::new("Mixed Crystallize", [5.0, 5.4, 10.0, 10.25].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect()); + let error = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2 of `Mixed Crystallize`"), "{error}"); + assert!(error.contains(CRYSTALLIZE) && error.contains("skipUnsupported"), "{error}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!( + skipped, + vec![ + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 1, id: CRYSTALLIZE.into() }, + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 3, id: CRYSTALLIZE.into() }, + ] + ); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), 2); + for (step, cell_size) in steps.iter().zip([5.0, 10.0]) { + assert_eq!(step.0, CRYSTALLIZE); + assert_eq!(step.1["cellSize"].as_f64(), Some(cell_size)); + } +} + +/// The plain event still cannot carry mask/channel targets or nonnumeric cell sizes. +#[test] +fn non_pixel_targets_and_malformed_fields_are_still_refused() { + for params in [ + json!({"cellSize": 5, "target": "mask"}), + json!({"cellSize": 5, "target": "quickMask"}), + json!({"cellSize": 5, "target": "channel"}), + json!({"cellSize": 5, "target": null}), + json!({"cellSize": null}), + json!({"cellSize": "5.4"}), + json!({"cellSize": 2147483648_i64}), + json!({"cellSize": -2147483649_i64}), + json!({"cellSize": 1e100}), + ] { + let action = Action::new("Unsupported Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + assert!(atn_cmds::export("Crystallize", &[&action], &[], false).is_err(), "{params}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Crystallize".into(), step: 0, id: CRYSTALLIZE.into() }]); + assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); + } +} From 6b1aa81561d6cc8487a95da27b95ef6d2d4199af Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:05 +0200 Subject: [PATCH 0805/1452] docs(perf): measure PNG filtering on real images --- docs/png-filter-selection-performance.md | 42 +++++++++++------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md index 34938b6d84..1f7ffeab29 100644 --- a/docs/png-filter-selection-performance.md +++ b/docs/png-filter-selection-performance.md @@ -1,35 +1,31 @@ # PNG filter selection -Only adaptive PNG filter selection changes: stop evaluating filters when their partial cost cannot win, and stop after a zero-cost winner. The kernel processes 4000 rows of 6000 RGBA8 pixels (24 MP) for zero, identical and varied rows; deflate is outside the timer. +Stop evaluating PNG filters when their partial cost cannot win. Filter choice and encoded bytes must remain identical. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +Photo PNG encoding improves, with identical streams. The small designs show little change because they use the existing serial PNG encoder. The previous filter-kernel percentages are not end-to-end results. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| zero | 448.138 | 5.734 | -98.7% | -| identical | 447.020 | 59.819 | -86.6% | -| varied | 450.469 | 277.254 | -38.5% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are serial filter-kernel timings, not end-to-end PNG encoding timings. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed oracle is the previous main-branch filter function. The unit test compares exact output bytes, including the selected filter/tie order, across six bytes-per-pixel layouts, 32 patterns and absent/identical/shifted previous rows. Existing encoder round trips and deterministic worker-count tests remain in place. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-8 | png-encode | 358.725 | 346.020 | -3.5% | -7.0..-0.9% | 24,569,965 / 24,569,965 | +| panasonic-16 | png-encode | 338.106 | 296.860 | -12.2% | -12.3..-9.7% | 111,583,441 / 111,583,441 | +| canon-8 | png-encode | 178.675 | 169.289 | -5.3% | -35.0..-2.7% | 13,720,268 / 13,720,268 | +| canon-16 | png-encode | 162.107 | 145.356 | -10.3% | -12.2..+4.6% | 58,279,529 / 58,279,529 | +| layout-masks | png-encode | 4.037 | 3.986 | -1.3% | -3.3..+1.9% | 42,842 / 42,842 | +| layout-mask-index | png-encode | 11.343 | 11.404 | +0.5% | -0.0..+2.2% | 115,229 / 115,229 | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-codecs --lib --no-run -# Run the printed executable at normal priority: - benchmark_filter_selection --ignored --nocapture --test-threads=1 -``` +The real-image PNG streams match main byte for byte and decode to identical pixels. The committed previous-filter oracle also covers bytes-per-pixel layouts, early exits, zero-cost rows and ties. -## Local validation +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-codecs --locked`: passed (782 passed, 4 ignored). - `cargo clippy -p photocraft-codecs --locked --all-targets -- -D warnings`: passed. From 78a9982eec3508dbd7654b262aea6b9f28386b09 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:06 +0200 Subject: [PATCH 0806/1452] perf(raster): share contiguous region reads --- crates/raster/src/lib.rs | 34 ++++----------- docs/sparse-raster-reads-performance.md | 57 ++++++++++++++----------- 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 1ea317d955..2ebfde9c59 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -419,6 +419,12 @@ impl Surface { let w = r.width() as usize; out.clear(); out.resize(w * r.height() as usize * n, 0.0); + self.read_samples_into(r, out); + } + + fn read_samples_into(&self, r: Rect, out: &mut [f32]) { + let n = self.channels(); + let w = r.width() as usize; let dp = self.default_samples(); let dp = &dp[..n]; let zero = self.default_pixel.iter().all(|&b| b == 0); @@ -431,7 +437,7 @@ impl Surface { for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; - let dst = &mut out[o..o + span]; + let Some(dst) = out.get_mut(o..o + span) else { continue }; match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; @@ -565,31 +571,7 @@ impl Surface { let n = self.channels(); let w = r.width() as usize; let mut out = vec![0.0f32; w * r.height() as usize * n]; - let dp = self.default_samples(); - let dp = &dp[..n]; - let zero = self.default_pixel.iter().all(|&b| b == 0); - for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - if tile.is_none() && zero { - continue; - } - for y in tr.y0..tr.y1 { - for x in tr.x0..tr.x1 { - let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; - match tile { - Some(t) => { - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; - for i in 0..n { - out[o + i] = read_sample(&t.data, self.format.sample, base + i); - } - } - None if n == 1 => out[o] = dp[0], - None => out[o..o + n].copy_from_slice(dp), - } - } - } - } + self.read_samples_into(r, &mut out); out } diff --git a/docs/sparse-raster-reads-performance.md b/docs/sparse-raster-reads-performance.md index 6a76244a99..1cc1a04502 100644 --- a/docs/sparse-raster-reads-performance.md +++ b/docs/sparse-raster-reads-performance.md @@ -1,38 +1,43 @@ # Sparse raster reads -Only default-pixel work changes: skip absent zero-default tiles and avoid temporary default-pixel allocations/copies. Read 6000×4000 single-channel pixels (24 MP) with one 9×9 painted patch, at three depths and two defaults. +Skip absent zero-default tiles and avoid temporary default-pixel allocations and copies. ZIP CRC acceleration is already on main. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +A roughly 4% median dense 16-bit city-photo read slowdown repeated, with paired intervals crossing zero. The local fix shares the existing contiguous row loop between allocating and reusable-buffer reads, removes duplicated code, and preserves zero-filled allocation for allocating reads. Final measurements include all photo depths and authored designs; all outputs match bit for bit. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| U8-0 | 48.484 | 0.430 | -99.1% | -| U8-0.25 | 48.934 | 4.466 | -90.9% | -| U16-0 | 48.517 | 0.441 | -99.1% | -| U16-0.25 | 48.475 | 4.461 | -90.8% | -| F32-0 | 49.066 | 0.479 | -99.0% | -| F32-0.25 | 48.511 | 4.503 | -90.7% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are read_region timings including output allocation. ZIP CRC-32 acceleration is already on main and is not duplicated here. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed oracle is the previous read_region implementation. Unit coverage compares exact float bits and checks read_region_into, RGBA/RGBA8 reads and individual samples across sparse tile boundaries, negative coordinates, signed zero, nonzero defaults and eight pixel formats. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-16 | read (fixed) | 54.689 | 41.140 | -24.8% | -28.0..-23.8% | — | +| panasonic-16 | render (fixed) | 38.492 | 38.345 | -0.4% | -1.8..+2.0% | — | +| canon-16 | read (fixed) | 27.634 | 19.236 | -30.4% | -34.0..-23.3% | — | +| panasonic-8 | read (fixed) | 50.429 | 20.210 | -59.9% | -62.2..-56.7% | — | +| panasonic-8 | render (fixed) | 30.953 | 30.670 | -0.9% | -3.4..+1.5% | — | +| panasonic-32 | read (fixed) | 91.120 | 69.922 | -23.3% | -23.6..-20.7% | — | +| panasonic-32 | render (fixed) | 39.436 | 39.867 | +1.1% | -4.5..+3.7% | — | +| canon-8 | read (fixed) | 24.899 | 10.543 | -57.7% | -58.5..-54.9% | — | +| canon-8 | render (fixed) | 16.925 | 16.901 | -0.1% | -5.6..+5.5% | — | +| canon-16 | render (fixed) | 21.569 | 21.716 | +0.7% | -2.2..+2.4% | — | +| canon-32 | read (fixed) | 45.494 | 35.200 | -22.6% | -23.5..-21.5% | — | +| canon-32 | render (fixed) | 22.056 | 22.180 | +0.6% | -1.8..+6.9% | — | +| layout-masks | read (fixed) | 35.240 | 3.438 | -90.2% | -90.7..-89.9% | — | +| layout-masks | render (fixed) | 8.167 | 7.461 | -8.6% | -10.6..-8.3% | — | +| layout-mask-index | read (fixed) | 2.314 | 1.051 | -54.6% | -61.7..-50.0% | — | +| layout-mask-index | render (fixed) | 1.926 | 1.827 | -5.1% | -13.0..+0.6% | — | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-raster --lib --no-run -# Run the printed executable at normal priority: - benchmark_sparse_reads --ignored --nocapture --test-threads=1 -``` +All real read-region and render float bits match main. The committed previous-read-region oracle also covers sparse boundaries, negative coordinates, signed zero, defaults and eight pixel formats. -## Local validation +Current local checks: `cargo test -p photocraft-raster --locked --lib` (27 passed, one ignored manual benchmark); strict all-target Clippy; affected wasm32 check. All passed. The existing previous-read-path oracle covers the shared loop. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored). - `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed. From b5b5776a3058b8c7c4920f9728476b07122c635e Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:06 +0200 Subject: [PATCH 0807/1452] perf(io): remove the PSD export inlining hint --- crates/io/src/pixels.rs | 1 - docs/psd-interleave-performance.md | 54 +++++++++++++++++------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index cca2a8dfd4..0251f83755 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -158,7 +158,6 @@ pub fn zero_sample(s: SampleType) -> Vec { } /// Quantizes a normalized value to big-endian bytes. -#[inline] pub fn encode_be(v: f32, s: SampleType, out: &mut Vec) { match s { SampleType::U8 => out.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8), diff --git a/docs/psd-interleave-performance.md b/docs/psd-interleave-performance.md index 99ae70824f..5958442881 100644 --- a/docs/psd-interleave-performance.md +++ b/docs/psd-interleave-performance.md @@ -1,35 +1,43 @@ # PSD pixel buffer allocation -Only PSD pixel shuffling and sample-codec inlining change. Interleave writes into the final buffer rather than allocating an intermediate buffer per band. The kernel processes 24 million pixels with four full planes at 8-bit, 16-bit and float depth. +Write PSD interleaved pixels directly into the final buffer. Direct RLE output is already on main. The unrelated export-side sample-encoder inlining hint is removed locally. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +Whole-photo imports improve by about 4–7%, and the authored designs by about 20–26%. An added export-side inlining hint accompanied a separately confirmed 2.3% float PSD export slowdown (2440.168 → 2497.423 ms). Removing that one line leaves export source equal to main; follow-up float export is +0.4% and 16-bit export +0.9%, with both paired intervals crossing zero. These results supersede the much larger previous interleave-only percentages. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| U8 | 20.493 | 5.097 | -75.1% | -| U16 | 31.827 | 6.312 | -80.2% | -| F32 | 41.915 | 11.305 | -73.0% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are interleave-kernel timings, not whole PSD import/export timings. Direct RLE output is already on main and is not part of this PR. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed equivalence test uses the literal previous main-branch interleave implementation. It compares raw bytes across U8/U16/F32, empty and partial bands, missing/truncated planes, fill pixels and inversion. Existing deinterleave reference and big-endian sample-codec round trips cover the companion paths. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-32 | psd-export (fixed) | 2471.456 | 2481.877 | +0.4% | -0.1..+1.7% | 461,042,978 / 461,042,978 | +| panasonic-16 | psd-export (fixed) | 2649.246 | 2673.071 | +0.9% | -5.2..+1.6% | 274,409,683 / 274,409,683 | +| panasonic-8 | psd-import | 275.828 | 262.237 | -4.9% | -5.9..-4.1% | 98,823,859 / 98,823,859 | +| panasonic-8 | psd-export | 2937.854 | 2915.929 | -0.7% | -11.5..+5.6% | 98,823,859 / 98,823,859 | +| panasonic-16 | psd-import | 365.901 | 349.827 | -4.4% | -4.4..-2.6% | 274,409,683 / 274,409,683 | +| panasonic-32 | psd-import | 360.706 | 334.838 | -7.2% | -12.6..-4.4% | 461,042,978 / 461,042,978 | +| canon-8 | psd-import | 141.331 | 135.878 | -3.9% | -5.1..-3.6% | 56,209,597 / 56,209,597 | +| canon-8 | psd-export | 1052.421 | 1054.472 | +0.2% | -0.6..+1.3% | 56,209,597 / 56,209,597 | +| canon-16 | psd-import | 177.327 | 169.952 | -4.2% | -5.5..-1.7% | 140,673,004 / 140,673,004 | +| canon-16 | psd-export | 1248.449 | 1239.159 | -0.7% | -1.6..+0.6% | 140,673,004 / 140,673,004 | +| canon-32 | psd-import | 175.721 | 163.271 | -7.1% | -9.3..-4.9% | 228,234,238 / 228,234,238 | +| canon-32 | psd-export | 1008.772 | 1005.356 | -0.3% | -1.8..+0.7% | 228,234,238 / 228,234,238 | +| layout-masks | psd-import | 12.509 | 9.981 | -20.2% | -20.8..-14.5% | 493,006 / 493,006 | +| layout-masks | psd-export | 55.113 | 54.714 | -0.7% | -2.3..+1.2% | 493,006 / 493,006 | +| layout-mask-index | psd-import | 3.299 | 2.443 | -25.9% | -30.8..-23.4% | 411,435 / 411,435 | +| layout-mask-index | psd-export | 18.200 | 18.397 | +1.1% | -0.1..+1.7% | 411,435 / 411,435 | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-io --lib --no-run -# Run the printed executable at normal priority: - benchmark_interleave --ignored --nocapture --test-threads=1 -``` +Real PSD exports remain byte-identical and imported layer/mask float samples match main. The committed literal previous-interleave oracle covers depths, bands, fills, truncation and inversion. -## Local validation +Current local checks: affected `pixels::` unit tests (five passed, one ignored manual benchmark); strict all-target IO Clippy; affected IO wasm32 check. All passed. Follow-up exports at float and 16-bit depth remain byte-identical to main; no synthetic timing test was run. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored). - `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed. From f1ba4854d0402a260dfa1e51d09ec1143af42217 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:12 +0200 Subject: [PATCH 0808/1452] docs(perf): record end-to-end workspace results --- docs/zstd-workspaces-performance.md | 50 +++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/zstd-workspaces-performance.md b/docs/zstd-workspaces-performance.md index 48f35252df..9952edc64e 100644 --- a/docs/zstd-workspaces-performance.md +++ b/docs/zstd-workspaces-performance.md @@ -1,33 +1,41 @@ # Rust Zstandard workspace reuse -Only the existing pure-Rust compressor workspace is reused per worker/save. No new backend or C dependency. The kernel compresses 367 independent 256 KiB objects, approximately 24 MP of RGBA8 data, with the fresh main path and reused path alternating order. +Reuse the existing pure-Rust compressor workspace per save worker. Partial-block objects keep the fresh-compressor fallback. No new backend or C dependency. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +Fresh real-document saves are close to neutral. Reusing compressor allocation does not establish a meaningful end-to-end save improvement on these inputs. Every bundle remains byte-identical. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| 24mp-rgba8 | 125.373 | 121.006 | -3.5% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -Both paths produced 1,352,031 encoded bytes. These are serial compression-kernel timings; they do not measure ZIP assembly, hashing, filesystem work or parallel save latency. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed unit test compares each frame byte-for-byte with the previous fresh compress_to_vec path and decodes it, across empty/short/full/partial block inputs and size transitions, including empty input after nonempty input. Partial-block objects keep the fresh-compressor fallback. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-8 | save | 163.156 | 162.549 | -0.4% | -1.4..+0.1% | 49,643,217 / 49,643,217 | +| panasonic-8 | load-own | 396.986 | 397.099 | +0.0% | -0.2..+1.1% | 49,643,217 / 49,643,217 | +| panasonic-16 | save | 355.760 | 353.995 | -0.5% | -0.9..+0.2% | 175,681,852 / 175,681,852 | +| panasonic-16 | load-own | 386.774 | 386.373 | -0.1% | -0.9..+1.0% | 175,681,852 / 175,681,852 | +| panasonic-32 | save | 627.383 | 630.287 | +0.5% | -0.6..+2.2% | 252,231,671 / 252,231,671 | +| panasonic-32 | load-own | 1320.923 | 1320.769 | -0.0% | -0.1..+0.7% | 252,231,671 / 252,231,671 | +| canon-8 | save | 86.768 | 86.708 | -0.1% | -0.9..+1.8% | 26,151,631 / 26,151,631 | +| canon-8 | load-own | 221.917 | 224.088 | +1.0% | -1.2..+1.2% | 26,151,631 / 26,151,631 | +| canon-16 | save | 185.739 | 186.190 | +0.2% | -1.5..+0.9% | 88,750,003 / 88,750,003 | +| canon-16 | load-own | 198.518 | 198.190 | -0.2% | -0.8..+1.0% | 88,750,003 / 88,750,003 | +| canon-32 | save | 335.712 | 338.159 | +0.7% | -1.1..+2.0% | 120,957,984 / 120,957,984 | +| canon-32 | load-own | 787.610 | 786.982 | -0.1% | -1.8..+2.3% | 120,957,984 / 120,957,984 | +| layout-masks | save | 18.907 | 19.230 | +1.7% | -1.7..+4.0% | 433,949 / 433,949 | +| layout-masks | load-own | 16.310 | 16.411 | +0.6% | -2.6..+3.3% | 433,949 / 433,949 | +| layout-mask-index | save | 7.598 | 7.446 | -2.0% | -7.7..+6.3% | 344,892 / 344,892 | +| layout-mask-index | load-own | 6.848 | 6.840 | -0.1% | -2.6..+3.7% | 344,892 / 344,892 | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-format --lib --no-run -# Run the printed executable at normal priority: - benchmark_workspace_reuse --ignored --nocapture --test-threads=1 -``` +Every saved real bundle is byte-identical to main and complete documents round-trip equal. The committed unit oracle compares reused frames with fresh compression, including partial blocks and size transitions. -## Local validation +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-format --locked`: passed (147 passed, 1 ignored). - `cargo clippy -p photocraft-format --locked --all-targets -- -D warnings`: passed. From c8907e2cd2809a328f2c331191f11e90e9857fab Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:23:03 -0600 Subject: [PATCH 0809/1452] Paragraph panel: grey the justify buttons on point text (#7713) Justification needs a paragraph box: Photoshop greys the four justify buttons for point text, which lays out left whatever its alignment. PhotoCraft offered them, and choosing one did nothing visible. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 34 +++++++++++++++++++++------ crates/ui-egui/src/type_tool_tests.rs | 30 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d07b8bb161..bb4780d5e3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2057,9 +2057,20 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc if on { ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside); } - paint(ui, r, if on { t.text } else { t.text_dim }); + let enabled = ui.is_enabled(); + paint( + ui, + r, + if !enabled { + t.text_faint + } else if on { + t.text + } else { + t.text_dim + }, + ); let tip = tip.to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, on, &tip)); resp.on_hover_text(tl!(&tip)).clicked() } @@ -2352,14 +2363,23 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa ("justifyAll", tl!("Justify all"), A::JustifyAll), ]; let cw = cell_width(full, items.len()); + // Justification needs a paragraph box: on point text Photoshop greys the four justify + // buttons (#7713). + let point = target(app).is_some_and(|(layer, _)| box_shape(app, LayerId(layer)).is_none()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; for (key, tip, a) in items { - let clicked = toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| { - // The glyph keeps its size; the cell grows around it. - let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0)); - align_glyph(ui.painter(), g, a, if para.align == a { col } else { t.icon }); - }); + let justify = !matches!(a, A::Left | A::Center | A::Right); + let clicked = ui + .add_enabled_ui(!(point && justify), |ui| { + toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| { + // The glyph keeps its size; the cell grows around it. + let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0)); + let col = if !ui.is_enabled() || para.align == a { col } else { t.icon }; + align_glyph(ui.painter(), g, a, col); + }) + }) + .inner; if clicked { apply(app, ui.ctx(), json!({"align": key})); } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 20e59a01d6..368a725acb 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2130,3 +2130,33 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } + +/// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify +/// buttons are greyed, as in Photoshop; on paragraph text they work. +#[test] +fn justify_buttons_are_greyed_on_point_text() { + use egui_kittest::kittest::{NodeT, Queryable}; + let justify = ["Justify last left", "Justify last centered", "Justify last right", "Justify all"]; + for (paragraph, enabled) in [(false, false), (true, true)] { + let mut app = new_app(); + let mut p = json!({"text": "one two three four", "size": 24, "x": 300, "y": 420}); + if paragraph { + p["box"] = json!([300, 300, 240, 180]); + } + app.run("type.create", p).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::type_tool::character_panel(app, ui, true); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for tip in justify { + assert_eq!(!h.get_by_label(tip).accesskit_node().is_disabled(), enabled, "{tip} on {}", if paragraph { "paragraph text" } else { "point text" }); + } + assert!(!h.get_by_label("Center text").accesskit_node().is_disabled(), "alignment itself stays available"); + } +} From d20a0782da10dcd0c4b4da4900a0b030b0096d8b Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 13:28:32 +0200 Subject: [PATCH 0810/1452] Crop: a W x H crop on an Indexed Color document resamples with Nearest Neighbor (#7722) The Crop tool's W x H x Resolution mode resampled with the chosen filter (Bicubic by default) even on an Indexed Color document, so the result held blends outside the colour table. Like Image Size (#6019), it now falls back to Nearest Neighbor there. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 92bbdd7463..275c82646b 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -531,7 +531,9 @@ impl CropTarget { /// Applies the target to a document that has just been cropped (inside the crop's history step). fn apply(&self, doc: &mut Document) { if let Some((w, h)) = self.size { - resample_doc(doc, w, h, self.filter); + // As for Image Size: an Indexed Color document holds only its colour table's colours (#6019). + let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter }; + resample_doc(doc, w, h, filter); } if let Some(r) = self.resolution { doc.resolution_dpi = r; @@ -1420,6 +1422,32 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + /// #7722: a W x H crop of an Indexed Color document resamples like Image Size (#6019), with + /// Nearest Neighbor, so no pixel gets a blend that isn't in the colour table. + #[test] + fn crop_target_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in [None, Some("bicubic"), Some("automatic"), Some("bilinear"), Some("lanczos")] { + let mut p = json!({"x": 0, "y": 0, "width": 2, "height": 1, "targetWidth": 8, "targetHeight": 1}); + if let Some(r) = resample { + p["resample"] = json!(r); + } + s.execute("image.crop", p).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample:?}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + #[test] fn crop_target_accepts_whole_number_floats() { // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. From dd552f76b1fbd918c2a56a80300f874d50f0594b Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:33:01 +0200 Subject: [PATCH 0811/1452] docs(raster): document touched read helpers --- crates/raster/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 2ebfde9c59..694cdb5751 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -239,6 +239,7 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } + /// Decode the default pixel into fixed storage without allocating. fn default_samples(&self) -> [f32; 8] { let mut px = [0.0; 8]; for (i, v) in px.iter_mut().enumerate().take(self.channels()) { @@ -422,6 +423,7 @@ impl Surface { self.read_samples_into(r, out); } + /// Copy samples into a zero-initialized buffer sized for `r`. fn read_samples_into(&self, r: Rect, out: &mut [f32]) { let n = self.channels(); let w = r.width() as usize; @@ -1105,6 +1107,7 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + /// Manually compare allocating reads with the previous implementation. #[test] #[ignore = "manual 24 MP release benchmark"] fn benchmark_sparse_reads() { @@ -1131,6 +1134,7 @@ mod tests { } } } + /// Previous allocating read path, retained as the bitwise-equivalence oracle. fn previous_read_region(surface: &Surface, r: Rect) -> Vec { let n = surface.channels(); let w = r.width() as usize; @@ -1289,6 +1293,7 @@ mod tests { } } + /// Compare sparse reads and converted accessors with the previous allocating path. #[test] fn zero_alloc_accessors_match_read_region() { for (fmt, default) in [ From b862323a8e1a7aebebd736bd9420e91d784e4ecd Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:39:46 -0600 Subject: [PATCH 0812/1452] Justify buttons: grey when any formatted type layer is point text Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 11 ++++++++++- crates/ui-egui/src/type_tool_tests.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index bb4780d5e3..441048c0a9 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2365,7 +2365,16 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let cw = cell_width(full, items.len()); // Justification needs a paragraph box: on point text Photoshop greys the four justify // buttons (#7713). - let point = target(app).is_some_and(|(layer, _)| box_shape(app, LayerId(layer)).is_none()); + // The panel formats the layer being typed in, else every selected type layer. + let point = match formatting_params(app) { + Some(p) => p.get("layers").and_then(serde_json::Value::as_array).map_or_else( + || p.get("layer").and_then(serde_json::Value::as_u64).into_iter().collect::>(), + |ids| ids.iter().filter_map(serde_json::Value::as_u64).collect(), + ), + None => Vec::new(), + } + .into_iter() + .any(|layer| box_shape(app, LayerId(layer)).is_none()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; for (key, tip, a) in items { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 368a725acb..168409760f 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2159,4 +2159,23 @@ fn justify_buttons_are_greyed_on_point_text() { } assert!(!h.get_by_label("Center text").accesskit_node().is_disabled(), "alignment itself stays available"); } + // A selection of a paragraph and a point type layer formats both: justify is greyed. + let mut app = new_app(); + let point = app.run("type.create", json!({"text": "point", "size": 24, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap(); + let boxed = + app.run("type.create", json!({"text": "boxed text", "size": 24, "x": 300, "y": 300, "box": [300, 300, 240, 180]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": point})).unwrap(); + app.run("layer.select", json!({"layer": boxed, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::type_tool::character_panel(app, ui, true); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text"); } From c7c2c31416af2173789336a8dff1fbdacc5ab28c Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 13:40:05 +0200 Subject: [PATCH 0813/1452] Painting symmetry: canvas edits move the axis with the pixels (#7732) Crop, Trim, Canvas Size, Image Size, Flip and Rotate Canvas mapped only the Color Sampler points; the painting symmetry kept its old document coordinates, so strokes mirrored about a different line (and a path axis left its own moved path). Session::edit now maps the symmetry through the same edit's geometry map: preset centre and angle (a flip mirrors it), or the sampled path segments. Flips and rotations pass their edge-coordinate map separately from the samplers' pixel-centre map. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/lib.rs | 18 +++++++++- crates/engine/src/mode_cmds.rs | 2 +- crates/engine/src/symmetry_cmds.rs | 56 +++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..2d80e90a59 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -120,7 +120,7 @@ fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result, + /// The same edit's map in edge coordinates, for the painting symmetry (Image Rotation maps the + /// samplers' pixel centres instead). Consumed by the edit. + symmetry_map: Option, /// Each open document's linked smart objects' files: size and modification time when last /// seen ([`link_watch`]). pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, @@ -565,8 +568,16 @@ impl Session { /// Move the Color Sampler points through `a` in the next [`Session::edit`] (crop, Canvas Size, /// Image Size, rotation), dropping the ones that land outside the new canvas. View state: the /// points stay pinned to their pixels, no history step. Cleared by the edit it is set for. + /// The painting symmetry moves through `a` too. pub(crate) fn set_sampler_map(&mut self, a: photocraft_geom::Affine) { - self.sampler_map = Some(a); + self.set_canvas_map(a, a); + } + + /// [`set_sampler_map`](Self::set_sampler_map) for an edit whose sampler map differs from its + /// geometry map (a flip or rotation maps pixel centres): `geometry` moves the painting symmetry. + pub(crate) fn set_canvas_map(&mut self, geometry: photocraft_geom::Affine, samplers: photocraft_geom::Affine) { + self.sampler_map = Some(samplers); + self.symmetry_map = Some(geometry); } pub fn sync_tools_mask_target(&mut self) { @@ -794,6 +805,7 @@ impl Session { let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); let restrict = self.color_restrict; let sampler_map = self.sampler_map.take(); + let symmetry_map = self.symmetry_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); // Selecting layers is not a step, so the state this edit leaves behind targets what was @@ -829,6 +841,10 @@ impl Session { st.color_samplers.retain(|p| bounds.contains(p[0].floor() as i32, p[1].floor() as i32)); samplers_removed = n - st.color_samplers.len(); } + // The painting symmetry stays on its pixels too (Photoshop keeps it as a path). + if let Some(a) = symmetry_map { + st.symmetry = st.symmetry.take().and_then(|sym| sym.transform(&a)); + } // The pixels the edit can have changed, as its undo would report them: nothing for a // selection, guides or a layer's name (a new lasso selection used to recomposite a large // document, seconds on the CPU path, #1773), a new or deleted layer's bounds (a diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 41b4ddde1a..998c85f11b 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -87,7 +87,7 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; - s.set_sampler_map(Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); + s.set_canvas_map(a, Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; diff --git a/crates/engine/src/symmetry_cmds.rs b/crates/engine/src/symmetry_cmds.rs index 2339568a0d..7946b847fc 100644 --- a/crates/engine/src/symmetry_cmds.rs +++ b/crates/engine/src/symmetry_cmds.rs @@ -4,7 +4,7 @@ use photocraft_doc::{LayerContent, vector::Path}; use photocraft_paint::{ StrokePoint, - symmetry::{PresetSymmetry, SymmetryMode}, + symmetry::{MAX_COORDINATE, PresetSymmetry, SymmetryMode}, }; use serde_json::{Value, json}; @@ -58,6 +58,33 @@ impl PaintingSymmetry { Self::Preset(p) => json!({"mode":p.mode.id(), "center":p.center, "rotation":p.rotation}), } } + /// The symmetry after a canvas edit (crop, Canvas Size, Image Size, rotation) moved the pixels + /// through `a`, so the axis stays on the same image content. `None` when it lands beyond the + /// coordinate limits. + pub fn transform(&self, a: &photocraft_geom::Affine) -> Option { + let map = |[x, y]: [f64; 2]| { + let q = a.apply(photocraft_geom::Point::new(x, y)); + [q.x, q.y].into_iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE).then_some([q.x, q.y]) + }; + match self { + Self::Path(axis) => { + let segments = axis.segments.iter().map(|&[p, q]| Some([map(p)?, map(q)?])).collect::>()?; + Some(Self::Path(SymmetryAxis { source: axis.source.clone(), segments })) + } + Self::Preset(p) => { + // The first axis' direction through the map's linear part gives the new angle; a + // flip mirrors it. + let [dx, dy] = *p.directions().first()?; + let [m0, m1, m2, m3, _, _] = a.m; + let turn = (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy) - dy.atan2(dx); + let rotation = (p.rotation + turn.to_degrees()).rem_euclid(360.0); + if !rotation.is_finite() { + return None; + } + Some(Self::Preset(PresetSymmetry { center: map(p.center)?, rotation, ..*p })) + } + } + } } fn set_preset(s: &mut Session, p: &Value) -> Result { @@ -246,6 +273,33 @@ mod tests { assert!(SymmetryAxis::from_path("long".into(), &Path::new(vec![long])).is_err(), "path complexity remains bounded"); } + #[test] + fn canvas_edits_move_the_symmetry_with_the_pixels() { + let preset = |s: &Session| s.active().unwrap().symmetry.as_ref().unwrap().preset().unwrap(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setSymmetry", json!({"mode": "vertical"})).unwrap(); + s.execute("image.crop", json!({"x": 40, "y": 0, "width": 60, "height": 100})).unwrap(); + assert_eq!(preset(&s).center, [10.0, 50.0], "the axis stays on the pixels it was on"); + s.execute("image.imageSize", json!({"width": 120, "height": 200})).unwrap(); + assert_eq!(preset(&s).center, [20.0, 100.0]); + s.execute("paint.setSymmetry", json!({"mode": "diagonal", "center": [30, 40]})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + let p = preset(&s); + assert_eq!(p.center, [90.0, 40.0], "edge coordinates mirror about the canvas width, not the last pixel"); + assert!((p.rotation - 90.0).abs() < 1e-9, "the 45° axis mirrors to 135°: {}", p.rotation); + + // A path axis keeps lying on its (moved) source path. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [60, 100]]}]}})).unwrap(); + s.execute("paint.symmetryFromPath", json!({"name": "work"})).unwrap(); + s.execute("image.crop", json!({"x": 30, "y": 10, "width": 50, "height": 80})).unwrap(); + let st = s.active().unwrap(); + let moved = SymmetryAxis::from_path("work".into(), st.doc.work_path.as_ref().unwrap()).unwrap(); + assert_eq!(st.symmetry, Some(PaintingSymmetry::Path(moved))); + } + #[test] fn symmetry_paints_mirrored_pixels_and_disable_stops_it() { let mut s = Session::new(); From b61a6124d925ae6ff7576386ed7d6521faf470c3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 13:40:23 +0200 Subject: [PATCH 0814/1452] Crop: Undo/Redo grey out when the frame has no step left (#7730) While either crop-box stack has a step, intercept takes both Undo and Redo, but is_enabled reported only the stack it would pop and otherwise fell back to the document's can-undo: Undo looked enabled, popped nothing and left the document's history unreachable. Grey the empty direction instead, as the Free Transform box does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..d33193041b 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -424,15 +424,16 @@ fn apply_step(app: &mut PhotocraftApp, s: CropStep) { /// While the Crop tool has box history, Undo/Redo step the *box*, not the document (Photoshop keeps /// Edit › Undo live during a crop, #1918/#3145). `Some(enabled)` for those commands, else `None` so -/// the caller falls back to document history. +/// the caller falls back to document history. While either box stack has a step, `intercept` takes +/// both commands, so one with nothing left to step greys out rather than offering the document's. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { - if app.ui.tool != Tool::Crop { + if app.ui.tool != Tool::Crop || (app.crop.undo.is_empty() && app.crop.redo.is_empty()) { return None; } match id { - "edit.undo" if !app.crop.undo.is_empty() => Some(true), - "edit.redo" if !app.crop.redo.is_empty() => Some(true), - "edit.toggleLastState" if !app.crop.undo.is_empty() || !app.crop.redo.is_empty() => Some(false), + "edit.undo" => Some(!app.crop.undo.is_empty()), + "edit.redo" => Some(!app.crop.redo.is_empty()), + "edit.toggleLastState" => Some(false), _ => None, } } @@ -1327,6 +1328,23 @@ mod tests { assert!((app.ui.crop_rect.unwrap()[0] - 60.0).abs() < 1e-3, "redo re-applies the move"); } + /// Once the box has no step left in one direction, that command greys out rather than staying + /// live over the document's history and doing nothing (as the Free Transform box does). + #[test] + fn box_undo_and_redo_grey_out_when_their_stack_is_empty() { + let mut app = app(SampleType::U8); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + assert!(app.session.active().unwrap().history.can_undo()); + app.ui.crop_rect = Some([40.0, 30.0, 100.0, 70.0]); + app.crop.default_frame = false; + drag(&mut app, &[[70.0, 50.0], [90.0, 50.0]], NONE); + assert!(crate::menus::is_enabled(&app, "edit.undo")); + assert!(!crate::menus::is_enabled(&app, "edit.redo"), "no box step to redo"); + app.run("edit.undo", json!({})).unwrap(); + assert!(!crate::menus::is_enabled(&app, "edit.undo"), "no box step left to undo"); + assert!(crate::menus::is_enabled(&app, "edit.redo")); + } + #[test] fn handles_resize_with_shift_and_alt() { let mut app = app(SampleType::U8); From 4d50e31efd32576b3b65b0a47b84e32b1db76d37 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:43:19 +0200 Subject: [PATCH 0815/1452] perf(codecs): stop PNG candidates at the winning cost --- crates/codecs/src/codecs/png.rs | 6 ++++ docs/png-filter-selection-performance.md | 39 +++++++++++++++--------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 34ae84acc0..946f8949e8 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -523,6 +523,9 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } if c < best { best = c; @@ -751,6 +754,9 @@ mod parallel_tests { let mut out = Vec::new(); filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); } #[test] diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md index 1f7ffeab29..6abbaaa846 100644 --- a/docs/png-filter-selection-performance.md +++ b/docs/png-filter-selection-performance.md @@ -1,27 +1,36 @@ # PNG filter selection -Stop evaluating PNG filters when their partial cost cannot win. Filter choice and encoded bytes must remain identical. +The final inner loop stops as soon as candidate cost reaches `best`. Costs cannot decrease, and only a strict improvement replaces the winning filter, preserving tie order. The previous-filter oracle now also checks an immediate tie against Sub. -Photo PNG encoding improves, with identical streams. The small designs show little change because they use the existing serial PNG encoder. The previous filter-kernel percentages are not end-to-end results. +Measured on 2026-10-19 against frozen main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently for each branch. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1. Release O3, LTO off, 16 codegen units, debug off; builds/tests at nice 10 with warm caches. Timed operations run at normal priority without concurrent builds or profiling. Persistent processes alternate execution order after a warmup. Time change compares the two medians; paired bootstrap intervals from these small samples describe variation, not guarantees. -Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. +CC0 Pixls P1000475.RW2 supplies a 6000 x 4000 centre crop (24 MP, no resampling); IMG_4059.CR2 supplies the native 4000 x 3000 city photograph. U8/U16/F32 documents retain ProPhoto RGB; F32 derives from 16-bit development and is not native HDR. No generated noise, repeated ramps, enlarged charts or duplicated photo layers are timed. Source pins and attribution remain in the existing corpus. -The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. +Nine alternating samples compare main, the previous split and the final cutoff implementation. The four photo cases use the parallel encoder; the two MIT psd-tools designs use the unchanged serial encoder. The illustrated card has a separate nine-pair confirmation, shown in the table. Input conversion, hashes and decoded-pixel checks are outside timers. Every PNG is byte-identical to main and decodes to identical pixels. -The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. +| Document | Operation | Main ms | Branch ms | Change | Paired 95% interval | +|---|---|---:|---:|---:|---:| +| panasonic-8 | png-encode | 358.588 | 337.725 | -5.8% | -6.6..-5.2% | +| panasonic-16 | png-encode | 334.253 | 301.130 | -9.9% | -10.8..-9.3% | +| canon-8 | png-encode | 179.462 | 168.651 | -6.0% | -8.4..-4.0% | +| canon-16 | png-encode | 168.310 | 150.325 | -10.7% | -17.5..-9.4% | +| layout-masks | png-encode | 4.035 | 4.020 | -0.4% | -1.4..+0.8% | +| layout-mask-index | png-encode (confirm) | 10.808 | 10.674 | -1.2% | -2.5..+0.7% | -| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | -|---|---|---:|---:|---:|---:|---:| -| panasonic-8 | png-encode | 358.725 | 346.020 | -3.5% | -7.0..-0.9% | 24,569,965 / 24,569,965 | -| panasonic-16 | png-encode | 338.106 | 296.860 | -12.2% | -12.3..-9.7% | 111,583,441 / 111,583,441 | -| canon-8 | png-encode | 178.675 | 169.289 | -5.3% | -35.0..-2.7% | 13,720,268 / 13,720,268 | -| canon-16 | png-encode | 162.107 | 145.356 | -10.3% | -12.2..+4.6% | 58,279,529 / 58,279,529 | -| layout-masks | png-encode | 4.037 | 3.986 | -1.3% | -3.3..+1.9% | 42,842 / 42,842 | -| layout-mask-index | png-encode | 11.343 | 11.404 | +0.5% | -0.0..+2.2% | 115,229 / 115,229 | +| Document | Previous split ms | Final ms | Cutoff-only change | +|---|---:|---:|---:| +| panasonic-8 | 339.785 | 337.725 | -0.6% | +| panasonic-16 | 300.287 | 301.130 | +0.3% | +| canon-8 | 172.739 | 168.651 | -2.4% | +| canon-16 | 150.183 | 150.325 | +0.1% | +| layout-masks | 4.019 | 4.020 | +0.0% | +| layout-mask-index | 10.674 | 10.674 | -0.0% | -The real-image PNG streams match main byte for byte and decode to identical pixels. The committed previous-filter oracle also covers bytes-per-pixel layouts, early exits, zero-cost rows and ties. +The cutoff-only photo intervals cross zero: these complete-image measurements do not establish an additional speedup over the previous split. The serial card initially measured +2.1% against the previous split; its independent confirmation is neutral (-0.0%, interval -1.1..+1.2%). The updated table reports the final implementation against main without relying on the earlier kernel-only promise. -Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. +Current affected checks: seven PNG unit tests passed, one ignored manual benchmark; strict all-target codec Clippy, affected codec WASM check and formatting passed. No synthetic timing test was run. + +Harness, raw observations, exact inputs and binary hashes: `.local/perf-followup-261019/` (ignored local evidence). Previous flat-photo and authored-design results remain in `.local/perf-real-261019/`. Nothing was pushed and no PR was changed. ## Earlier split validation From f64a143c5593b8d247433ff1b13e0ce7887d2254 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:43:20 +0200 Subject: [PATCH 0816/1452] perf(format): drop unhelpful Zstandard workspace reuse --- crates/format/src/store.rs | 77 ++++------------------------- docs/zstd-workspaces-performance.md | 46 ++++++++--------- 2 files changed, 30 insertions(+), 93 deletions(-) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 96647d0961..7da5dd6b53 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -26,7 +26,9 @@ fn blob_path(h: &str) -> String { format!("blobs/{h}.zst") } -type Compressor<'a> = ruzstd::encoding::FrameCompressor>, Vec, ruzstd::encoding::MatchGeneratorDriver>; +fn compress(data: &[u8]) -> Vec { + ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) +} /// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the /// decoded length must be exactly that (`Corrupt` otherwise). @@ -414,16 +416,8 @@ enum Object { } impl Object { - fn compressed<'a>(&'a self, compressor: &mut Compressor<'a>) -> Vec { - let bytes = self.content(); - // ruzstd's pooled suffix tables retain the first block's size. - if !bytes.len().is_multiple_of(128 << 10) { - return ruzstd::encoding::compress_to_vec(bytes.as_ref(), ruzstd::encoding::CompressionLevel::Fastest); - } - compressor.set_source(std::io::Cursor::new(bytes)); - compressor.set_drain(Vec::new()); - compressor.compress(); - compressor.set_drain(Vec::new()).unwrap_or_default() + fn compressed(&self) -> Vec { + compress(&self.content()) } /// The bytes this object stores (tiles little-endian). @@ -695,20 +689,18 @@ type Compressed<'a> = (&'a String, Arc>); fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result>> { let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }; if threads < 2 || objects.len() < 2 { - let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); - return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed(&mut compressor)))).collect()); + return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect()); } let next = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|sc| { let hs: Vec<_> = (0..threads.min(objects.len())) .map(|_| { sc.spawn(|| { - let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); let mut out = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let Some((p, o)) = objects.get(i) else { break }; - out.push((*p, Arc::new(o.compressed(&mut compressor)))); + out.push((*p, Arc::new(o.compressed()))); } out }) @@ -801,7 +793,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed(&mut Compressor::new(ruzstd::encoding::CompressionLevel::Fastest)))) + fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed())) } }; z.add(path, &data)?; @@ -836,7 +828,6 @@ impl PcraftWriter { None => 0, }; let recheck = self.rolling_recheck(&p.objects); - let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); for (path, obj) in &p.objects { let object_path = dir.join(path); // bundle_root and list_objects validated the fixed parent directories once. Check @@ -871,7 +862,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed(&mut compressor))?; + write_bundle_file(dir, &object_path, &obj.compressed())?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -1020,53 +1011,3 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { crate::atomic::atomic_write(path, data)?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[ignore = "manual 24 MP RGBA8-equivalent release benchmark"] - fn benchmark_workspace_reuse() { - use std::{hint::black_box, time::Instant}; - let data: Vec<_> = (0..367).map(|seed| Arc::new((0..262_144).map(|i| ((i * 37 + i / 97 + seed * 13) % 251) as u8).collect())).collect(); - let objects: Vec<_> = data.into_iter().map(Object::Blob).collect(); - for trial in 0..6 { - let mut paths = ["main", "split"]; - if trial % 2 == 1 { - paths.reverse(); - } - for name in paths { - let t = Instant::now(); - let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); - let bytes: usize = objects - .iter() - .map(|o| { - let encoded = if name == "main" { - ruzstd::encoding::compress_to_vec(o.content().as_ref(), ruzstd::encoding::CompressionLevel::Fastest) - } else { - o.compressed(&mut compressor) - }; - black_box(encoded).len() - }) - .sum(); - println!("workspace,24mp-rgba8,{trial},{name},{:.6},{bytes}", t.elapsed().as_secs_f64() * 1000.0); - } - } - } - - #[test] - fn reused_compressor_produces_independent_frames() { - let data: Vec>> = [0, 1, 8, 131_072, 0, 262_144, 262_147, 131_072, 1] - .into_iter() - .map(|n| Arc::new((0..n).map(|i| ((i * 37 + i / 97) % 251) as u8).collect())) - .collect(); - let objects: Vec<_> = data.iter().cloned().map(Object::Blob).collect(); - let mut compressor = Compressor::new(ruzstd::encoding::CompressionLevel::Fastest); - for (object, bytes) in objects.iter().zip(&data) { - let encoded = object.compressed(&mut compressor); - assert_eq!(encoded, ruzstd::encoding::compress_to_vec(bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); - assert_eq!(decompress(&encoded, bytes.len(), "test").unwrap(), **bytes); - } - } -} diff --git a/docs/zstd-workspaces-performance.md b/docs/zstd-workspaces-performance.md index 9952edc64e..25b7e04854 100644 --- a/docs/zstd-workspaces-performance.md +++ b/docs/zstd-workspaces-performance.md @@ -1,37 +1,33 @@ # Rust Zstandard workspace reuse -Reuse the existing pure-Rust compressor workspace per save worker. Partial-block objects keep the fresh-compressor fallback. No new backend or C dependency. +Removed locally: complete layered-photo ZIP save changes range from -1.9% to +0.2%. An apparent 4.8% gain in one 16-bit APFS directory save failed independent confirmation, which was 3.2% slower. The other directory cases are neutral. There is no repeatable meaningful whole-operation gain, so store.rs, including its added workspace tests and synthetic microbenchmark, is restored byte for byte to main. The original local tip is recoverable at refs/archive/perf/zstd-workspaces-PRE-LAYERED-REVIEW. Only the performance report remains different from main on this branch. -Fresh real-document saves are close to neutral. Reusing compressor allocation does not establish a meaningful end-to-end save improvement on these inputs. Every bundle remains byte-identical. +Measured on 2026-10-19 against frozen main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently for each branch. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1. Release O3, LTO off, 16 codegen units, debug off; builds/tests at nice 10 with warm caches. Timed operations run at normal priority without concurrent builds or profiling. Persistent processes alternate execution order after a warmup. Time change compares the two medians; paired bootstrap intervals from these small samples describe variation, not guarantees. -Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. +CC0 Pixls P1000475.RW2 supplies a 6000 x 4000 centre crop (24 MP, no resampling); IMG_4059.CR2 supplies the native 4000 x 3000 city photograph. U8/U16/F32 documents retain ProPhoto RGB; F32 derives from 16-bit development and is not native HDR. No generated noise, repeated ramps, enlarged charts or duplicated photo layers are timed. Source pins and attribution remain in the existing corpus. -The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. +Each constructed photo edit has four visible layers: the real raster photograph; global Levels (black 2/255, white 250/255, gamma 1.08); Curves with master points (0,0), (0.25,0.22), (0.5,0.52), (0.75,0.80), (1,1) plus red midtone 0.5→0.515; and foreground Levels (gamma 1.15) through a smooth grayscale mask fading from zero to one over 35–75% of image height. These are authored benchmark edits of licensed photos, not Photoshop-authored oracle files. Saved previews were inspected. -The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. +Complete full-resolution CPU rendering and fresh ZIP saves use seven alternating pairs. Thumbnail rendering, hashing and document equality are outside timers. Every render must match main float bit for float bit; every save must match main byte for byte and reload to the complete source document. Directory saves also use seven pairs and fresh directories with a fresh writer; an independent confirmation uses nine pairs. Directory creation/removal, output hashing and full reload equality are outside the timer. These directory timings include APFS writes and fsync, unlike the in-memory ZIP timings. These are CPU/core measurements, not GPU or browser performance claims. -| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | -|---|---|---:|---:|---:|---:|---:| -| panasonic-8 | save | 163.156 | 162.549 | -0.4% | -1.4..+0.1% | 49,643,217 / 49,643,217 | -| panasonic-8 | load-own | 396.986 | 397.099 | +0.0% | -0.2..+1.1% | 49,643,217 / 49,643,217 | -| panasonic-16 | save | 355.760 | 353.995 | -0.5% | -0.9..+0.2% | 175,681,852 / 175,681,852 | -| panasonic-16 | load-own | 386.774 | 386.373 | -0.1% | -0.9..+1.0% | 175,681,852 / 175,681,852 | -| panasonic-32 | save | 627.383 | 630.287 | +0.5% | -0.6..+2.2% | 252,231,671 / 252,231,671 | -| panasonic-32 | load-own | 1320.923 | 1320.769 | -0.0% | -0.1..+0.7% | 252,231,671 / 252,231,671 | -| canon-8 | save | 86.768 | 86.708 | -0.1% | -0.9..+1.8% | 26,151,631 / 26,151,631 | -| canon-8 | load-own | 221.917 | 224.088 | +1.0% | -1.2..+1.2% | 26,151,631 / 26,151,631 | -| canon-16 | save | 185.739 | 186.190 | +0.2% | -1.5..+0.9% | 88,750,003 / 88,750,003 | -| canon-16 | load-own | 198.518 | 198.190 | -0.2% | -0.8..+1.0% | 88,750,003 / 88,750,003 | -| canon-32 | save | 335.712 | 338.159 | +0.7% | -1.1..+2.0% | 120,957,984 / 120,957,984 | -| canon-32 | load-own | 787.610 | 786.982 | -0.1% | -1.8..+2.3% | 120,957,984 / 120,957,984 | -| layout-masks | save | 18.907 | 19.230 | +1.7% | -1.7..+4.0% | 433,949 / 433,949 | -| layout-masks | load-own | 16.310 | 16.411 | +0.6% | -2.6..+3.3% | 433,949 / 433,949 | -| layout-mask-index | save | 7.598 | 7.446 | -2.0% | -7.7..+6.3% | 344,892 / 344,892 | -| layout-mask-index | load-own | 6.848 | 6.840 | -0.1% | -2.6..+3.7% | 344,892 / 344,892 | +| Document | Operation | Main ms | Branch ms | Change | Paired 95% interval | +|---|---|---:|---:|---:|---:| +| panasonic-8 | save | 167.893 | 165.025 | -1.7% | -5.3..-0.6% | +| panasonic-16 | save | 371.857 | 364.765 | -1.9% | -3.0..+1.8% | +| panasonic-32 | save | 641.312 | 635.064 | -1.0% | -3.0..+4.3% | +| canon-8 | save | 93.469 | 93.592 | +0.1% | -0.8..+1.0% | +| canon-16 | save | 185.048 | 185.385 | +0.2% | -0.2..+4.3% | +| canon-32 | save | 327.223 | 326.519 | -0.2% | -0.2..+2.4% | +| panasonic-8 | save-dir | 5537.227 | 5534.940 | -0.0% | -7.1..+2.2% | +| panasonic-16 | save-dir | 7077.156 | 6739.019 | -4.8% | -5.3..-0.3% | +| canon-32 | save-dir | 3979.360 | 3974.353 | -0.1% | -0.9..+1.6% | +| panasonic-16 | save-dir (confirm) | 6483.861 | 6691.230 | +3.2% | +1.5..+9.2% | -Every saved real bundle is byte-identical to main and complete documents round-trip equal. The committed unit oracle compares reused frames with fresh compression, including partial blocks and size transitions. +The earlier eight flat-photo/authored-design comparisons were also close to neutral for fresh ZIP saves. All candidate bundles and directory contents match main byte for byte. Retention required a repeatable improvement of at least 3% in a complete operation, with a paired interval excluding zero and no material new regression. Kernel-only differences do not satisfy that criterion. -Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. +Post-removal affected checks passed: three RGB ZIP depth round trips; directory round trips across the existing mode/depth matrix; ZIP incremental saves; directory incremental saves and garbage collection (six tests total). Strict all-target format Clippy, affected format WASM and formatting checks also passed. No unrelated full-suite or synthetic timing run was repeated. + +Harness, raw observations, exact inputs and binary hashes: `.local/perf-followup-261019/` (ignored local evidence). Previous flat-photo and authored-design results remain in `.local/perf-real-261019/`. Nothing was pushed and no PR was changed. ## Earlier split validation From c4e8eb2b86320b8af7ba1cdd9579cb260c739c32 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 14:07:15 +0200 Subject: [PATCH 0817/1452] Brush smoothing: catch-up compares the pose too, not only the position (#7747) Since #5726 the smoothed brush pulls its pressure, tilt, rotation and wheel towards the pointer like its position, but Stroke Catch-Up (`lagging`) and Catch-Up On Stroke End (`finish`) still compared x/y only. A pen that changed only its pressure in place got no catch-up time and no end point, so the stroke ended at an in-between pressure (1.0 -> 0.0 ended at 0.64). Both checks now also compare the pose (rotation on the circle). Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 40 +++++++++++++++++++++++++++++++++ crates/paint/src/dynamics.rs | 19 +++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 2365d5e586..c498d7a358 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -939,6 +939,46 @@ fn a_pulled_string_brush_takes_the_pose_along_the_string() { assert!((p.rotation - 6.0).abs() < 1e-3, "{}", p.rotation); } +#[test] +fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() { + // #7747: only the pressure changes while the pointer stands still. The brush lags in pose, + // so catch-up must keep running and the stroke end at the pen's last pressure. + let cfg = Smoothing { amount: 0.8, ..Default::default() }; + let mut s = Smoother::new(&cfg, 1.0); + let mut out = Vec::new(); + s.push(StrokePoint::new(50.0, 50.0, 1.0), &mut out); + s.push(StrokePoint { time: 32.0, ..StrokePoint::new(50.0, 50.0, 0.0) }, &mut out); + assert!(out.last().unwrap().pressure > 0.5, "the brush trails in pressure: {:?}", out.last()); + assert!(s.lagging(), "a pose behind the pointer is still catching up"); + // Held long enough, the brush arrives and stops asking for time. + for i in 1..=200 { + s.push(StrokePoint { time: 32.0 + f64::from(i) * 16.0, ..StrokePoint::new(50.0, 50.0, 0.0) }, &mut out); + } + assert!(!s.lagging(), "{:?}", out.last()); + s.finish(&mut out); + assert_eq!(out.last().unwrap().pressure, 0.0); +} + +#[test] +fn catch_up_on_stroke_end_finishes_the_pose_of_a_still_pen() { + // #7747: tilt, rotation and wheel changed in place; the end catch-up lands on all of them. + let cfg = Smoothing { amount: 0.8, ..Default::default() }; + let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(50.0, 50.0, 1.0) }; + let b = StrokePoint { time: 16.0, tilt_x: 40.0, tilt_y: -20.0, rotation: 10.0, wheel: 0.5, ..StrokePoint::new(50.0, 50.0, 1.0) }; + let mut s = Smoother::new(&cfg, 1.0); + let mut out = Vec::new(); + s.push(a, &mut out); + s.push(b, &mut out); + assert!(s.lagging()); + s.finish(&mut out); + let p = out.last().unwrap(); + assert_eq!((p.tilt_x, p.tilt_y, p.rotation, p.wheel), (40.0, -20.0, 10.0, 0.5)); + // Nothing left to catch up: no extra point. + let n = out.len(); + s.finish(&mut out); + assert_eq!(out.len(), n); +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 0c535cf89c..e53b366fa2 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -76,6 +76,18 @@ fn pull(brush: &StrokePoint, p: &StrokePoint, f: f64) -> StrokePoint { } } +/// Does the brush's pose (pressure, tilt, rotation, wheel) differ from `p`'s by more than `eps`? +/// [`pull`] moves the pose with the position, so a pen that only changes pressure or tilt in place +/// leaves the brush behind too (#7747). +fn pose_differs(brush: &StrokePoint, p: &StrokePoint, eps: f32) -> bool { + let dr = (p.rotation - brush.rotation + 540.0).rem_euclid(360.0) - 180.0; + (p.pressure - brush.pressure).abs() > eps + || (p.tilt_x - brush.tilt_x).abs() > eps + || (p.tilt_y - brush.tilt_y).abs() > eps + || dr.abs() > eps + || (p.wheel - brush.wheel).abs() > eps +} + /// Incremental stroke smoothing: exponential (with optional time-based catch-up) or pulled string. #[derive(Clone, Debug)] pub struct Smoother { @@ -153,7 +165,7 @@ impl Smoother { /// stroke needs time to keep passing (repeats of the last point) while the pointer is held. pub fn lagging(&self) -> bool { match (self.active() && self.cfg.catch_up && !self.cfg.pulled_string, self.pos, self.last_input) { - (true, Some(pos), Some(last)) => (pos.x - last.x).abs() + (pos.y - last.y).abs() > 0.01, + (true, Some(pos), Some(last)) => (pos.x - last.x).abs() + (pos.y - last.y).abs() > 0.01 || pose_differs(&pos, &last, 1e-3), _ => false, } } @@ -166,13 +178,14 @@ impl Smoother { (self.active() && (pos.x != last.x || pos.y != last.y)).then_some(([pos.x, pos.y], [last.x, last.y])) } - /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position. + /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position + /// and pose. pub fn finish(&mut self, out: &mut Vec) { if !self.active() || !self.cfg.catch_up_on_end { return; } if let (Some(pos), Some(last)) = (self.pos, self.last_input) - && (pos.x != last.x || pos.y != last.y) + && (pos.x != last.x || pos.y != last.y || pose_differs(&pos, &last, 0.0)) { self.pos = Some(last); out.push(last); From d67a949a54c5785d381246ae34ecf011dc67cfc8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 14:10:07 +0200 Subject: [PATCH 0818/1452] Crop: a lost Straighten release no longer hijacks the next drag (#7745) A Straighten line whose release went to Space's temporary Hand stayed set, so the next plain drag fed its moves and release to the old line: the frame turned instead of moving and the crop gesture stayed stuck. A new press now drops any leftover line first. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_straighten.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/crop_straighten.rs b/crates/ui-egui/src/crop_straighten.rs index 9f8128b80a..4be201b540 100644 --- a/crates/ui-egui/src/crop_straighten.rs +++ b/crates/ui-egui/src/crop_straighten.rs @@ -70,6 +70,8 @@ pub fn reset(app: &mut PhotocraftApp) { pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { match ev { ToolEvent::Down { x, y, .. } => { + // A line whose release went elsewhere (Space's Hand took it) never continues here. + app.crop.straighten.line = None; let s = app.crop.straighten; if app.crop.drag.is_some() || !(s.armed || mods.command) { return false; @@ -325,6 +327,21 @@ mod tests { assert!(app.ui.crop_angle.is_finite() && app.ui.crop_rect.unwrap().iter().all(|v| v.is_finite())); } + #[test] + fn a_line_whose_release_was_lost_never_takes_over_the_next_drag() { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + app.crop.default_frame = false; + // A ⌘-drag whose release went to the temporary Hand (Space pressed mid-line). + tool_event(&mut app, ToolEvent::Down { x: 0.0, y: 0.0, pressure: 1.0 }, CMD); + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 17.6, pressure: 1.0 }, CMD); + assert!(app.crop.straighten.line.is_some()); + // The next plain drag inside the frame moves it, and ends. + drag(&mut app, &[[50.0, 40.0], [60.0, 40.0]], NONE); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), (Some([30.0, 20.0, 130.0, 70.0]), 0.0)); + assert!(app.crop.drag.is_none() && app.crop.straighten.line.is_none()); + } + #[test] fn holding_command_is_a_temporary_straighten() { let mut app = app(SampleType::U8); From 7835d05a54bc426a3053870666a11cb5e43ce7c1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 17:11:27 +0500 Subject: [PATCH 0819/1452] Actions: a Selective Color step keeps the values of its scalar form `image.adjustments.selectiveColor` takes a range two ways, and the command reads both: the array form `"reds": [c, m, y, k]`, and the scalar form that names one range and gives the channels it changes, `"colors": "reds", "cyan": 35`. `selective_to` looked only for the arrays. A step recorded the other way exported with an empty `ClrC` list -- an adjustment that changes nothing -- with `skipped` empty and no warning on import, so nothing said the values had gone. On a single red pixel the step produces [0.6509804, 0, 0, 1] run directly and leaves [1, 0, 0, 1] after a round trip through .atn. The rows are now built the way `selective_from_params` reads them: the arrays first, then the scalar form over the range it names, so a step that gives both exports what playing it would produce. An unknown range in `colors` still cannot be written and refuses the step. Fixes #7672. --- crates/engine/src/atn_cmds.rs | 27 ++++++++++++++++++++--- crates/engine/src/atn_cmds/tests.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..a4d6c33de5 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -724,12 +724,33 @@ fn selective_from(d: &Descriptor) -> Option { } fn selective_to(p: &Value) -> Option { - let mut list = Vec::new(); - for (ps, name) in SELECTIVE_RANGES { + // Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form + // `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and + // gives the channels it changes. Writing only the arrays dropped the scalar form's values, and + // the step exported as an adjustment that changes nothing (#7672). + let mut rows: Vec> = vec![None; SELECTIVE_RANGES.len()]; + for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) { if let Some(a) = p.get(*name).and_then(Value::as_array) { let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + *row = Some([v(0), v(1), v(2), v(3)]); + } + } + if let Some(named) = jstr(p, "colors") { + let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?; + let row = rows.get_mut(i)?; + let mut v = row.unwrap_or([0.0; 4]); + for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) { + if let Some(n) = jnum(p, key) { + *slot = n; + } + } + *row = Some(v); + } + let mut list = Vec::new(); + for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) { + if let Some(v) = row { list.push(D::Descriptor( - desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))), + desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])), )); } } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..8ecf7334f7 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -166,6 +166,39 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(r["set"], "Ours"); } +/// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as +/// the scalar form that names one range (`"colors": "reds", "cyan": 35`). Only the arrays were +/// written, so a step recorded the other way exported as an adjustment that changes nothing, with +/// `skipped` empty (#7672). +#[test] +fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { + let back = |params: Value| -> Value { + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), params)])); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + imported.actions[0].steps[0].1.clone() + }; + + // The numbers are read back as numbers; a percentage may arrive as 35 or 35.0. + let range = + |step: &Value, name: &str| -> Vec { step[name].as_array().unwrap_or(&Vec::new()).iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect() }; + + // The reported step: one range, one channel. + let scalar = back(json!({"method": "absolute", "colors": "reds", "cyan": 35})); + assert_eq!(scalar["method"], "absolute"); + assert_eq!(range(&scalar, "reds"), [35.0, 0.0, 0.0, 0.0], "{scalar}"); + + // The scalar form over an array keeps both, as playing the step would. + let both = back(json!({"reds": [10, 20, 30, 40], "colors": "reds", "cyan": 35})); + assert_eq!(range(&both, "reds"), [35.0, 20.0, 30.0, 40.0], "{both}"); + + // The array form is unchanged. + let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]})); + assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}"); +} + #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ From de2cb1f0c080a3b46fd17889ccc6f2759d7148e7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 14:14:06 +0200 Subject: [PATCH 0820/1452] Compose: a clipped adjustment inside a pass-through clip base damages the whole group (#6535) A pass-through group that is the base of a clipping group renders isolated while it holds a visible adjustment (`clip_base_adjusts`), so showing, hiding, adding or removing a clipped adjustment inside it can switch the group's other children (a Multiply layer beside the base) between isolated and pass-through rendering. `change_bounds_in` now takes the list holding the parent group and reports that group's change bounds in this case; `layers_damage`, `step_damage` and `one_sided_bounds` pass it. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 16 ++++- crates/compose/src/tests.rs | 49 ++++++++++++--- crates/engine/src/layer_multi_cmds.rs | 85 ++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 27 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f2733d157e..7d0487d63e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1177,14 +1177,26 @@ pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option { /// adjustment layer: it changes only its clip base's pixels, so it reaches as far as showing, /// hiding or restyling that base does (#6535). Still `None` when there is no base below it (the /// compositor then draws it as an ordinary adjustment) or the base is an adjustment itself -/// (layers clipped to one composite atop the adjusted backdrop, everywhere). -pub fn change_bounds_in(layers: &[Layer], k: usize, canvas: Rect) -> Option { +/// (layers clipped to one composite atop the adjusted backdrop, everywhere). `outer` is the list +/// holding the group whose children `layers` are, and that group's index there (`None` at the +/// top level): a pass-through group that is a clip base renders isolated while it holds a visible +/// adjustment, so showing or hiding one inside can change the whole group. +pub fn change_bounds_in(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option { let layer = layers.get(k)?; if layer.clipped && matches!(layer.content, LayerContent::Adjustment(_)) { let base = layers.get(..k)?.iter().rev().find(|l| !l.clipped)?; if matches!(base.content, LayerContent::Adjustment(_)) { return None; } + if let Some((outer, g)) = outer + && let Some(group) = outer.get(g) + && group.blend == BlendMode::PassThrough + && !group.clipped + && outer.get(g + 1).is_some_and(|above| above.clipped) + { + // The group bounds its base too. + return change_bounds(group, canvas); + } return change_bounds(base, canvas); } change_bounds(layer, canvas) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9bbbaaf2a6..811fa371ab 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -464,7 +464,7 @@ fn clipped_adjustment_changes_only_its_base() { adj.clipped = true; d.layers.push(adj); assert_eq!(change_bounds(&d.layers[3], canvas), None); - assert_eq!(change_bounds_in(&d.layers, 3, canvas), Some(base_rect)); + assert_eq!(change_bounds_in(&d.layers, 3, None, canvas), Some(base_rect)); // Every pixel the toggle changes lies inside those bounds. let shown = render(&d, canvas); d.layers[3].visible = false; @@ -480,24 +480,24 @@ fn clipped_adjustment_changes_only_its_base() { } assert_eq!(changed, base_rect); // Other layers keep their own bounds. - assert_eq!(change_bounds_in(&d.layers, 2, canvas), Some(Rect::new(3, 2, 4, 3))); - assert_eq!(change_bounds_in(&d.layers, 9, canvas), None); + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(3, 2, 4, 3))); + assert_eq!(change_bounds_in(&d.layers, 9, None, canvas), None); // Unclipped, or with no base beneath it, it reaches everything. let mut free = d.layers.clone(); free[3].clipped = false; - assert_eq!(change_bounds_in(&free, 3, canvas), None); + assert_eq!(change_bounds_in(&free, 3, None, canvas), None); let orphan = vec![d.layers[3].clone()]; - assert_eq!(change_bounds_in(&orphan, 0, canvas), None); + assert_eq!(change_bounds_in(&orphan, 0, None, canvas), None); // Clipped layers on an adjustment composite atop the adjusted backdrop. let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()]; - assert_eq!(change_bounds_in(&on_adj, 2, canvas), None); + assert_eq!(change_bounds_in(&on_adj, 2, None, canvas), None); // A pass-through group holding an adjustment is isolated only while it has visible clipped // layers, so they change whether its adjustment reaches the layers beneath. let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); g.blend = BlendMode::PassThrough; let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()]; - assert_eq!(change_bounds_in(&on_group, 2, canvas), None); + assert_eq!(change_bounds_in(&on_group, 2, None, canvas), None); } /// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites @@ -518,12 +518,43 @@ fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() { let shown = px(&d, 0, 0); d.layers[2].visible = false; assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group"); - assert_eq!(change_bounds_in(&d.layers, 2, canvas), None); + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), None); assert_eq!(change_bounds(&d.layers[1], canvas), None); // With a base beneath it the clipped adjustment stays inside the group. let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") }; g.children.swap(0, 1); - assert_eq!(change_bounds_in(&d.layers, 2, canvas), Some(Rect::new(2, 1, 4, 3))); + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(2, 1, 4, 3))); +} + +/// A pass-through group that is a clip base renders isolated while it holds a visible +/// adjustment, so hiding a clipped adjustment inside it changes the group's other children too: +/// its damage is the group's, not just its base's. +#[test] +fn clipped_adjustment_in_pass_through_clip_base_reaches_its_group() { + let canvas = Rect::new(0, 0, 10, 8); + let mut d = doc_white(10, 8); + d.layers.push(solid_layer("bg", canvas, [0.5, 0.5, 0.5, 1.0])); + let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + a.clipped = true; + let mut q = solid_layer("Q", Rect::new(6, 4, 8, 6), [0.5, 0.5, 0.5, 1.0]); + q.blend = BlendMode::Multiply; + let mut g = Layer::group("G", vec![solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0]), a, q]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let mut x = solid_layer("X", Rect::new(2, 1, 3, 2), [1.0, 0.0, 0.0, 1.0]); + x.clipped = true; + d.layers.push(x); + let shown = px(&d, 7, 5); + let LayerContent::Group(g) = &mut d.layers[2].content else { panic!("group") }; + g.children[1].visible = false; + assert!(!close4(shown, px(&d, 7, 5)), "hiding A changes Q, outside A's base"); + let group = Rect::new(2, 1, 8, 6); + let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") }; + assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(group)); + // Not a clip base: the group stays pass-through either way, and A changes only its base. + d.layers.truncate(3); + let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") }; + assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(Rect::new(2, 1, 4, 3))); } #[test] diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 3a67f8278b..5bf9b3c42c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -502,8 +502,8 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op let canvas = d.bounds(); for id in ids { // A layer gone from one side (never for a move) changes who knows what. - let (siblings, k) = siblings_of(d, *id)?; - let b = photocraft_compose::change_bounds_in(siblings, k, canvas)?; + let (siblings, k, outer) = siblings_of(d, *id)?; + let b = photocraft_compose::change_bounds_in(siblings, k, outer, canvas)?; if !b.is_empty() { out = if out.is_empty() { b } else { out.union(&b) }; } @@ -512,12 +512,15 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op Some(out) } -/// The sibling list holding layer `id` in `doc` (bottom→top) and its index there. -fn siblings_of(doc: &Document, id: LayerId) -> Option<(&[Layer], usize)> { +/// The sibling list holding layer `id` in `doc` (bottom→top), its index there, and the list +/// holding its parent group with the group's index (`None` at the top level). +type Siblings<'d> = (&'d [Layer], usize, Option<(&'d [Layer], usize)>); +fn siblings_of(doc: &Document, id: LayerId) -> Option> { let path = doc.path_of(id)?; let (&k, parent) = path.split_last()?; - let siblings = if parent.is_empty() { doc.layers.as_slice() } else { doc.layer_at(parent)?.children()? }; - Some((siblings, k)) + let Some((&g, grand)) = parent.split_last() else { return Some((doc.layers.as_slice(), k, None)) }; + let outer = if grand.is_empty() { doc.layers.as_slice() } else { doc.layer_at(grand)?.children()? }; + Some((outer.get(g)?.children()?, k, Some((outer, g)))) } /// Report [`layers_damage`] as the active document's last change, so the canvas recomposites @@ -770,11 +773,13 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { if !doc_pixels_equal(a, b) { return None; } - fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, out: &mut Rect) -> bool { + /// The list holding a sibling list's parent group, and the group's index there. + type Outer<'d> = Option<(&'d [Layer], usize)>; + fn walk(xs: &[Layer], ys: &[Layer], (ox, oy): (Outer, Outer), a: &Document, b: &Document, out: &mut Rect) -> bool { // Pair the layers by id, stepping over layers only one side has: one that draws nothing // changes no pixels (a new empty layer or group, #1771), any other only where it draws. - let one_sided = |layers: &[Layer], k: usize, canvas: Rect, out: &mut Rect| { - let Some(r) = one_sided_bounds(layers, k, canvas) else { return false }; + let one_sided = |layers: &[Layer], k: usize, outer: Outer, canvas: Rect, out: &mut Rect| { + let Some(r) = one_sided_bounds(layers, k, outer, canvas) else { return false }; if !r.is_empty() { *out = if out.is_empty() { r } else { out.union(&r) }; } @@ -794,14 +799,14 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { continue; } (_, Some(y)) if xs.iter().all(|x| x.id != y.id) => { - if !one_sided(ys, j, b.bounds(), out) { + if !one_sided(ys, j, oy, b.bounds(), out) { return false; } j += 1; continue; } (Some(x), _) if ys.iter().all(|y| y.id != x.id) => { - if !one_sided(xs, i, a.bounds(), out) { + if !one_sided(xs, i, ox, a.bounds(), out) { return false; } i += 1; @@ -824,7 +829,8 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { if !layer_pixels_equal(x, y) { // Both sides' extents: a move changes pixels where it left and where it landed. // A clipped adjustment reaches as far as its base (#6535). - let (Some(bx), Some(by)) = (photocraft_compose::change_bounds_in(xs, xk, a.bounds()), photocraft_compose::change_bounds_in(ys, yk, b.bounds())) + let (Some(bx), Some(by)) = + (photocraft_compose::change_bounds_in(xs, xk, ox, a.bounds()), photocraft_compose::change_bounds_in(ys, yk, oy, b.bounds())) else { return false; }; @@ -832,7 +838,7 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } match (x.children(), y.children()) { (Some(xc), Some(yc)) => { - if !walk(xc, yc, a, b, out) { + if !walk(xc, yc, (Some((xs, xk)), Some((ys, yk))), a, b, out) { return false; } } @@ -842,7 +848,7 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } } let mut out = Rect::EMPTY; - if walk(&a.layers, &b.layers, a, b, &mut out) { Some(out) } else { None } + if walk(&a.layers, &b.layers, (None, None), a, b, &mut out) { Some(out) } else { None } } /// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds @@ -851,7 +857,7 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { /// of clipped layers above it (they would change base), and neither it nor anything inside it is /// a video layer or excludes channels (both reach outside the layer's bounds, see /// [`step_damage`]). -fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { +fn one_sided_bounds(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option { fn contained(l: &Layer, depth: usize) -> bool { depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1))) } @@ -860,7 +866,7 @@ fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { if new_base || !contained(l, 0) { return None; } - photocraft_compose::change_bounds_in(layers, k, canvas) + photocraft_compose::change_bounds_in(layers, k, outer, canvas) } /// Whether `layers[k]` draws nothing wherever it sits, so adding or removing it leaves the @@ -1858,6 +1864,53 @@ mod tests { } } + /// #6535: hiding a clipped adjustment inside a pass-through group that is itself a clip base + /// can switch the group between isolated and pass-through rendering, so the step damages the + /// whole group, on undo, redo and when the adjustment is deleted too. + #[test] + fn clipped_adjustment_in_pass_through_clip_base_damages_its_group() { + use photocraft_doc::{Adjustment, BlendMode}; + let solid = |name: &str, r: Rect, blend: BlendMode| { + let mut l = Layer::raster(name, PixelFormat::RGBA8); + l.surface_mut().unwrap().fill_rect(r, &[0.5, 0.5, 0.5, 1.0]); + l.blend = blend; + l + }; + let mut before = Document::with_background( + "t", + photocraft_geom::Size::new(10, 8), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_color::Color::WHITE, + ); + before.layers.push(solid("bg", before.bounds(), BlendMode::Normal)); + let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + a.clipped = true; + let mut g = Layer::group("G", vec![solid("P", Rect::new(2, 1, 4, 3), BlendMode::Normal), a, solid("Q", Rect::new(6, 4, 8, 6), BlendMode::Multiply)]); + g.blend = BlendMode::PassThrough; + before.layers.push(g); + let mut x = solid("X", Rect::new(2, 1, 3, 2), BlendMode::Normal); + x.clipped = true; + before.layers.push(x); + let mut after = before.clone(); + let LayerContent::Group(g) = &mut after.layers[2].content else { panic!("group") }; + g.children[1].visible = false; + let group = Some(Rect::new(2, 1, 8, 6)); + assert_eq!(step_damage(&before, &after), group); + assert_eq!(step_damage(&after, &before), group); + let mut deleted = before.clone(); + let LayerContent::Group(g) = &mut deleted.layers[2].content else { panic!("group") }; + g.children.remove(1); + assert_eq!(step_damage(&before, &deleted), group); + let LayerContent::Group(g) = &before.layers[2].content else { panic!("group") }; + let ids = [g.children[1].id]; + assert_eq!(layers_damage(&before, &after, &ids), group); + // Without the layer clipped to the group it stays pass-through: just A's base. + before.layers.truncate(3); + after.layers.truncate(3); + assert_eq!(step_damage(&before, &after), Some(Rect::new(2, 1, 4, 3))); + } + /// #2883 (P8/P9): a duplicate shares the source's tiles (no pixel copy) and recomposites only /// where it draws; undo brings back exactly the old pixels. A copy that becomes the clipping /// base of the layers above still recomposites everything. From a053f9bc4eafe43151ab73f8dfaa631a15d9e6e2 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Wed, 14 Oct 2026 10:09:17 -0500 Subject: [PATCH 0821/1452] Warp: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) Committing Edit > Transform > Warp ran on the UI thread: on an 8000x6000 layer the window froze for 6.9 s (Arc) or 9.3 s (Flag), with no progress or cancel. edit.transform.warp now runs its edit through edit_job, as Free Transform does since #4218: the mesh warp reports progress per tile and stops when cancelled, and the transform session keeps its preview until the result lands. Undo during the commit names Warp. Most of a Flag warp's time was after the mesh warp: re-converting a layer that already had alpha (2.0 s) and compositing the warp over a cleared layer that held nothing else (2.3 s). The layer is now cloned, and the warp is the result when the box held all of it. Flag drops to 1.4 s and Arc to 4.2 s inline. --- crates/algo/src/warp.rs | 54 ++++++++++- crates/engine/src/jobs_tests.rs | 30 ++++++ crates/engine/src/warp_cmds.rs | 140 ++++++++++++++++++++------- crates/ui-egui/src/jobs_ui_tests.rs | 54 +++++++++++ crates/ui-egui/src/transform_tool.rs | 5 +- 5 files changed, 244 insertions(+), 39 deletions(-) diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 54a6c7c868..23c5d29237 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -15,7 +15,7 @@ use photocraft_color::PixelFormat; use photocraft_geom::{Rect, TileCoord}; -use photocraft_raster::{Surface, Tile}; +use photocraft_raster::{Interrupt, Surface, Tile}; use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -75,6 +75,16 @@ pub fn place_source_projective( /// coordinates → destination document coordinates). Output has `src`'s format with alpha /// added; pixels outside the warped area are transparent. pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface { + mesh(src, src_rect, f, interp, &Interrupt::NONE) +} + +/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled. +pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option { + let out = mesh(src, src_rect, f, interp, ctl); + (!ctl.cancelled()).then_some(out) +} + +fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface { if src_rect.is_empty() { let fmt = src.format(); return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); @@ -104,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> tris.push([a, a + w1 + 1, a + w1]); } } - warp_triangles(src, src_rect, &verts, &tris, interp) + triangles(src, src_rect, &verts, &tris, interp, ctl) } /// Probe lines per axis [`mapped_lengths`] follows through the map. @@ -149,6 +159,12 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) - /// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the /// order is the depth order). Output has `src`'s format with alpha added. pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface { + triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE) +} + +/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips +/// the remaining tiles; the caller discards the result. +fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface { let mut fmt = src.format(); let converted; let src = if fmt.alpha { @@ -208,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let n = fmt.channels(); let a = n - 1; + // Tiles finished so far, for progress reported per tile (from any worker thread). + let finished = std::sync::atomic::AtomicUsize::new(0); + let total = tiles.len().max(1); let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> { + if ctl.cancelled() { + return None; + } + let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + // The last few percent are putting the tiles in place. + ctl.progress(0.98 * k as f32 / total as f32); if tris.is_empty() { return None; } @@ -430,6 +455,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 }; #[cfg(target_arch = "wasm32")] let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + if ctl.cancelled() { + return out; + } out.put_tiles(done); out } @@ -503,6 +531,28 @@ mod tests { assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20)); } + #[test] + fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]); + let r = s.content_bounds(); + let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]); + let map = |x: f64, y: f64| w.map(x, y); + let reported = std::sync::Mutex::new(0.0f32); + let progress = |p: f32| { + let mut m = reported.lock().unwrap(); + *m = m.max(p); + }; + let never = || false; + let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled"); + let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic); + assert_eq!(out.content_bounds(), want.content_bounds()); + assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds())); + assert!(*reported.lock().unwrap() > 0.9); + let always = || true; + assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none()); + } + #[test] fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() { // #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 2bcdda76fc..77b4764801 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -94,6 +94,36 @@ fn free_transform_runs_as_a_job_with_the_inline_result() { assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); } +#[test] +fn warp_runs_as_a_job_with_the_inline_result() { + let arc = json!({"style": "arc", "bend": 30}); + let mut inline = session(300, 200); + inline.execute("edit.transform.warp", arc.clone()).unwrap(); + + let mut s = session(300, 200); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform.warp", arc).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(e.label, "Warp"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); +} + +#[test] +fn a_cancelled_warp_leaves_the_document_unchanged() { + let mut s = session(1600, 1200); + let doc_before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform.warp", json!({"style": "flag", "bend": 40})).unwrap()); + assert!(s.cancel_job(id)); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + s.join_cancelled_jobs(); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &doc_before), "the document snapshot was not replaced"); + assert_eq!(st.history.past_len(), steps); +} + #[test] fn cancel_leaves_the_document_byte_identical() { let mut s = session(1600, 1200); diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 8e2ab3c4fd..90b3361e49 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -9,10 +9,10 @@ //! what the Free Transform warp UI uses) or the active smart object's stored warp (a history step). use photocraft_algo::transform::{Homography, Interp}; -use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; +use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface_with}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, Rect}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -112,7 +112,24 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). -fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { +/// `Err(Cancelled)` once `ctl` is cancelled: between the steps of a warp that don't check it. +fn cancelled(ctl: &Interrupt) -> Result<()> { + if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) } +} + +/// `ctl` reports progress and cancels (`Err(Cancelled)`, the layer left half-done for the caller +/// to discard). +#[allow(clippy::too_many_arguments)] +fn warp_layer( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + w: &Warp, + rect: Rect, + interp: Interp, + frames: Option<&Frames>, + ctl: &Interrupt, +) -> Result<()> { let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); @@ -127,7 +144,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - warp_layer(None, locks, c, w, rect, interp, frames)?; + warp_layer(None, locks, c, w, rect, interp, frames, ctl)?; } } LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())), @@ -154,7 +171,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, sm.warp = src_warp.filter(|w| !w.is_identity()); // Fallback appearance for sources that can't be re-rendered. if let Some(c) = &mut sm.cache { - *c = warp_mesh_surface(c, rect, &map, interp); + *c = warp_mesh_surface_with(c, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; } if let Some(m) = &mut sm.filter_mask { m.surface = warp_mesh_gray(&m.surface, &map, interp); @@ -165,7 +182,8 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, *surf = match doc_sel { Some(sel) => { let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?; - let moved = warp_mesh_surface(&lifted, rect, &map, interp); + let moved = warp_mesh_surface_with(&lifted, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; + cancelled(ctl)?; crate::transform_cmds::composite_over(&mut rest, &moved); rest.prune(); rest @@ -173,12 +191,20 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, None => { // Content outside the warp box stays where it is. let fmt = surf.format(); - let warped = warp_mesh_surface(surf, rect, &map, interp); - let mut rest = surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); + let warped = warp_mesh_surface_with(surf, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; + cancelled(ctl)?; + // Tiles are shared, so a surface that has alpha already costs nothing to copy. + let mut rest = if fmt.alpha { surf.clone() } else { surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)) }; crate::pixels::try_clear_surface(&mut rest, rect, None)?; - crate::transform_cmds::composite_over(&mut rest, &warped); - rest.prune(); - rest + if rest.content_bounds().is_empty() { + // The box held all of the layer: the warp is the whole result. + warped + } else { + cancelled(ctl)?; + crate::transform_cmds::composite_over(&mut rest, &warped); + rest.prune(); + rest + } } }; } @@ -189,7 +215,12 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, { let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp)); m.surface = match mask_sel { - Some(sel) => crate::transform_cmds::move_gray_selected(&m.surface, sel, &|lifted, src| Ok(warp_mesh_surface(lifted, src, &map, interp)), &whole)?, + Some(sel) => crate::transform_cmds::move_gray_selected( + &m.surface, + sel, + &|lifted, src| warp_mesh_surface_with(lifted, src, &map, interp, &Interrupt::NONE).ok_or(EngineError::Cancelled), + &whole, + )?, None => whole(&m.surface)?, }; } @@ -350,30 +381,39 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); - s.edit("Warp", |doc, _| { - let sel = doc.selection.clone(); - let frames = content_knots.then(|| { - let mut f = Frames::new(); - if let Some(l) = doc.layer(id) { - source_frames(doc, l, &mut f); - } - f - }); - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = crate::transform_cmds::group_locks(doc, id); - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp, frames.as_ref())?; - let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - crate::transform_cmds::refresh_text(&snapshot, l); + // A background job when started with `Session::start` (as Free Transform, #4218): the warp of + // a large layer runs on a worker with progress, and can be cancelled. + crate::jobs::edit_job( + s, + "Warp", + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Warping", |ctl| warp_doc(doc, id, &w, rect, interp, content_knots, ctl)), + move |()| json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]}), + ) +} + +/// The edit of [`apply`]: the layer and the selection outline. +fn warp_doc(doc: &mut Document, id: LayerId, w: &Warp, rect: Rect, interp: Interp, content_knots: bool, ctl: &Interrupt) -> Result<()> { + let sel = doc.selection.clone(); + let frames = content_knots.then(|| { + let mut f = Frames::new(); + if let Some(l) = doc.layer(id) { + source_frames(doc, l, &mut f); } - if let Some(sel) = &doc.selection { - doc.selection = Some(warp_mesh_gray(sel, &|x, y| w.map(x, y), Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); - } - Ok(()) - })?; - Ok(json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]})) + f + }); + let is_group = doc.layer(id).is_some_and(Layer::is_group); + let group = crate::transform_cmds::group_locks(doc, id); + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + warp_layer(if is_group { None } else { sel.as_ref() }, group, l, w, rect, interp, frames.as_ref(), ctl)?; + let snapshot = doc.clone(); + if let Some(l) = doc.layer_mut(id) { + crate::transform_cmds::refresh_text(&snapshot, l); + } + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_mesh_gray(sel, &|x, y| w.map(x, y), Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + } + Ok(()) } #[derive(Clone, Copy)] @@ -630,6 +670,36 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn a_warp_box_holding_the_whole_layer_gives_what_compositing_over_the_cleared_layer_did() { + for depth in [8, 16, 32] { + let s = session(depth); + let st = s.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let surf = l.surface().unwrap().clone(); + let rect = surf.content_bounds(); + let w = Warp::preset(WarpStyle::Flag, 35.0, rect_f(rect)); + // What the warp built before the shortcut: the warp composited over the cleared layer. + let map = |x: f64, y: f64| w.map(x, y); + let warped = warp_mesh_surface_with(&surf, rect, &map, Interp::Bicubic, &Interrupt::NONE).unwrap(); + let mut want = surf.clone(); + crate::pixels::try_clear_surface(&mut want, rect, None).unwrap(); + crate::transform_cmds::composite_over(&mut want, &warped); + want.prune(); + let mut got = l.clone(); + warp_layer(None, Locks::default(), &mut got, &w, rect, Interp::Bicubic, None, &Interrupt::NONE).unwrap(); + let got = got.surface().unwrap(); + let b = want.content_bounds(); + assert_eq!(got.content_bounds(), b, "depth {depth}"); + let (g, wv) = (got.read_region(b), want.read_region(b)); + // Exact at 8 and 16 bits. At 32 the composite's `t·a / a` rounds a few values by an + // ulp; the shortcut keeps the warp's own. + let tol = if depth == 32 { 1e-6 } else { 0.0 }; + let worst = g.iter().zip(&wv).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst <= tol, "depth {depth}: off by {worst}"); + } + } + fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap(); diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 70ce973be0..e33db98778 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -784,6 +784,60 @@ fn esc_cancels_a_background_free_transform_commit() { assert_eq!(st.doc.layers.len(), before.layers.len()); } +/// Warp the active layer into an arc, then ↩. +fn warp_and_commit(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + crate::transform_tool::begin(app, &ctx).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 40.0, t.rect)); + crate::transform_tool::commit(app); +} + +#[test] +fn a_warp_commit_runs_in_the_background_and_keeps_its_preview() { + let mut h = app_harness(); + let steps = h.state().session.active().unwrap().history.past_len(); + warp_and_commit(&mut h); + let app = h.state(); + let job = crate::transform_tool::committing(app).expect("the commit runs as a job"); + assert!(app.session.job(job).is_some_and(|j| j.label == "Warp")); + assert!(app.ui.transform.is_some() && app.transform_preview.is_some()); + assert_eq!(h.state_mut().run("edit.undo", json!({})).err().as_deref(), Some("Warp is still being applied")); + let t = Instant::now(); + while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) { + h.step(); + } + h.run_steps(2); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1); + assert_eq!(st.history.undo_label(), Some("Warp")); + // The arc bulges the layer past its old edges. + let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_ne!(b, photocraft_geom::Rect::new(0, 0, 320, 240), "the layer was warped"); +} + +#[test] +fn esc_cancels_a_background_warp_commit() { + let mut h = app_harness(); + let steps = h.state().session.active().unwrap().history.past_len(); + let before = h.state().session.active().unwrap().doc.clone(); + warp_and_commit(&mut h); + let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job"); + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(3); + let app = h.state(); + assert!(app.session.job(job).is_none()); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps); + let id = st.active_layer.unwrap(); + let px = |d: &photocraft_doc::Document| d.layer(id).unwrap().surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 320, 240)); + assert_eq!(px(&st.doc), px(&before), "the layer is untouched"); +} + /// #4559: a background open (the desktop app's) also warns, when its document arrives, about /// type in a font that isn't installed. #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 45ef0b1528..b69d131383 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1011,8 +1011,9 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } -/// Undo and Redo while the commit runs in the background. +/// Undo and Redo while the commit runs in the background (Free Transform, or Warp). const STILL_APPLYING: &str = "Free Transform is still being applied"; +const STILL_WARPING: &str = "Warp is still being applied"; pub fn commit(app: &mut PhotocraftApp) { if committing(app).is_some() { @@ -1309,7 +1310,7 @@ fn box_command(id: &str) -> bool { pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { let warping = app.ui.transform.as_ref()?.warp.is_some(); if committing(app).is_some() { - return box_command(id).then(|| Err(STILL_APPLYING.into())); + return box_command(id).then(|| Err(if warping { STILL_WARPING } else { STILL_APPLYING }.into())); } match id { "edit.undo" => Some(Ok(step(app, false))), From e8a9f579f7da5959a3882f40f0d50d04a8900d3e Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 14:44:02 +0200 Subject: [PATCH 0822/1452] Crop: a typed ratio fits from the frame the edit started with (#7752) The options-bar ratio fields apply every keystroke, and each ratio was fitted inside the frame the previous keystroke left: typing 16 over 4 in a 400 x 300 frame went 1:3 (100 x 300), then 16:3 inside that (100 x 18.75) instead of 400 x 75. The bar now keeps the frame from when the W/H edit (typing, arrows or scrubbing) started and fits each new ratio from it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 15 +++++++++ crates/ui-egui/src/panels.rs | 62 +++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..77228342a0 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -248,6 +248,21 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { crate::crop_mode::sync_frame(app); } +/// [`apply_ratio`] for a ratio being typed: fits from `base`, the frame the edit started with, +/// not from the frame the last keystroke's ratio left (#7752). +pub(crate) fn apply_ratio_from(app: &mut PhotocraftApp, base: [f64; 4]) { + if app.ui.tool != Tool::Crop || app.crop.drag.is_some() { + return; + } + if let Some(ratio) = preset_ratio(app) + && let Some(fitted) = fitted_ratio(base, ratio) + && app.ui.crop_rect != Some(fitted) + { + keyed(app, fitted); + } + crate::crop_mode::sync_frame(app); +} + fn fit_frame(app: &mut PhotocraftApp) { let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); if let Some(r) = app.ui.crop_rect diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..50525b814e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1276,6 +1276,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919). crate::crop_presets::dropdown(app, ui); let o = &mut app.ui.tool_options; + let mut ratio_base = None; if o.crop_ratio == crate::crop_size::WHR { crate::crop_size::fields(o, ui); } else { @@ -1283,15 +1284,27 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Original Ratio shows the document's numbers, as Photoshop does. let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o, doc_size); let decimals = crate::crop_size::RATIO_DECIMALS; - let cw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals).changed(); + let fw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals); let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); - let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed(); + let fh = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals); + let (cw, ch) = (fw.changed(), fh.changed()); if swap { std::mem::swap(&mut rw, &mut rh); } if cw || ch || swap { crate::crop_size::set_custom_ratio_fields(o, rw, rh); } + // Every ratio typed or scrubbed in one edit fits from the frame the edit + // started with: 1:3 on the way to 16:3 mustn't shrink it first (#7752). + let base_id = egui::Id::new("crop-ratio-edit-base"); + if cw || ch { + ratio_base = ui.data(|d| d.get_temp::<[f64; 4]>(base_id)).or(app.ui.crop_rect); + } + if ![&fw, &fh].iter().any(|f| f.has_focus() || f.dragged()) { + ui.data_mut(|d| d.remove::<[f64; 4]>(base_id)); + } else if let Some(base) = ratio_base { + ui.data_mut(|d| d.insert_temp(base_id, base)); + } } widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() { @@ -1316,7 +1329,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 150.0, ); if crate::crop_ui::preset_ratio(app) != before_ratio { - crate::crop_ui::apply_ratio(app); + match ratio_base { + Some(base) => crate::crop_ui::apply_ratio_from(app, base), + None => crate::crop_ui::apply_ratio(app), + } } if pick_shield_color { crate::crop_shield::pick_custom_color(app); @@ -6428,6 +6444,46 @@ mod compact_visual_regressions { assert_eq!(app.ui.tool_options.crop_ratio, "1.414:1", "showing the ratio leaves it alone"); } + #[test] + fn a_typed_crop_ratio_fits_from_the_frame_the_edit_started_with() { + // #7752: typing 16 over 4 gave 1:3, then 16:3 inside that 100 x 300 frame (100 x 18.75). + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| { + crate::theme::apply(&ctx, *k); + Tokens::get(&ctx).pro + }) else { + return; + }; + crate::theme::apply(&ctx, kind); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.tool_options.crop_ratio = "4:3".into(); + app.ui.crop_rect = Some([0.0, 0.0, 400.0, 300.0]); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0)); + let step = |app: &mut PhotocraftApp, events: Vec| { + let mut input = input_at_scale(screen, 1.0); + input.events = events; + ctx.run_ui(input, |ui| options_bar(app, ui)).textures_delta.clear(); + }; + for _ in 0..3 { + step(&mut app, vec![]); + } + // The ratio's W field is the bar's first number field; focusing it selects its text. + widgets::focus_first_field(&ctx, true); + step(&mut app, vec![]); + step(&mut app, vec![egui::Event::Text("1".into())]); + assert_eq!(app.ui.tool_options.crop_ratio, "1:3"); + step(&mut app, vec![egui::Event::Text("6".into())]); + step(&mut app, vec![]); + assert_eq!(app.ui.tool_options.crop_ratio, "16:3"); + let r = app.ui.crop_rect.unwrap(); + let want = [0.0, 112.5, 400.0, 187.5]; + assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "16:3 fitted once in 400 x 300: {r:?}"); + } + #[test] fn the_narrow_options_bar_preference_shortens_the_bar() { assert_eq!(options_bar_height(true, false), 36.0); From 745f535fda4c44cf1cc5e1827873e66a35dfeffd Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 14:51:25 +0200 Subject: [PATCH 0823/1452] Perspective Crop: Color Sampler points move with the image (#7760) Perspective Crop set no sampler map, so the edit only dropped the points now off the smaller canvas and left the rest at their old coordinates, reading unrelated pixels. As in Photoshop, each point now follows its pixel through the crop's homography (fractional positions kept), and the points outside the quad are deleted and counted for the status message. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/perspective_crop_cmds.rs | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 0cd1f01604..8e08877a6f 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -202,6 +202,18 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32) Ok(()) } +/// Color Sampler points (pixel indices) through `h`, as Photoshop maps them: each pixel's centre +/// goes where the crop takes it, and the points outside the quad, now off the canvas, are dropped. +fn map_samplers(points: &[[f64; 2]], h: &Homography, (w, ht): (u32, u32)) -> Vec<[f64; 2]> { + let bounds = Rect::from_xywh(0, 0, w, ht); + points + .iter() + .map(|p| h.apply(p[0] + 0.5, p[1] + 0.5)) + .map(|(x, y)| [x - 0.5, y - 0.5]) + .filter(|p| p[0].is_finite() && p[1].is_finite() && bounds.contains(p[0].floor() as i32, p[1].floor() as i32)) + .collect() +} + fn perspective_crop(s: &mut Session, p: &Value) -> Result { let q = corners(p)?; let (w, h) = out_size(p, &q)?; @@ -216,6 +228,10 @@ fn perspective_crop(s: &mut Session, p: &Value) -> Result { .any(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Fill(_) | LayerContent::Smart(_))); let map = quad_to_rect(&q, f64::from(w), f64::from(h)).ok_or_else(|| bad("the corners don't define a perspective"))?; let bg = s.tools.background; + // The Color Sampler points follow their pixels through the homography, which the edit's + // affine sampler map can't express: mapped here once the crop has gone through (#7760). + let samplers = s.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); + let removed = s.samplers_removed; crate::analysis_cmds::compound(s, "Perspective Crop", |s| { if rasterize { crate::extra_cmds::rasterize_all(s)?; @@ -228,6 +244,11 @@ fn perspective_crop(s: &mut Session, p: &Value) -> Result { Ok(()) }) })?; + let kept = map_samplers(&samplers, &map, (w, h)); + s.samplers_removed = removed + (samplers.len() - kept.len()); + if let Some(d) = s.active_mut() { + d.color_samplers = kept; + } let dpi = s.active().map(|d| d.doc.resolution_dpi); Ok(json!({"width": w, "height": h, "resolution": dpi, "corners": q})) } @@ -299,6 +320,28 @@ mod tests { } } + /// #7760: Color Sampler points move with the image through the crop, as in Photoshop, so they + /// keep reading the same content; points outside the quad are deleted, also those whose old + /// coordinates lie inside the smaller new canvas. + #[test] + fn color_samplers_follow_the_perspective_crop() { + let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]]; + let (w, h) = (100.0, 80.0); + let mut s = skewed_ramp(SampleType::U16, &q, w, h); + s.active_mut().unwrap().color_samplers = vec![[70.0, 60.0], [5.0, 5.0], [120.0, 40.0]]; + s.execute(CMD, json!({"corners": q, "width": w, "height": h})).unwrap(); + let to_out = Homography::rect_to_quad([0.0, 0.0, w, h], q).unwrap().inverse().unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.color_samplers.len(), 2, "(5, 5) lies outside the quad: {:?}", st.color_samplers); + for (p, old) in st.color_samplers.iter().zip([[70.0, 60.0], [120.0, 40.0]]) { + let (u, v) = to_out.apply(old[0] + 0.5, old[1] + 0.5); + assert!((p[0] + 0.5 - u).abs() < 1e-6 && (p[1] + 0.5 - v).abs() < 1e-6, "{old:?} -> {p:?}, want ({u:.2}, {v:.2})"); + let px = st.doc.layers[0].surface().unwrap().rgba(p[0].round() as i32, p[1].round() as i32); + assert!((f64::from(px[0]) * w - u).abs() < 1.5 && (f64::from(px[1]) * h - v).abs() < 1.5, "{old:?} reads {px:?}"); + } + assert_eq!(s.take_samplers_removed(), 1); + } + /// Corners past the canvas: the uncovered Background is the background colour, opaque even /// when that colour has alpha (the Background stays opaque). #[test] From 198fa59c7d46dc6bef2c3b2544ab62a1b9aed92f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:52:12 +0200 Subject: [PATCH 0824/1452] fix(psd): a new type layer gets a full Txt2 object and its own frame (#7698) A type layer the file's Txt2 has no object for (one added in PhotoCraft) was written as a bare stub with no view, no paragraph runs and no frame in /0 /8 /0. Photopea and Photoshop refused the file once the objects outnumbered the frames. build_txt2 now gives such a layer an object shaped like one of the file's own (refitted with edited_object from #7220), with its view naming its own frame, and pads the frame list to one per object. A stub written by an earlier save is replaced the same way, so re-saving an affected file repairs it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 77 ++++++++++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 60 +++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 41d65ff887..5ffbebd94d 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -975,6 +975,11 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let prev = previous.and_then(parse_txt2); let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]); let mut slots: Vec = prev_objects.to_vec(); + // Looked up once per save, not per layer: a crafted block with many objects must not cost + // a pass over all of them for every type layer. + let all_stubs = prev_objects.iter().all(is_stub); + // A template is copied once per new layer, so a large one is skipped (the stub is written). + let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some()); for (index, layer) in objects { if !(0..=MAX_TEXT_INDEX).contains(index) { continue; @@ -988,8 +993,15 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); - let reuse = kept.is_some() || edited.is_some(); - let mut object = kept.cloned().or(edited).unwrap_or_else(E::dict); + // A new object (#7698): a layer the file's block has no object for (a type layer added + // here) takes the shape of one of the file's own objects, refitted the same way, with its + // view naming its own frame. A bare stub beside Photoshop's objects, its slot without a + // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A stub an earlier save + // wrote is replaced the same way, so re-saving such a file repairs it. + let kept = kept.filter(|o| !is_stub(o) || all_stubs); + let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); + let reuse = kept.is_some() || edited.is_some() || fresh.is_some(); + let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict); let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = file_runs.unwrap_or_else(|| { @@ -1019,6 +1031,7 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and // `/1` whatever it holds beside the text objects. let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); + pad_frames(&mut outer, slots.len()); match outer.get_mut("1") { Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), _ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])), @@ -1036,6 +1049,66 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec } } +/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's +/// own objects hold a few hundred (611 at most in the file from #7698). +const TEMPLATE_NODES: usize = 4096; + +/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over). +fn fits(v: &E, budget: &mut usize) -> bool { + let Some(left) = budget.checked_sub(1) else { return false }; + *budget = left; + match v { + E::Dict(items) => items.iter().all(|(_, x)| fits(x, budget)), + E::Array(items) => items.iter().all(|x| fits(x, budget)), + _ => true, + } +} + +/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as +/// Photoshop writes it. +fn names_one_frame(object: &E) -> bool { + object.path(&["1", "0"]).and_then(E::as_array).is_some_and(|f| f.len() == 1 && f.first().and_then(|x| x.get("0")).is_some()) +} + +/// A text object for the slot `at`, which the file's block has none for: the file's object +/// `template` refitted to `text` by [`edited_object`], its view now naming frame `at` and its view +/// runs (`/1 /1 /0`, one per paragraph) remade for the new paragraphs. `None` when the template +/// can't be refitted. +fn new_object(template: &E, at: usize, text: &str) -> Option { + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let mut object = edited_object(template, text)?; + let view = object.get_mut("1")?; + view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(i64::try_from(at).ok()?))])])); + if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) { + let fitted = runs_per(runs, ¶graphs)?; + view.get_mut("1")?.set("0", E::Array(fitted)); + } + Some(object) +} + +/// Whether `object` is the bare `<< /0 << /0 text /6 runs >> >>` object [`build_txt2`] writes +/// when it has nothing to take a shape from: no view, no paragraph runs. +fn is_stub(object: &E) -> bool { + let only = |v: &E, keys: &[&str]| matches!(v, E::Dict(items) if items.iter().all(|(k, _)| keys.contains(&k.as_str()))); + only(object, &["0"]) && object.get("0").is_some_and(|m| only(m, &["0", "6"])) +} + +/// Gives the block `outer` a frame (`/0 /8 /0`) for each of its `count` text objects: Photoshop +/// writes one per object, and a reader indexes them by object number. Missing frames are the +/// small point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7 +/// false >> >> >> >>`), never copies of a file's frame: the slot count reaches +/// [`MAX_TEXT_INDEX`], and a large frame copied that often would blow up the save. A block +/// without a frame list is left as it is. +fn pad_frames(outer: &mut E, count: usize) { + let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return }; + if frames.len() < count { + let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); + let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]); + let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]); + frames.resize(count, frame); + } +} + /// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine /// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`) /// one per paragraph of the new text (each keeping the style of the file's paragraph at that diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d2aaa78a45..4f6aad9c86 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1147,6 +1147,66 @@ fn edited_text_keeps_the_files_txt2_object() { } } +/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an +/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, +/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. +#[test] +fn new_text_layer_gets_a_txt2_object_and_frame() { + use crate::engine_data::Value as E; + let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> "); + let prev = format!( + "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", + run(3), + run(3), + run(6) + ); + // The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698). + let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> "; + let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>")); + let broken: Vec = broken.chars().map(|c| c as u32 as u8).collect(); + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let old = runs_of("AB\nCD", &[(5, CharStyle::default())]); + let new = runs_of("v1.0", &[(4, CharStyle::default())]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap(); + let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap(); + assert_eq!(objects.len(), 2); + let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view"); + assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs"); + let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap(); + assert_eq!(frames.len(), 2, "one frame per object"); + let object = &objects[1]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r")); + assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame"); + assert!(object.get("21").is_none()); + let lens = + |path: &[&str]| -> Vec { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() }; + assert_eq!(lens(&["0", "5", "0"]), vec![5]); + assert_eq!(lens(&["0", "6", "0"]), vec![5]); + assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs"); + + // Re-saving the file with the stub repairs it: the stub is replaced like a missing object. + assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); + let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap(); + assert_eq!(repaired, out, "a stub from an earlier save is replaced"); + + // No object to take the shape from: the fresh object as before, with the frames still padded. + let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> "; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap(); + assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); + + // An oversized object is never the template (it would be copied once per new layer). + let filler = "0 ".repeat(5000); + let big = prev.iter().map(|&b| b as char).collect::().replace("/2 [ << /99 /PC >> ]", &format!("/2 [ << /99 /PC >> ] /9 [ {filler}]")); + let big: Vec = big.chars().map(|c| c as u32 as u8).collect(); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap(); + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; + assert!(object.get("1").is_none(), "no copy of an oversized template"); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); +} + /// The file's style runs are only reused when they cover the text: runs of another length (or /// hostile lengths) fall back to generated runs, without panicking. #[test] From e87ad39a2191fb33abc8a69d162ce0160ec020f0 Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:03:46 +0200 Subject: [PATCH 0825/1452] Vanishing Point: perspective clone samples without allocating per pixel (#7491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sample` read a 2×2 region (plus a default-pixel Vec) and collected a new Vec for every covered pixel of each dab. It now writes into caller-owned scratch and reads its corners from the dab's source footprint, read once per dab; corners outside it come from `read_pixel`. Output is unchanged; corner coordinates saturate instead of overflowing at the edge of i32. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/vanishing.rs | 95 +++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 77b7bb107c..87e55f8733 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -345,21 +345,39 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let src_copy = surf.clone(); let fmt = surf.format(); let n = fmt.channels(); + // Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result. + let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]); let mut dabs = 0; for p in points { let mc = metric(&hd_inv, lud, lvd, *p); // Image-space footprint: map a metric circle's bounding points. - let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; - for k in 0..16 { - let a = k as f64 / 16.0 * std::f64::consts::TAU; - let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd); - let (x, y) = hd.apply(u, v); - bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; - } - let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)); + let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| { + let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for k in 0..16 { + let a = k as f64 / 16.0 * std::f64::consts::TAU; + let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv); + bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; + } + Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)) + }; + let area = footprint(&hd, lud, lvd, mc); if area.is_empty() || area.width() > 4096 || area.height() > 4096 { continue; } + // The source footprint, read once; `sample` reads any pixel outside it from the surface. + // Skipped when it dwarfs the destination (cloning from near to far in strong perspective): + // decoding it would cost more than the few per-pixel reads it saves. + win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2); + let dest_px = u64::from(area.width()) * u64::from(area.height()); + if win.area.is_empty() + || win.area.width() > 4096 + || win.area.height() > 4096 + || u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px + { + win.area = Rect::new(0, 0, 0, 0); + } + src_copy.read_region_into(win.area, &mut win.data); let mut data = surf.read_region(area); let w = area.width() as usize; let mut changed = false; @@ -376,7 +394,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: // Source pixel at the same metric offset. let sm = [m[0] + offset[0], m[1] + offset[1]]; let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs); - let s = sample(&src_copy, sx - 0.5, sy - 0.5, n); + sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); for c in 0..n { px[c] += (s[c] - px[c]) * k; } @@ -390,11 +408,33 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: dabs } -fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec { +/// Pixels of `area` read from the source surface (`read_region` layout). +struct Window { + area: Rect, + data: Vec, +} + +/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), using `corners` (four pixels) as +/// scratch, so the per-pixel clone loop does not allocate. Corners inside `win` come from it. +fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut [f32], out: &mut [f32]) { + let n = out.len(); let (x0, y0) = (x.floor() as i32, y.floor() as i32); let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); - let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2)); - (0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect() + let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1)); + let w = win.area.width() as usize; + for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() { + let dst = &mut corners[k * n..(k + 1) * n]; + if win.area.contains(px, py) { + let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n; + dst.copy_from_slice(&win.data[o..o + n]); + } else { + s.read_pixel(px, py, dst); + } + } + let r = corners; + for (c, o) in out.iter_mut().enumerate() { + *o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty; + } } #[cfg(test)] @@ -487,4 +527,35 @@ mod tests { assert_eq!(n, 1); assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32)); } + + #[test] + fn sample_at_the_edge_of_i32_does_not_overflow() { + let s = Surface::new(PixelFormat::RGBA8); + let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + let (mut corners, mut out) = ([0.0; 16], [1.0; 4]); + sample(&s, &win, 3e9, 3e9, &mut corners, &mut out); + assert_eq!(out, [0.0; 4]); + } + + #[test] + fn sample_interpolates_across_tile_edges() { + let mut s = Surface::new(PixelFormat::RGBA8); + let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]); + // Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge. + s.fill_rect(Rect::new(0, 0, 256, 512), &a); + s.fill_rect(Rect::new(256, 0, 512, 512), &b); + let (mut corners, mut out) = ([0.0; 16], [0.0; 4]); + let want = [0.75, 0.0, 0.25, 1.0]; + // From the surface, from a window holding the footprint, and from one holding half of it. + for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] { + let mut win = Window { area, data: Vec::new() }; + s.read_region_into(area, &mut win.data); + sample(&s, &win, 255.25, 255.5, &mut corners, &mut out); + assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}"); + } + // Outside every tile, the default (transparent) pixel. + let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + sample(&s, &win, -10.5, -10.5, &mut corners, &mut out); + assert_eq!(out, [0.0; 4]); + } } From 41fb9f986c7829149a79b0a0d360bf6e115a62dd Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 15:14:51 +0200 Subject: [PATCH 0826/1452] Layer Style: a new Stroke is 1 px inside black, as in Photoshop (#7765) `layer.layerStyle.stroke {}` built a red 3 px outside stroke, while the dialog's factory default (defaultFor / Reset to Default) was black 3 px outside. Photoshop's Reset to Default on the Stroke page is 1 px, Inside, Normal, 100 %, black; both tables now give that, and a test checks that every effect kind's bare build equals its dialog default. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 28 ++++++++++++++++++++++------ crates/engine/src/pattern_cmds.rs | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 8933f838b9..8b8103a171 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -91,7 +91,7 @@ pub fn effect_defaults(kind: &str) -> Value { "innerGlow" => { json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) } - "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), + "stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), "gradientOverlay" => { json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) @@ -507,9 +507,9 @@ fn fresh_effect(kind: &str) -> Option { "innerGlow" => Effect::InnerGlow(glow()), "stroke" => Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), - size: 3.0, - position: StrokePosition::Outside, - paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + size: 1.0, + position: StrokePosition::Inside, + paint: FxPaint::Color(Color::BLACK), }), "colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }, "gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }, @@ -1012,7 +1012,7 @@ pub fn specs() -> Vec { style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( @@ -1518,7 +1518,7 @@ mod tests { // Factory default first: Reset without a saved default gives the factory set. let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap(); assert_eq!(r["user"], json!(false)); - assert_eq!(r["params"]["size"], json!(3)); + assert_eq!(r["params"]["size"], json!(1)); s.execute( "layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": {"size": 9, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}}), @@ -1533,6 +1533,22 @@ mod tests { assert!(s.execute("layer.layerStyle.defaultFor", json!({"kind": "nope"})).is_err()); } + /// #7765: a stroke built without params was red, while the dialog's factory default was + /// black. Both now give Photoshop's Reset to Default: 1 px, inside, Normal, 100 %, black. + #[test] + fn fresh_effects_match_the_dialog_factory_defaults() { + for kind in + ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "satin", "bevelEmboss"] + { + let bare = effect_from_params(kind, &json!({})).unwrap(); + let dialog = effect_from_params(kind, &effect_defaults(kind)).unwrap(); + assert_eq!(bare, dialog, "{kind}"); + } + let Some(Effect::Stroke(st)) = effect_from_params("stroke", &json!({})) else { panic!("no stroke") }; + assert_eq!((st.size, st.position, st.common.blend, st.common.opacity), (1.0, StrokePosition::Inside, BlendMode::Normal, 1.0)); + assert_eq!(st.paint, FxPaint::Color(Color::BLACK)); + } + #[test] fn layer_style_defaults_persist_with_presets() { let mut s = session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 0cb9052ceb..dc3cc380fb 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -557,7 +557,7 @@ mod tests { s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); s.execute("layer.layerStyle.patternOverlay", json!({"pattern": "Diagonal Lines"})).unwrap(); - s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#00ff00", "add": true})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#00ff00", "add": true})).unwrap(); let out = pixel(&s, 8, 15); assert!(out[1] > 0.9 && out[0] < 0.1, "stroke outside: {out:?}"); } From 1f6dbf8415b33cb15e8ce4cc30b5acf5e6a449f4 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 18:17:25 +0500 Subject: [PATCH 0827/1452] Lasso: a short straight segment places a vertex instead of closing the outline With the Lasso and Alt held (Photoshop's straight-segment mode), a click closer to the last vertex than the radius for grabbing the first one ended the selection instead of placing the vertex: three Alt clicks at (50,50) (90,50) (90,90) -> 3 vertices a fourth at (85,91), 5 points away -> the selection was committed The radius is `8.0 / point_zoom`, so every segment shorter than 8 points on screen was impossible to draw -- the click meant to continue the outline finished it, and tracing anything small ended the selection after three vertices (#7629). Both closing gestures stay; they stop sharing one radius. Clicking the first vertex is aiming at a target and keeps the 8-point grab radius. Clicking the last vertex again is the same click repeated, so it only has to allow for a hand's jitter: 3 points. Enter still closes, Escape cancels, Backspace and right-click retract, releasing Alt with the button up closes, and the Freeform Pen still ends an open path by clicking its last point. I could not reproduce this on the dedicated Polygonal Lasso: `polygon_click` closes only near the first vertex, and egui counts a double click by the time between clicks *and* their distance (`max_click_dist`, 6 points), so two quick clicks further apart are not one. A probe placing a vertex 4 units from the last, clicked in the next frame, keeps it. --- crates/ui-egui/src/lasso_ui.rs | 20 +++++++++--- crates/ui-egui/src/polygon_lasso_tests.rs | 39 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 1f3f7adba2..0fe904f745 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -42,6 +42,11 @@ pub fn active(app: &PhotocraftApp) -> bool { app.drag.as_ref().is_some_and(|d| owns(d.tool) && d.lasso.is_some()) } +/// How far from the last vertex a click still counts as that same vertex clicked again (points +/// on screen): a hand's jitter, not an aiming radius -- a vertex placed further away than this is +/// the next vertex of the outline, however short the segment (#7629). +const SAME_POINT: f64 = 3.0; + /// Between polygonal clicks, Backspace/right-click retracts the last fixed vertex. /// Do not affect a freehand stroke whose primary button is still held. pub fn waiting_for_vertex(app: &PhotocraftApp) -> bool { @@ -154,13 +159,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool match ev { ToolEvent::Down { .. } => { if active(app) { - // A click on the first point, or on the last one again, closes the outline. - let reach = 8.0 / app.point_zoom().max(0.01) as f64; - let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach; - let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]])))); + // A click on the first point, or on the last one again, closes the outline. Aiming + // at the first point is a grab; clicking the last one again is the same click + // repeated, so it has only to allow for a pixel of hand jitter. Sharing the grab + // radius made every segment shorter than it impossible to draw, because the click + // meant to place the next vertex closed the outline instead (#7629). + let zoom = app.point_zoom().max(0.01) as f64; + let near = |q: [f64; 2], reach: f64| (q[0] - p[0]).hypot(q[1] - p[1]) < reach / zoom; + let again = |d: &Drag| d.points.last().is_some_and(|l| near([l[0], l[1]], SAME_POINT)); + let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start, 8.0) || again(d))); if close { // A path closes on its start point (a selection outline always closes). - if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start)) { + if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start, 8.0)) { let (start, n) = (d.start, d.points.len()); append(d, start); if let Some(lasso) = d.lasso.as_mut().filter(|_| d.points.len() > n) { diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index 62975e2d73..6101436a17 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -138,6 +138,45 @@ fn double_click_closes_the_polygon_after_quickly_placed_vertices() { assert!(selection(&h).is_some_and(|r| near(r, 50, 50, 150, 150)), "{:?}", selection(&h)); } +/// The Lasso's ⌥ (straight-segment) mode could not draw a segment shorter than the radius for +/// grabbing the first vertex: the click that should have placed the next vertex counted as the +/// last one clicked again and closed the outline, so a small outline could not be traced at all +/// (#7629). Clicking the same point again still closes it, within a jitter of where it is. +#[test] +fn a_short_alt_lasso_segment_places_a_vertex_instead_of_closing() { + let mut app = polygon_app(); + app.ui.tool = Tool::Lasso; + let alt = Modifiers::ALT; + let click = |app: &mut PhotocraftApp, x: f64, y: f64| { + ev(app, "down", x, y, alt); + ev(app, "up", x, y, alt); + }; + for (x, y) in [(50.0, 50.0), (90.0, 50.0), (90.0, 90.0)] { + click(&mut app, x, y); + } + // 5 document units from the last vertex, inside the 8-unit reach at 100 %. + click(&mut app, 85.0, 91.0); + let points = app.drag.as_ref().map(|d| d.points.len()); + assert_eq!(points, Some(4), "the short segment placed a vertex: {:?}", app.drag.as_ref().map(|d| d.points.clone())); + assert!(app.session.active().unwrap().doc.selection.is_none(), "and the outline is still being drawn"); + + // Clicking the last vertex again, within a jitter of it, still closes the outline. + click(&mut app, 86.0, 92.0); + assert!(app.drag.is_none(), "the same point clicked again closes it"); + let r = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert!(r.is_some_and(|r| near(r, 50, 50, 90, 91)), "{r:?}"); + + // And so does a click on the first vertex, which is a grab rather than a repeat. + let mut app = polygon_app(); + app.ui.tool = Tool::Lasso; + for (x, y) in [(50.0, 50.0), (90.0, 50.0), (90.0, 90.0), (85.0, 91.0)] { + click(&mut app, x, y); + } + click(&mut app, 54.0, 53.0); + assert!(app.drag.is_none(), "the first vertex closes it from a few points away"); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} + /// Double-clicking on the first vertex: the first click closes the polygon, and the second must /// not start a new one (which would deselect what was just made). #[test] From 674bfb697be24ff56f52a6e283bf0cebf07e6ce9 Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Mon, 19 Oct 2026 16:27:06 +0300 Subject: [PATCH 0828/1452] docs(atn): document Ripple field conversion and test helpers --- crates/engine/src/atn_cmds.rs | 4 ++++ crates/engine/tests/atn_ripple_amount.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 31519677d4..1507995bd5 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -404,6 +404,8 @@ enum Kind { /// One descriptor key ↔ one command param. struct F(&'static str, &'static str, Kind); +/// Import mapped descriptor fields, leaving absent values to command defaults. +/// Return `None` when a present enum, blend mode or colour cannot be represented. fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { let mut m = Map::new(); for F(key, param, kind) in fields { @@ -430,6 +432,8 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { Some(m) } +/// Export supplied mapped params, omitting absent values to preserve event defaults. +/// Return `None` for unsupported values; `ExactLong` also rejects rounding or saturation. fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { for F(key, param, kind) in fields { let Some(v) = p.get(param) else { continue }; diff --git a/crates/engine/tests/atn_ripple_amount.rs b/crates/engine/tests/atn_ripple_amount.rs index 8149a838c6..b6bde88f7a 100644 --- a/crates/engine/tests/atn_ripple_amount.rs +++ b/crates/engine/tests/atn_ripple_amount.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; const RIPPLE: &str = "filter.distort.ripple"; +/// Build a deterministic RGB image at the requested depth that exposes Ripple rounding. fn fixture(depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); @@ -21,6 +22,7 @@ fn fixture(depth: u32) -> Session { s } +/// Flatten the active document to compare direct, native and ATN playback pixels. fn pixels(s: &Session) -> Vec<[f32; 4]> { photocraft_compose::flatten(&s.active().unwrap().doc).px } From f30966569467231f34765998d136eb36ed6140f2 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 18:29:33 +0500 Subject: [PATCH 0829/1452] Refuse a Selective Color channel the command would refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scalar form's channels went through `jnum`, which returns `None` for anything that is not a number, so `{"colors": "reds", "cyan": "35"}` exported as an untouched range while the command errors on it (`adjust_cmds::selective_from_params` → `percent`). The export would have written an adjustment that is not the one recorded. A channel that is present and not null must now be a finite number, or the step cannot be written and takes the refusal path. `null` counts as absent, as the command's `given` reads it. --- crates/engine/src/atn_cmds.rs | 9 +++++++-- crates/engine/src/atn_cmds/tests.rs | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index a4d6c33de5..87749a64d6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -740,8 +740,13 @@ fn selective_to(p: &Value) -> Option { let row = rows.get_mut(i)?; let mut v = row.unwrap_or([0.0; 4]); for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) { - if let Some(n) = jnum(p, key) { - *slot = n; + // A channel that is given but is not a number is an error in the command + // (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written + // either: ignoring it would export a different adjustment than the one recorded. + // `null` counts as absent there, and so here. + match p.get(key) { + None | Some(Value::Null) => {} + Some(_) => *slot = jnum(p, key)?, } } *row = Some(v); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 8ecf7334f7..81766af2cb 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -197,6 +197,17 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { // The array form is unchanged. let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]})); assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}"); + + // A channel the command would refuse is not quietly dropped: the step is refused too, since + // writing it would export an adjustment that is not the one recorded. `null` is absent, as + // the command reads it. + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))])); + let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); + assert!(e.contains("image.adjustments.selectiveColor"), "{e}"); + + let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null})); + assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); } #[test] From 604007490bfba2f0df84b8e0b87f8b6648229214 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:33:05 +0200 Subject: [PATCH 0830/1452] fix(psd): replace an earlier save's Txt2 stub also when its text is edited The stub filter applied only to the kept object, so an edited layer whose slot held a stub from an earlier save went through edited_object and kept the bare stub. The slot is now filtered before both lookups. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 12 +++++++----- crates/text/src/tests.rs | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 5ffbebd94d..2caddb0c6e 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -987,18 +987,20 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let text = format!("{}\r", layer.text.replace('\n', "\r")); let modes = txt2_mode_runs(layer); let at = *index as usize; - let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); + // A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so + // it is replaced like a missing one (below) and re-saving such a file repairs it, whether + // or not the layer's text changed. + let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs); + let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); // An edited object (#6968): the file's object at this slot with its text changed. It keeps // the file's structure — paragraph runs, the view naming its frame, the rest — with every // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. - let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); + let edited = kept.is_none().then(|| slot.and_then(|o| edited_object(o, &text))).flatten(); // A new object (#7698): a layer the file's block has no object for (a type layer added // here) takes the shape of one of the file's own objects, refitted the same way, with its // view naming its own frame. A bare stub beside Photoshop's objects, its slot without a - // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A stub an earlier save - // wrote is replaced the same way, so re-saving such a file repairs it. - let kept = kept.filter(|o| !is_stub(o) || all_stubs); + // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); let reuse = kept.is_some() || edited.is_some() || fresh.is_some(); let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4f6aad9c86..1015107843 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1190,6 +1190,12 @@ fn new_text_layer_gets_a_txt2_object_and_frame() { assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap(); assert_eq!(repaired, out, "a stub from an earlier save is replaced"); + // Also when the stub's layer is edited before the re-save. + let edited = runs_of("v2.0", &[(4, CharStyle::default())]); + let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &edited)], Some(&broken))).unwrap(); + let object = &repaired.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v2.0\r")); + assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "an edited stub is replaced too"); // No object to take the shape from: the fresh object as before, with the frames still padded. let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> "; From 4277f12437d4e3f6b3f8e293be049a9833342efa Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:44:01 +0200 Subject: [PATCH 0831/1452] Vanishing Point: skip the source window read when it is disabled (#7491) Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/vanishing.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 87e55f8733..3077371976 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -376,8 +376,10 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: || u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px { win.area = Rect::new(0, 0, 0, 0); + win.data.clear(); + } else { + src_copy.read_region_into(win.area, &mut win.data); } - src_copy.read_region_into(win.area, &mut win.data); let mut data = surf.read_region(area); let w = area.width() as usize; let mut changed = false; From ebfe3d98daabc226af5528b0ff66b2a361f1e68b Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 16:02:32 +0200 Subject: [PATCH 0832/1452] Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) `slices::resolve()` only clipped each stored slice to the canvas, so a slice under a later one was numbered and exported in full and Save for Web wrote the overlap twice (the HTML path cut pieces itself, named `_01`, `_02`). As Photoshop does, resolve() now cuts a covered slice into its visible pieces: the first piece in reading order keeps the name, every further piece is `-NN` with its own slice number, and auto slices count the pieces in their numbering (measured in Photoshop: sliceA, _02, sliceA-03, sliceB, _05). - Save for Web exports resolve()'s pieces as they are, with or without HTML; a no-image cell's text goes into its first piece only. - Slice Options and slice.list show the slice's own name for every piece; slice.set accepts a slice that ends up wholly under later ones (number null) instead of calling it off the canvas. - Slice Select drags the whole slice, not the piece that was hit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/slices.rs | 58 +++++++++++++++++---- crates/engine/src/slice_cmds.rs | 17 +++++-- crates/engine/src/slice_cmds/tests.rs | 17 +++++++ crates/engine/src/web_cmds.rs | 73 ++++++++++++--------------- crates/engine/src/web_cmds/tests.rs | 20 +++++++- crates/ui-egui/src/slice_ui.rs | 19 ++++++- 6 files changed, 144 insertions(+), 60 deletions(-) diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 2705bdf5f0..e22fcc0b79 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -168,10 +168,17 @@ pub struct ResolvedSlice { } /// The visible part of every stored slice (later slices cover earlier ones, as in Photoshop), -/// clipped to the canvas. -fn clipped(doc: &Document) -> Vec<(u32, Rect)> { +/// clipped to the canvas: a covered slice is cut into the rectangles that stay visible, each +/// paired with the slice's index in the list. +fn visible(doc: &Document) -> Vec<(usize, Rect)> { let canvas = doc.bounds(); - doc.slices.list.iter().map(|s| (s.id, s.rect.intersect(&canvas))).filter(|(_, r)| !r.is_empty()).collect() + let rects: Vec = doc.slices.list.iter().map(|s| s.rect.intersect(&canvas)).collect(); + let mut out = Vec::new(); + for (i, r) in rects.iter().enumerate() { + let above = rects.get(i + 1..).unwrap_or_default(); + out.extend(auto_slices(*r, above).into_iter().map(|p| (i, p))); + } + out } /// Rectangles that tile `canvas` minus `taken`. The canvas is cut into a grid along every slice @@ -229,26 +236,31 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec { /// Every slice of `doc` in display order (see [`ResolvedSlice`]). Without user or layer slices /// the whole canvas is one auto slice named `` alone (as Photoshop's Save for Web names -/// it); with several, generated names are numbered `_NN`. +/// it); with several, generated names are numbered `_NN`. A slice covered by later ones +/// appears once per visible piece: the first piece keeps its name, the others are `-NN`. pub fn resolve(doc: &Document) -> Vec { let base = base_name(doc); - let stored = clipped(doc); + let stored = visible(doc); let rects: Vec = stored.iter().map(|(_, r)| *r).collect(); - let mut all: Vec<(Option, Rect)> = stored.iter().map(|(id, r)| (Some(*id), *r)).collect(); + let mut all: Vec<(Option, Rect)> = stored.iter().map(|(i, r)| (Some(*i), *r)).collect(); all.extend(auto_slices(doc.bounds(), &rects).into_iter().map(|r| (None, r))); all.sort_by_key(|(_, r)| (r.y0, r.x0)); let single = all.len() == 1; + let mut named = vec![false; doc.slices.list.len()]; all.into_iter() .enumerate() - .map(|(i, (id, rect))| { - let s = id.and_then(|id| doc.slices.get(id)); - let number = i + 1; + .map(|(n, (i, rect))| { + let s = i.and_then(|i| doc.slices.list.get(i)); + let number = n + 1; + let first = i.and_then(|i| named.get_mut(i)).is_none_or(|seen| !std::mem::replace(seen, true)); let name = match s.map(|s| s.name.as_str()).filter(|n| !n.is_empty()) { - Some(n) => n.to_string(), + Some(n) if first => n.to_string(), + Some(n) => format!("{n}-{number:02}"), None if single => base.clone(), None => format!("{base}_{number:02}"), }; - ResolvedSlice { number, id, origin: s.map_or(SliceOrigin::Auto, |s| s.origin), rect, name, kind: s.map_or(SliceKind::Image, |s| s.kind) } + let (id, origin, kind) = s.map_or((None, SliceOrigin::Auto, SliceKind::Image), |s| (Some(s.id), s.origin, s.kind)); + ResolvedSlice { number, id, origin, rect, name, kind } }) .collect() } @@ -303,6 +315,30 @@ mod tests { assert!(resolve(&d).iter().any(|s| s.name == "logo")); } + #[test] + fn later_slices_cover_earlier_ones() { + // Photoshop's Save for Web on this document writes sliceA [0,0,60,40], o82_02, sliceA-03 + // [0,40,40,60], sliceB and o82_05: the covered slice is cut into its visible pieces. + let mut d = Document::new("o82.psd", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + d.slices.list.push(Slice { id: 1, name: "sliceA".into(), rect: Rect::new(0, 0, 60, 60), ..Default::default() }); + d.slices.list.push(Slice { id: 2, name: "sliceB".into(), rect: Rect::new(40, 40, 100, 100), ..Default::default() }); + let r = resolve(&d); + let got: Vec<(&str, Option, Rect)> = r.iter().map(|s| (s.name.as_str(), s.id, s.rect)).collect(); + assert_eq!( + got, + [ + ("sliceA", Some(1), Rect::new(0, 0, 60, 40)), + ("o82_02", None, Rect::new(60, 0, 100, 40)), + ("sliceA-03", Some(1), Rect::new(0, 40, 40, 60)), + ("sliceB", Some(2), Rect::new(40, 40, 100, 100)), + ("o82_05", None, Rect::new(0, 60, 40, 100)), + ] + ); + d.slices.list[0].name.clear(); + let names: Vec = resolve(&d).into_iter().filter(|s| s.id == Some(1)).map(|s| s.name).collect(); + assert_eq!(names, ["o82_01", "o82_03"]); + } + #[test] fn exhausted_slice_id_survives_serialization() { let slices = Slices { list: vec![Slice { id: u32::MAX, ..Default::default() }], ..Default::default() }; diff --git a/crates/engine/src/slice_cmds.rs b/crates/engine/src/slice_cmds.rs index 959f132927..b3aa66919b 100644 --- a/crates/engine/src/slice_cmds.rs +++ b/crates/engine/src/slice_cmds.rs @@ -243,7 +243,8 @@ pub fn resolved_json(doc: &Document, r: &ResolvedSlice) -> Value { "number": r.number, "id": r.id, "origin": r.origin.id(), - "name": r.name, + // A covered slice's later pieces show as `-NN`; the slice itself keeps its name. + "name": s.map(|s| s.name.as_str()).filter(|n| !n.is_empty()).unwrap_or(&r.name), "rect": [r.rect.x0, r.rect.y0, r.rect.width(), r.rect.height()], "kind": r.kind.id(), "layer": s.and_then(|s| s.layer).map(|l| l.0), @@ -321,16 +322,24 @@ fn set_slice(s: &mut Session, p: &Value) -> Result { // nothing is committed unless every option is valid and the slice stays on the canvas. let id = s.edit("Slice Options", |doc, _| { let id = store(doc, t)?; + let canvas = doc.bounds(); let sl = doc.slices.get_mut(id).ok_or_else(|| bad(cmd, "slice vanished"))?; apply_options(sl, p, cmd)?; - if !slices::resolve(doc).iter().any(|r| r.id == Some(id)) { + if sl.rect.intersect(&canvas).is_empty() { return Err(bad(cmd, "slice is off the canvas")); } Ok(id) })?; let d = s.active().ok_or(EngineError::NoDocument)?; - let r = slices::resolve(&d.doc).into_iter().find(|r| r.id == Some(id)).ok_or_else(|| bad(cmd, "slice is off the canvas"))?; - Ok(resolved_json(&d.doc, &r)) + if let Some(r) = slices::resolve(&d.doc).into_iter().find(|r| r.id == Some(id)) { + return Ok(resolved_json(&d.doc, &r)); + } + // Wholly under later slices: it has no visible piece, so no number. + let sl = d.doc.slices.get(id).ok_or_else(|| bad(cmd, "slice vanished"))?; + let r = ResolvedSlice { number: 0, id: Some(id), origin: sl.origin, rect: sl.rect.intersect(&d.doc.bounds()), name: sl.name.clone(), kind: sl.kind }; + let mut v = resolved_json(&d.doc, &r); + v["number"] = Value::Null; + Ok(v) } fn promote(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/slice_cmds/tests.rs b/crates/engine/src/slice_cmds/tests.rs index 4c3008ef41..7d572238ee 100644 --- a/crates/engine/src/slice_cmds/tests.rs +++ b/crates/engine/src/slice_cmds/tests.rs @@ -51,6 +51,23 @@ fn user_slices_and_options() { } } +#[test] +fn covered_slices_keep_their_options() { + // #7770: a slice under a later one shows as its visible pieces, yet stays one slice. + let mut s = session(8); + let under = s.execute("slice.new", json!({"rect": [0, 0, 60, 60], "name": "under"})).unwrap()["slice"].as_u64().unwrap(); + s.execute("slice.new", json!({"rect": [40, 40, 60, 50], "name": "over"})).unwrap(); + let l = s.execute("slice.list", json!({})).unwrap(); + let pieces: Vec<&Value> = l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).collect(); + assert_eq!(pieces.len(), 2); + assert!(pieces.iter().all(|v| v["name"] == "under"), "Slice Options shows the slice's own name: {pieces:?}"); + // Moved wholly under the later slice, it is still on the canvas. + let r = s.execute("slice.set", json!({"slice": under, "rect": [50, 50, 10, 10], "alt": "hidden"})).unwrap(); + assert_eq!(r["number"], Value::Null); + assert_eq!(r["alt"], "hidden"); + assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(50, 50, 60, 60)); +} + #[test] fn rect_param_rejects_corner_overflow() { assert_eq!(rect_param(&json!({"rect": [1, 2, 3, 4]})), Some(Rect::new(1, 2, 4, 6))); diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 29237fd5ad..7b373199bb 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -964,53 +964,42 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } name }; - // Stacking order: later stored slices are on top (auto slices never overlap anything). - let z = |r: &ResolvedSlice| r.id.and_then(|id| doc.slices.list.iter().position(|sl| sl.id == id)); + // Slices never overlap here: one covered by later slices comes as its visible pieces (see + // `slices::resolve`), each its own image and cell, as Photoshop's subslices. A no-image cell's + // text goes into its first piece only. + let mut texted = std::collections::HashSet::new(); for r in &chosen { - let rect = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); - if rect.is_empty() { + let piece = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); + if piece.is_empty() { continue; } - // In the HTML table a slice shows only where no slice above covers it; that visible part - // is cut into rectangles, each its own cell (and image), as Photoshop's subslices. - let pieces = if html { - let above: Vec = chosen.iter().filter(|o| z(o) > z(r)).map(|o| scale_rect(o.rect, sx, sy).intersect(&wdoc.bounds())).collect(); - slices::auto_slices(rect, &above) - } else { - vec![rect] - }; - let split = pieces.len() > 1; let stored = r.id.and_then(|id| doc.slices.get(id)); - for (k, piece) in pieces.into_iter().enumerate() { - if r.kind == SliceKind::NoImage { - let text = if k > 0 { - String::new() - } else { - stored.map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }).unwrap_or_default() - }; - let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); - cells.push((piece, bg, text)); - continue; - } - let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; - let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; - let name = unique(&stem, o.ext); - let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; - let out = join(&dir, &rel); - write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; - total += o.bytes.len(); - files.push(out); - let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); - let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); - let body = match stored.filter(|s| safe_slice_url(&s.url)) { - Some(s) => { - let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; - format!("\n\t\t\t{img}", html_escape(&s.url)) - } - None => img, - }; - cells.push((piece, String::new(), body)); + if r.kind == SliceKind::NoImage { + let text = stored + .filter(|s| texted.insert(s.id)) + .map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }) + .unwrap_or_default(); + let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); + cells.push((piece, bg, text)); + continue; } + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; + let name = unique(&slice_file(&r.name), o.ext); + let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; + let out = join(&dir, &rel); + write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; + total += o.bytes.len(); + files.push(out); + let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); + let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); + let body = match stored.filter(|s| safe_slice_url(&s.url)) { + Some(s) => { + let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; + format!("\n\t\t\t{img}", html_escape(&s.url)) + } + None => img, + }; + cells.push((piece, String::new(), body)); } let mut html_path = None; if html { diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 5791e5f27c..ea8fe40177 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -785,6 +785,21 @@ fn slices_with_the_same_name_get_distinct_files() { assert!(html.contains("images/tile.gif") && html.contains("images/tile_2.gif")); } +#[test] +fn overlapping_slices_export_each_pixel_once() { + // #7770: without HTML too, a slice covered by a later one is written as its visible pieces. + let dir = tmp("overlap_images"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 40, 30], "name": "under"})).unwrap(); + s.execute("slice.new", json!({"rect": [20, 15, 40, 30], "name": "over"})).unwrap(); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "slices": "user"})).unwrap(); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let dims: Vec<(u32, u32)> = files.iter().map(|f| decode(f).dimensions()).collect(); + files.iter_mut().for_each(|f| *f = f.rsplit('/').next().unwrap().to_string()); + assert_eq!(files, ["under.png", "under-03.png", "over.png"]); + assert_eq!(dims, [(40, 15), (20, 15), (40, 30)]); +} + #[test] fn overlapping_slices_keep_the_later_slice_in_html() { // #909: the later slice is on top; the earlier one shows only where it isn't covered. @@ -809,7 +824,10 @@ fn overlapping_slices_keep_the_later_slice_in_html() { }) .sum(); assert_eq!(area, 64 * 48); - assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}"); + // As Photoshop names subslices: the first piece keeps the name, the others add their number. + let under = files.iter().find(|f| f.ends_with("images/under.png")).unwrap(); + assert_eq!(decode(under).dimensions(), (40, 15), "the covered slice is cut into pieces: {files:?}"); + assert!(files.iter().any(|f| f.ends_with("images/under-03.png")), "{files:?}"); } #[path = "color_table_tests.rs"] diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 1e75bbd714..d997e2ed96 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -68,10 +68,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) - } else { let hit = slice_at(&doc, x, y); app.ui.slices.selected = hit.as_ref().map(|r| r.number); + // The whole slice moves, also when the press hits one piece of a covered slice. if let Some(r) = hit - && let Some(id) = r.id.filter(|_| !locked && r.origin == SliceOrigin::User) + && let Some(sl) = r.id.and_then(|id| doc.slices.get(id)).filter(|_| !locked && r.origin == SliceOrigin::User) { - app.ui.slices.drag = Some(SliceDrag::Move { id, start: [x, y], cur: [x, y], rect: r.rect }); + app.ui.slices.drag = Some(SliceDrag::Move { id: sl.id, start: [x, y], cur: [x, y], rect: sl.rect }); } } } @@ -323,6 +324,20 @@ mod tests { assert!(doc.slices.list.iter().any(|s| s.name == "footer")); } + #[test] + fn dragging_a_piece_moves_the_whole_covered_slice() { + // #7770: the slice under a later one is drawn as pieces; dragging one moves all of it. + let mut app = app(); + app.run("slice.new", json!({"rect": [0, 0, 60, 60]})).unwrap(); + app.run("slice.new", json!({"rect": [40, 40, 20, 20]})).unwrap(); + app.ui.tool = Tool::SliceSelect; + let m = egui::Modifiers::NONE; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 50.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 15.0, y: 50.0 }, m); + let doc = app.session.active().unwrap().doc.clone(); + assert_eq!(doc.slices.list[0].rect, photocraft_geom::Rect::new(5, 0, 65, 60)); + } + #[test] fn locked_slices_ignore_gestures() { let mut app = app(); From 7a60608cdd1522a7150439c991b541ba5a6e130c Mon Sep 17 00:00:00 2001 From: Ni-zav Date: Mon, 19 Oct 2026 21:39:32 +0700 Subject: [PATCH 0833/1452] fix(ui): preserve imported bevel shading precision --- crates/ui-egui/src/layer_style.rs | 50 ++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 78657db3e6..1b68b318d8 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -648,7 +648,21 @@ fn apply(f: &Map, mut run: impl FnMut(&str, Value) -> Result json!({"kind": kind, "fx": fx, "params": params}), + Some(fx) => { + if kind == "bevelEmboss" + && let Ok(original) = serde_json::from_value::(fx.clone()) + && let Some(edits) = params.as_object_mut() + { + let shown = values_of(&original, initial_light_angle.unwrap_or(120.0) as f32); + // Unedited display values must not quantize imported shading in the snapshot. + for key in ["highlightBlend", "highlightColor", "highlightOpacity", "shadowBlend", "shadowColor", "shadowOpacity"] { + if edits.get(key) == shown.get(key) { + edits.remove(key); + } + } + } + json!({"kind": kind, "fx": fx, "params": params}) + } None => { // A new instance: the page shows the factory defaults merged with // the edits, and the engine builds from the full set. @@ -2079,6 +2093,40 @@ mod tests { } } + #[test] + fn unedited_bevel_shading_keeps_imported_precision() { + for size in [None, Some(12.0)] { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({})).unwrap(); + s.edit("imported shading", |doc, active| { + let Effect::BevelEmboss(b) = &mut doc.layer_mut(active.unwrap()).unwrap().effects.items[0] else { panic!("bevel") }; + b.highlight_color = photocraft_color::Color::rgb(0.12345, 0.45678, 0.78901); + b.shadow_color = photocraft_color::Color::rgb(0.98765, 0.54321, 0.13579); + b.highlight.opacity = 0.3525; + b.shadow.opacity = 0.6175; + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let original = st.doc.layer(id).unwrap().effects.items[0].clone(); + let mut fields = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + if let Some(size) = size { + set_param(&mut fields, "fx1", "size", json!(size)); + } + apply(&fields, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::BevelEmboss(before) = original else { panic!("bevel") }; + let Effect::BevelEmboss(after) = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!("bevel") }; + assert_eq!(after.highlight, before.highlight); + assert_eq!(after.shadow, before.shadow); + assert_eq!(after.highlight_color, before.highlight_color); + assert_eq!(after.shadow_color, before.shadow_color); + if let Some(size) = size { + assert_eq!(after.size, size, "the unrelated edit still applies"); + } + } + } + #[test] fn bevel_shading_round_trips_through_dialog_preview_and_confirm() { let mut s = session(); From 05bd004bc44b33d669ccdd05af64e601e30d1b92 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:38:22 +0200 Subject: [PATCH 0834/1452] perf(format): reuse native save writers per document Reuse unchanged tile and blob hashes and bound retained compressed payloads. Carry the writer through native save settings while preserving save identity and exact output. --- crates/engine/src/lib.rs | 3 + crates/format/src/store.rs | 116 ++++++++++++++++++++-- crates/io/src/lib.rs | 11 +- crates/io/src/pcraft_cache.rs | 74 ++++++++++++++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/save_identity_tests.rs | 10 ++ docs/zstd-workspaces-performance.md | 63 ++++++------ 7 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 crates/io/src/pcraft_cache.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba7556901b..9b033dc1ea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -209,6 +209,8 @@ pub struct DocState { /// Increments on every change; UIs re-render when it moves. pub revision: u64, pub saved_revision: u64, + /// Native save cache shared with this document's workers, released with the editing state. + pub pcraft_cache: photocraft_io::PcraftExportCache, /// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those. pub view_revision: u64, /// Area changed by the latest revision (None = assume everything changed). @@ -270,6 +272,7 @@ impl DocState { source_read_only: false, revision: 1, saved_revision: 1, + pcraft_cache: Default::default(), view_revision: 0, last_damage: None, coalesce: None, diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 7da5dd6b53..13c1506641 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -304,16 +304,20 @@ pub struct SaveStats { pub manifest_bytes: usize, } +type BlobHashCache = HashMap>, Hash)>; +type TileHashCache = HashMap<(usize, SampleType), (Weak, Hash)>; + #[derive(Default)] struct Collect<'c> { tiles: BTreeMap, SampleType)>, blobs: BTreeMap>>, - hash_cache: Option<&'c mut HashMap, Hash)>>, + hash_cache: Option<&'c mut TileHashCache>, + blob_hash_cache: Option<&'c mut BlobHashCache>, } impl Sink for Collect<'_> { fn tile(&mut self, format: PixelFormat, tile: &Arc) -> Hash { - let key = Arc::as_ptr(tile) as usize; + let key = (Arc::as_ptr(tile) as usize, format.sample); if let Some(cache) = self.hash_cache.as_deref_mut() && let Some((w, h)) = cache.get(&key) && w.upgrade().is_some_and(|t| Arc::ptr_eq(&t, tile)) @@ -331,7 +335,19 @@ impl Sink for Collect<'_> { } fn blob(&mut self, data: &Arc>) -> Hash { + let key = Arc::as_ptr(data) as usize; + if let Some(cache) = self.blob_hash_cache.as_deref_mut() + && let Some((w, h)) = cache.get(&key) + && w.upgrade().is_some_and(|b| Arc::ptr_eq(&b, data)) + { + let h = h.clone(); + self.blobs.entry(h.clone()).or_insert_with(|| data.clone()); + return h; + } let h = hash_bytes(data); + if let Some(cache) = self.blob_hash_cache.as_deref_mut() { + cache.insert(key, (Arc::downgrade(data), h.clone())); + } self.blobs.entry(h.clone()).or_insert_with(|| data.clone()); h } @@ -365,16 +381,25 @@ pub const DEFAULT_REVERIFY_BUDGET: u64 = 16 * 1024 * 1024; /// [`Self::set_reverify_budget`]). Over enough saves every object is read again, and a damaged /// one is rewritten. pub struct PcraftWriter { - hash_cache: HashMap, Hash)>, + hash_cache: TileHashCache, + blob_hash_cache: BlobHashCache, /// Compressed objects by bundle path (ZIP mode). compressed: HashMap>>, + compressed_budget: usize, verified_directory: Option, reverify_budget: u64, } impl Default for PcraftWriter { fn default() -> Self { - Self { hash_cache: HashMap::new(), compressed: HashMap::new(), verified_directory: None, reverify_budget: DEFAULT_REVERIFY_BUDGET } + Self { + hash_cache: HashMap::new(), + blob_hash_cache: HashMap::new(), + compressed: HashMap::new(), + compressed_budget: usize::MAX, + verified_directory: None, + reverify_budget: DEFAULT_REVERIFY_BUDGET, + } } } @@ -729,10 +754,18 @@ impl PcraftWriter { self.reverify_budget = bytes; } + /// Cap compressed payload capacity retained between ZIP saves. Clears the previous payload + /// cache; tile hashes remain reusable. The default is unbounded; `0` retains no payloads. + pub fn set_compressed_cache_budget(&mut self, bytes: usize) { + self.compressed_budget = bytes; + self.compressed.clear(); + } + fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); + self.blob_hash_cache.retain(|_, (w, _)| w.strong_count() > 0); convert::check_nesting(&doc.layers)?; - let mut c = Collect { hash_cache: Some(&mut self.hash_cache), ..Default::default() }; + let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() }; let document = convert::doc_m(doc, &mut c); let mut previews = Vec::new(); if let Some(t) = &opts.thumbnail { @@ -777,6 +810,7 @@ impl PcraftWriter { z.add(name, data)?; } let mut next = HashMap::with_capacity(p.objects.len()); + let mut cached_bytes = 0usize; // New objects are compressed on all cores first (zstd dominates a full save). let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect(); let mut fresh: HashMap<&String, Arc>> = par_compress(&todo)?.into_iter().collect(); @@ -797,7 +831,10 @@ impl PcraftWriter { } }; z.add(path, &data)?; - next.insert(path.clone(), data); + if data.capacity() <= self.compressed_budget.saturating_sub(cached_bytes) { + cached_bytes += data.capacity(); + next.insert(path.clone(), data); + } } self.compressed = next; Ok((z.finish()?, stats)) @@ -945,6 +982,73 @@ impl PcraftWriter { } } +#[cfg(test)] +#[test] +fn tile_hash_cache_keeps_sample_byte_order_in_its_identity() { + let mut cache = HashMap::new(); + let mut surface = photocraft_raster::Surface::new(PixelFormat::RGBA8); + surface.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]); + let tile = surface.tile(photocraft_geom::TileCoord::new(0, 0)).unwrap(); + let mut collect = Collect { hash_cache: Some(&mut cache), ..Default::default() }; + for sample in [SampleType::U8, SampleType::U16] { + let format = if sample == SampleType::U8 { PixelFormat::RGBA8 } else { PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true) }; + assert_eq!(collect.tile(format, tile), hash_bytes(&tile_le(tile, sample))); + } + drop(collect); + assert_eq!(cache.len(), 2); +} + +#[cfg(test)] +#[test] +fn blob_hash_cache_tracks_arc_identity_without_retaining_payloads() { + let mut cache = HashMap::new(); + let original = Arc::new(vec![1, 2, 3]); + let weak = Arc::downgrade(&original); + let mut collect = Collect { blob_hash_cache: Some(&mut cache), ..Default::default() }; + let hash = collect.blob(&original); + assert_eq!(collect.blob(&original), hash); + let mut edited = original.clone(); + Arc::make_mut(&mut edited)[0] = 7; + assert_eq!(collect.blob(&edited), hash_bytes(&[7, 2, 3])); + assert_ne!(collect.blob(&edited), hash); + drop(collect); + assert_eq!(cache.len(), 2); + drop(original); + assert!(weak.upgrade().is_none()); +} + +#[cfg(test)] +#[test] +fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() { + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + let mut doc = Document::new("cache", Size::new(257, 1), photocraft_color::ColorMode::Rgb, SampleType::U8); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + surface.write_pixel(256, 0, &[0.6, 0.4, 0.2, 1.0]); + doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface))); + let mut writer = PcraftWriter::new(); + let (expected, _) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + let budget = writer.compressed.values().map(|v| v.capacity()).min().unwrap(); + writer.set_compressed_cache_budget(budget); + assert!(writer.compressed.is_empty()); + let (bytes, _) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert!(writer.compressed.values().map(|v| v.capacity()).sum::() <= budget); + assert!(!writer.compressed.is_empty()); + let retained = writer.compressed.len(); + let (bytes, stats) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert_eq!(stats.tiles_reused, retained); + writer.set_compressed_cache_budget(0); + let (bytes, stats) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert_eq!(stats.tiles_reused, 0); + assert!(writer.compressed.is_empty()); +} + #[cfg(all(test, unix))] #[test] fn gc_pinned_directory_handles_resist_subdirectory_swap() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3543cd359c..ec27d0ff0c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -52,8 +52,10 @@ mod multichannel_map; mod ora; pub use ora::losses as ora_losses; pub mod pattern_map; +mod pcraft_cache; pub mod pdf; mod pdn; +pub use pcraft_cache::PcraftExportCache; pub mod pixels; mod psd_export; mod psd_import; @@ -191,6 +193,8 @@ pub struct ExportOptions { /// The path of the document's own file, which linked smart objects that PSD/PSB and layered /// TIFF embed are resolved against ([`doc_path::resolve`]). pub document_path: Option, + /// Reuse one cache per open document for native saves; other formats ignore it. + pub pcraft_cache: Option, } impl Default for ExportOptions { @@ -204,6 +208,7 @@ impl Default for ExportOptions { image_previews: true, psd_maximize_compatibility: true, document_path: None, + pcraft_cache: None, } } } @@ -413,7 +418,11 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); let previews = photocraft_format::SaveOptions { thumbnail, composite }; - return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); + let bytes = match &opts.pcraft_cache { + Some(cache) => cache.encode(doc.as_ref(), &previews)?, + None => photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, + }; + return Ok(ExportResult { bytes, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { diff --git a/crates/io/src/pcraft_cache.rs b/crates/io/src/pcraft_cache.rs new file mode 100644 index 0000000000..86af7cb063 --- /dev/null +++ b/crates/io/src/pcraft_cache.rs @@ -0,0 +1,74 @@ +//! Per-document native export cache; file writes and save identity stay with the caller. + +use std::sync::{Arc, Mutex}; + +use photocraft_doc::Document; +use photocraft_format::{PcraftWriter, SaveOptions}; + +/// Shared native export cache. Clones reuse tile hashes and up to 64 MiB of compressed payloads; +/// dropping the last clone releases it, including when a document closes after a save worker. +#[derive(Clone)] +pub struct PcraftExportCache(Arc>); + +impl Default for PcraftExportCache { + fn default() -> Self { + let mut writer = PcraftWriter::new(); + writer.set_compressed_cache_budget(64 << 20); + Self(Arc::new(Mutex::new(writer))) + } +} + +impl std::fmt::Debug for PcraftExportCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PcraftExportCache") + } +} + +impl PartialEq for PcraftExportCache { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl PcraftExportCache { + pub(crate) fn encode(&self, doc: &Document, opts: &SaveOptions) -> photocraft_format::Result> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).save_zip(doc, opts).map(|(bytes, _)| bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ExportOptions, export}; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + #[test] + fn cached_exports_reuse_tiles_and_preserve_edits_and_save_as() { + for depth in SampleType::ALL { + let mut doc = Document::new("first.pcraft", Size::new(2, 1), ColorMode::Rgb, depth); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + doc.layers.push(Layer::new("photo", LayerContent::Raster(surface))); + let cache = PcraftExportCache::default(); + let options = ExportOptions { pcraft_cache: Some(cache.clone()), ..Default::default() }; + let first = export(&doc, "first.pcraft", &options).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&first.bytes).unwrap(), doc); + let (_, stats) = cache.0.lock().unwrap().save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(stats.tiles_reused, stats.tiles_total); + assert_eq!(stats.tiles_written, 0); + doc.layers[0].surface_mut().unwrap().write_pixel(0, 0, &[0.8, 0.3, 0.1, 1.0]); + let next = export(&doc, "second.pcraft", &options).unwrap(); + let mut expected = doc.clone(); + expected.name = "second.pcraft".into(); + assert_eq!(photocraft_format::load_from_bytes(&next.bytes).unwrap(), expected); + assert_eq!(next.bytes, export(&doc, "second.pcraft", &ExportOptions::default()).unwrap().bytes); + let weak = Arc::downgrade(&cache.0); + drop(options); + drop(cache); + assert!(weak.upgrade().is_none()); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5948bd4edf..855ef1e67f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,6 +287,8 @@ pub struct ExportSettings { /// The path of the document's own file, which linked smart objects that PSD/PSB embed are /// resolved against ([`photocraft_io::ExportOptions::document_path`]). pub document_path: Option, + /// Native save cache owned by the open document; exports without one remain one-shot. + pub pcraft_cache: Option, } impl Default for ExportSettings { @@ -303,6 +305,7 @@ impl Default for ExportSettings { embed_icc: true, embed_metadata: true, document_path: None, + pcraft_cache: None, } } } @@ -327,6 +330,7 @@ impl ExportSettings { opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; opts.document_path = self.document_path.clone(); + opts.pcraft_cache = self.pcraft_cache.clone(); opts } } @@ -1606,6 +1610,7 @@ impl PhotocraftApp { settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless); settings.webp_quality = e.webp_quality.map(|q| q.min(100)); settings.document_path = st.path.as_deref().map(std::path::PathBuf::from); + settings.pcraft_cache = Some(st.pcraft_cache.clone()); } settings } diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 3396dfcdd5..2805557c53 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -30,6 +30,16 @@ fn identity(app: &PhotocraftApp) -> (String, Option, u64, u64) { (st.doc.name.clone(), st.path.clone(), st.revision, st.saved_revision) } +#[test] +fn save_settings_share_only_the_open_documents_native_cache() { + let mut app = app(); + let first = app.save_settings().io_options().pcraft_cache.unwrap(); + assert_eq!(first, app.session.active().unwrap().pcraft_cache); + assert_eq!(first, app.save_settings().io_options().pcraft_cache.unwrap()); + app.run("file.new", json!({"width": 2, "height": 2})).unwrap(); + assert_ne!(first, app.save_settings().io_options().pcraft_cache.unwrap()); +} + #[test] fn new_document_psd_and_subsequent_save_as_adopt_the_written_file_name() { for depth in [8, 16, 32] { diff --git a/docs/zstd-workspaces-performance.md b/docs/zstd-workspaces-performance.md index 25b7e04854..a3eb6be39f 100644 --- a/docs/zstd-workspaces-performance.md +++ b/docs/zstd-workspaces-performance.md @@ -1,39 +1,42 @@ -# Rust Zstandard workspace reuse +# Native save writer reuse -Removed locally: complete layered-photo ZIP save changes range from -1.9% to +0.2%. An apparent 4.8% gain in one 16-bit APFS directory save failed independent confirmation, which was 3.2% slower. The other directory cases are neutral. There is no repeatable meaningful whole-operation gain, so store.rs, including its added workspace tests and synthetic microbenchmark, is restored byte for byte to main. The original local tip is recoverable at refs/archive/perf/zstd-workspaces-PRE-LAYERED-REVIEW. Only the performance report remains different from main on this branch. +Each open document keeps a writer through the ordinary save settings. Unchanged tile and +blob identities reuse weakly cached hashes; compressed payload capacity retained between +saves is capped at 64 MiB. The last cache owner releases it. Other formats ignore the cache, +and one-shot exports still work. Save As names, previews, cancellation, atomic writes and +saved revisions keep their existing behavior. Tile hash identity includes sample depth. -Measured on 2026-10-19 against frozen main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently for each branch. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1. Release O3, LTO off, 16 codegen units, debug off; builds/tests at nice 10 with warm caches. Timed operations run at normal priority without concurrent builds or profiling. Persistent processes alternate execution order after a warmup. Time change compares the two medians; paired bootstrap intervals from these small samples describe variation, not guarantees. +The format writer's compressed-cache limit defaults to unbounded for existing callers. +The UI selects 64 MiB. This is retained memory that a one-shot save would release; it does +not cap current-save scratch or ZIP output, and it does not reduce bytes written to disk. -CC0 Pixls P1000475.RW2 supplies a 6000 x 4000 centre crop (24 MP, no resampling); IMG_4059.CR2 supplies the native 4000 x 3000 city photograph. U8/U16/F32 documents retain ProPhoto RGB; F32 derives from 16-bit development and is not native HDR. No generated noise, repeated ramps, enlarged charts or duplicated photo layers are timed. Source pins and attribution remain in the existing corpus. +## Measurements -Each constructed photo edit has four visible layers: the real raster photograph; global Levels (black 2/255, white 250/255, gamma 1.08); Curves with master points (0,0), (0.25,0.22), (0.5,0.52), (0.75,0.80), (1,1) plus red midtone 0.5→0.515; and foreground Levels (gamma 1.15) through a smooth grayscale mask fading from zero to one over 35–75% of image height. These are authored benchmark edits of licensed photos, not Photoshop-authored oracle files. Saved previews were inspected. +Release build on Apple M4 Max. CC0 Pixls foliage (24 MP, unscaled crop) and city (12 MP, +native size) edits contain a photograph, global Levels, Curves and masked foreground Levels. +ProPhoto RGB is retained. U8/U16/F32 are tested; F32 derives from developed U16 samples. +Two upstream PSD designs add authored layered-file coverage. No generated noise is timed. -Complete full-resolution CPU rendering and fresh ZIP saves use seven alternating pairs. Thumbnail rendering, hashing and document equality are outside timers. Every render must match main float bit for float bit; every save must match main byte for byte and reload to the complete source document. Directory saves also use seven pairs and fresh directories with a fresh writer; an independent confirmation uses nine pairs. Directory creation/removal, output hashing and full reload equality are outside the timer. These directory timings include APFS writes and fsync, unlike the in-memory ZIP timings. These are CPU/core measurements, not GPU or browser performance claims. +The table uses seven alternating pairs after warmup and includes thumbnail/composite +rendering, encoding, APFS atomic writes, fsync and rename. Hashing, reload equality and file +removal are outside timers. Intervals are paired bootstrap 95% descriptions of these samples. +The parent's production path is the same as main. Each small edit changes one real-photo tile. -| Document | Operation | Main ms | Branch ms | Change | Paired 95% interval | +| Document | Operation | Main ms | Parent ms | Retained ms | Change vs parent | |---|---|---:|---:|---:|---:| -| panasonic-8 | save | 167.893 | 165.025 | -1.7% | -5.3..-0.6% | -| panasonic-16 | save | 371.857 | 364.765 | -1.9% | -3.0..+1.8% | -| panasonic-32 | save | 641.312 | 635.064 | -1.0% | -3.0..+4.3% | -| canon-8 | save | 93.469 | 93.592 | +0.1% | -0.8..+1.0% | -| canon-16 | save | 185.048 | 185.385 | +0.2% | -0.2..+4.3% | -| canon-32 | save | 327.223 | 326.519 | -0.2% | -0.2..+2.4% | -| panasonic-8 | save-dir | 5537.227 | 5534.940 | -0.0% | -7.1..+2.2% | -| panasonic-16 | save-dir | 7077.156 | 6739.019 | -4.8% | -5.3..-0.3% | -| canon-32 | save-dir | 3979.360 | 3974.353 | -0.1% | -0.9..+1.6% | -| panasonic-16 | save-dir (confirm) | 6483.861 | 6691.230 | +3.2% | +1.5..+9.2% | +| 24 MP foliage, U16 | save-first-disk | 966.0 | 966.0 | 984.9 | +2.0% (-8.5..+2.7) | +| 24 MP foliage, U16 | save-repeat-disk | 983.0 | 983.0 | 651.8 | -33.7% (-35.8..-30.9) | +| 24 MP foliage, U16 | save-edit-disk | 968.6 | 968.6 | 640.1 | -33.9% (-34.2..-33.0) | +| 12 MP city, U8 | save-first-disk | 660.8 | 660.8 | 621.6 | -5.9% (-7.5..+1.1) | +| 12 MP city, U8 | save-repeat-disk | 658.9 | 658.9 | 496.6 | -24.6% (-26.4..-18.8) | +| 12 MP city, U8 | save-edit-disk | 628.4 | 628.4 | 502.2 | -20.1% (-26.3..-17.8) | -The earlier eight flat-photo/authored-design comparisons were also close to neutral for fresh ZIP saves. All candidate bundles and directory contents match main byte for byte. Retention required a repeatable improvement of at least 3% in a complete operation, with a paired interval excluding zero and no material new regression. Kernel-only differences do not satisfy that criterion. +An independent nine-pair U16 first-save check changed its median delta from +2.0% to -5.9%, +with both intervals crossing zero; no repeatable first-save regression or speedup is established. +Initial in-memory repeated-export medians across six photo/depth cases improve 18–47%. +The independent disk runs confirm 20–34% gains for repeated and small-edit saves. +Peak RSS is close to main; the retained-payload idle-memory cost still applies. -Post-removal affected checks passed: three RGB ZIP depth round trips; directory round trips across the existing mode/depth matrix; ZIP incremental saves; directory incremental saves and garbage collection (six tests total). Strict all-target format Clippy, affected format WASM and formatting checks also passed. No unrelated full-suite or synthetic timing run was repeated. - -Harness, raw observations, exact inputs and binary hashes: `.local/perf-followup-261019/` (ignored local evidence). Previous flat-photo and authored-design results remain in `.local/perf-real-261019/`. Nothing was pushed and no PR was changed. - -## Earlier split validation - -These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - -- `cargo test -p photocraft-format --locked`: passed (147 passed, 1 ignored). -- `cargo clippy -p photocraft-format --locked --all-targets -- -D warnings`: passed. -- `cargo xtask layers`: passed. -- `cargo check -p photocraft-format --locked --target wasm32-unknown-unknown`: passed. +Every output is byte-identical to the baseline and reloads to the complete source document. +Scoped tests cover cache capacity/output, weak identities, sample depth, Save As, lifetime and +save-settings forwarding. Existing save-identity tests cover failed encoding/writing and copies. From 443e6a3fc8a1c7015b6337a52ba18177c07d4100 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 20:40:13 +0530 Subject: [PATCH 0835/1452] Refuse strict .atn export of an in-place layer duplicate Photoshop's Duplicate Layer has no in-place flag, so `layer.duplicate {"inPlace": true}` was exported as a plain Duplicate and replayed with the artboard copy beside the original. Strict export now fails on it and skipUnsupported reports it, as for a Close with a document target. Fixes #7200 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 5 +++++ crates/engine/src/atn_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..266de7faa8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1956,6 +1956,11 @@ fn duplicate_to(id: &str, p: &Value) -> Option { if id == "layer.duplicate" && jstr(p, "document") == Some("new") { return None; } + // Photoshop's Duplicate has no in-place flag: a plain one would place the artboard copy beside + // the original on playback. + if id == "layer.duplicate" && jbool(p, "inPlace") == Some(true) { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..60fe381dab 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1089,3 +1089,21 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); } } + +/// Photoshop's Duplicate Layer has no in-place flag: an in-place duplicate (the artboard copy +/// that stays on the original) must not be exported as a plain duplicate, which places the +/// copy beside the original on playback. +#[test] +fn duplicate_in_place_is_not_exported_as_plain_duplicate() { + let in_place = Action { name: "Probe".into(), steps: vec![("layer.duplicate".into(), json!({"inPlace": true}))], ..Default::default() }; + assert!(export("Audit5", &[&in_place], &[], false).is_err()); + let (_, skipped) = export("Audit5", &[&in_place], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.duplicate".into() }]); + for params in [json!({}), json!({"inPlace": false})] { + let plain = Action { name: "Plain".into(), steps: vec![("layer.duplicate".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + } +} From 24010398871440e3e4cdbf291d7c9daf33864b97 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 20:40:13 +0530 Subject: [PATCH 0836/1452] contributors: credit ksaxena149 by their GitHub profile name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 102c69e66c..a61b8ff6bc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -172,3 +172,6 @@ sync_github_name = false [people.Byxis] sync_github_name = true + +[people.ksaxena149] +sync_github_name = true From dfeba9e97497e0e2debc28f8006b0f30ed13e33d Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Mon, 19 Oct 2026 18:11:13 +0300 Subject: [PATCH 0837/1452] test(compose): compare NaN as NaN in the vector span test, not by its bits --- crates/compose/src/psblend.rs | 5 +++-- crates/compose/src/tests.rs | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index b5eeaa79d0..4c0556be22 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -238,7 +238,7 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity normal_rgb(backdrop, source, as_, ao) } -/// [`composite_normal_rgb`] over a row, bit for bit, in blocks the compiler vectorizes (see +/// [`composite_normal_rgb`] over a row, with the same bits, in blocks the compiler vectorizes (see /// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel. pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) { let n = dst.len().min(src.len()); @@ -262,7 +262,8 @@ const LANES: usize = 8; /// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels, /// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a /// select per pixel between the backdrop, zero and the mix. Same operations in the same order -/// (Rust never fuses them into multiply-adds) and the same tests, NaN included: bit-identical. +/// (Rust never fuses them into multiply-adds) and the same tests: the same bits, except that a +/// NaN's sign and payload may differ (IEEE 754 doesn't fix which operand's NaN is passed on). #[inline] #[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 4cc6e7925e..5043657c14 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -82,7 +82,7 @@ fn normal_rgb_kernel_preserves_scalar_bits() { /// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover /// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals -/// and values outside 0..1. +/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN. #[test] fn normal_rgb_span_preserves_scalar_bits() { let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30]; @@ -111,8 +111,11 @@ fn normal_rgb_span_preserves_scalar_bits() { } let mut got = backdrop; psblend::composite_normal_rgb_span(&mut got, &source, opacity); + // IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the + // vector units differ in its sign), so any NaN counts as the same NaN. + let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() }); for (i, (g, w)) in got.iter().zip(&want).enumerate() { - assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); + assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); } } } From dd87a1b149776a4c0f13b4bf0332d655e5dd05df Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:52:10 +0200 Subject: [PATCH 0838/1452] Print: Scale to Fit Media fills the driver's printable area (#7782) `layout()` fitted the image to the paper minus a fixed 18 pt margin, so a borderless page (the driver reports the whole sheet as printable) still printed with a white border, and a driver margin wider than 18 pt could clip the image. A new optional `printable` parameter (the rectangle `file.print.page` reports) is now the area Scale to Fit Media fills and centres in, with the 36 pt mark inset kept inside it and Emulsion Down fitting its mirror. On the native Windows route the engine reads it from the driver, so Print One Copy, the CLI and replayed actions match the Print dialog, which passes it for its preview. Without one (CUPS, PDF), the layout is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds.rs | 101 ++++++++++++++++++++++---- crates/engine/src/print_cmds/tests.rs | 88 ++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 4 + 3 files changed, 177 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 0a1701a166..af06fd2e95 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -475,6 +475,28 @@ fn paper(p: &Value, cmd: &str) -> Result<(f64, f64)> { Ok(if p.get("orientation").and_then(Value::as_str) == Some("landscape") { (h.max(w), w.min(h)) } else { (w, h) }) } +/// The driver's printable area (`printable`: [x, y, width, height] pt from the paper's top-left, +/// as `file.print.page` reports it), clipped to the paper, with the layout's bottom-left origin. +fn printable(p: &Value, (pw, ph): (f64, f64), cmd: &str) -> Result> { + let Some(v) = p.get("printable").filter(|v| !v.is_null()) else { return Ok(None) }; + let r: Option> = v.as_array().and_then(|a| a.iter().map(Value::as_f64).collect()); + let Some([x, y, w, h]) = r.as_deref().and_then(|r| <[f64; 4]>::try_from(r).ok()) else { + return Err(bad(cmd, "printable is [x, y, width, height] in points from the paper's top-left")); + }; + // Drivers report their paper a fraction of a point off the named size (842.29 pt for A3's + // 841.89), so clip that much; an area that doesn't fit the paper (another paper or + // orientation) is refused. 2 pt, as `print_windows` allows between driver and layout. + const SLACK: f64 = 2.0; + if !(x >= -SLACK && y >= -SLACK && x + w <= pw + SLACK && y + h <= ph + SLACK) { + return Err(bad(cmd, "the printable area doesn't fit the paper and orientation")); + } + let (x0, y0, x1, y1) = (x.max(0.0), y.max(0.0), (x + w).min(pw), (y + h).min(ph)); + if !(x1 > x0 && y1 > y0) { + return Err(bad(cmd, "the printable area is empty")); + } + Ok(Some((x0, ph - y1, x1 - x0, y1 - y0))) +} + /// Where the image lands on the paper. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PrintLayout { @@ -499,16 +521,34 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { center_crop: p.get("centerCropMarks").and_then(Value::as_bool).unwrap_or(false), registration: p.get("registrationMarks").and_then(Value::as_bool).unwrap_or(false), }; - let margin = if marks.corner_crop || marks.center_crop || marks.registration { 36.0 } else { 18.0 }; - let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); - let scale = if fit { - ((pw - 2.0 * margin) / iw).min((ph - 2.0 * margin) / ih) - } else { - p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 + let has_marks = marks.corner_crop || marks.center_crop || marks.registration; + // Scale to Fit Media fits the driver's printable area when it is known (the whole sheet for + // borderless paper); marks keep their inset inside it. Without one (CUPS, PDF), the paper + // minus a fixed margin. + let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); + let fit_box = match printable(p, (pw, ph), cmd)? { + Some((x, y, w, h)) => { + // Emulsion Down mirrors the page as it prints: fit the mirror of the printable area, + // so the printed image lands inside the real one. + let x = if emulsion_down { pw - x - w } else { x }; + let inset = if has_marks { 36.0 } else { 0.0 }; + if !(w > 2.0 * inset && h > 2.0 * inset) { + return Err(bad(cmd, "the printable area is too small for crop and registration marks")); + } + (x + inset, y + inset, w - 2.0 * inset, h - 2.0 * inset) + } + None => { + let margin = if has_marks { 36.0 } else { 18.0 }; + (margin, margin, pw - 2.0 * margin, ph - 2.0 * margin) + } }; + let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); + let scale = if fit { (fit_box.2 / iw).min(fit_box.3 / ih) } else { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; let (w, h) = (iw * scale, ih * scale); let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); - let (x, y) = if center { + let (x, y) = if center && fit { + (fit_box.0 + (fit_box.2 - w) / 2.0, fit_box.1 + (fit_box.3 - h) / 2.0) + } else if center { ((pw - w) / 2.0, (ph - h) / 2.0) } else { // Top / left in inches from the paper's top-left corner (Photoshop's Position fields). @@ -516,7 +556,6 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { let left = p.get("left").and_then(Value::as_f64).unwrap_or(0.0) * 72.0; (left, ph - top - h) }; - let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); Ok(PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down }) } @@ -568,7 +607,38 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(result) } +/// True when a print may go to a Windows printer through the driver rather than a PDF spooler. +fn native_route(p: &Value, spooler: Option<&Spooler>) -> bool { + // A named print server always takes the IPP route and a preferred app spooler takes every + // job (the route order in `spool_job`), on Windows too: neither may enter the native path, + // which would print on the local default printer instead. + let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); + let output = p.get("output").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let send = p.get("send").and_then(Value::as_bool).unwrap_or(!output); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + cfg!(windows) && !skip_native && send && !dry && !output +} + +/// `p` with the driver's printable area, read from the printer itself, so Print One Copy, the +/// CLI, control and replayed actions fit the same area as the Print dialog does (#7782). +fn with_driver_printable(p: &Value) -> Option { + let printer = printer_name(p).ok()?; + let pg = craft_print::rgb::page(&printer, printer_settings(p).ok()?.as_ref()).ok()?; + // Only for the paper the driver is set to: a print laid out on another paper keeps the + // fixed margin (and `print_windows` refuses it), as before. + let (pw, ph) = paper(p, "file.print").ok()?; + if (pg.paper.0 - pw).abs() > 2.0 || (pg.paper.1 - ph).abs() > 2.0 { + return None; + } + let mut p = p.clone(); + p.as_object_mut()?.insert("printable".into(), json!([pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3])); + Some(p) +} + fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { + let driver = native_route(p, spooler).then(|| with_driver_printable(p)).flatten(); + let p = driver.as_ref().unwrap_or(p); let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; let description = p @@ -586,12 +656,8 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); - // A named print server always takes the IPP route and a preferred app spooler takes every - // job (the route order in `spool_job`), on Windows too: neither may enter the native path - // below, which would print on the local default printer instead. - let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); - let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); - if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + let native = native_route(p, spooler); + if native && native_rgb_compatible(&page, handling) { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ @@ -599,7 +665,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } - if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + if native && printer_settings(p)?.is_some() { return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); @@ -666,6 +732,9 @@ fn remember_print(s: &mut Session, p: &Value) { o.remove("dryRun"); // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); + // The driver's printable area belongs to the printer and paper chosen now: the dialog + // reads it again, and a later print to PDF or another printer must not inherit it. + o.remove("printable"); } let kept = remembered.clone(); s.prefs.edit(|prefs| { @@ -995,7 +1064,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"printable":[x,y,w,h] pt? (the driver's printable area from the paper's top-left, as file.print.page reports it; Scale to Fit Media fits it, else the paper minus 18 pt),"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c54dddf6f8..0a41a2247a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -772,3 +772,91 @@ mod cups { assert_eq!(jobs, 2, "both prints reached the fake CUPS"); } } + +/// A 500 × 1000 pt document (72 ppi) on A3 (841.89 × 1190.55 pt): its height sets the fit. +fn a3_layout(extra: Value) -> Result { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + let mut p = json!({"paper": "a3", "scaleToFit": true}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + layout(&s.active().unwrap().doc, &p, "file.print") +} + +#[test] +fn scale_to_fit_fills_a_borderless_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // Without a printable area, the fixed 18 pt margin stays (CUPS, PDF). + let l = a3_layout(json!({})).unwrap(); + assert!((l.scale - (ph - 36.0) / 1000.0).abs() < 1e-9, "{l:?}"); + // Borderless: the driver reports the whole sheet (#7782: a Canon PRO-1000 reports its A3 as + // 842.29 × 1191.14 pt), and the image reaches the paper's edges. + let l = a3_layout(json!({"printable": [0.0, 0.0, 842.29, 1191.14]})).unwrap(); + assert!((l.scale - ph / 1000.0).abs() < 1e-9, "{l:?}"); + let (x, y, w, h) = l.rect; + assert!(y.abs() < 1e-9 && (h - ph).abs() < 1e-9 && (x - (pw - w) / 2.0).abs() < 1e-9, "{l:?}"); +} + +#[test] +fn scale_to_fit_stays_inside_an_uneven_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // 17 pt left and right, 14 pt at the top, 48 pt at the bottom (from the top-left, as + // `file.print.page` reports it). The layout's rect has its origin at the bottom-left. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0]})).unwrap(); + assert!((l.scale - (ph - 62.0) / 1000.0).abs() < 1e-9, "{l:?}"); + let (x, y, w, h) = l.rect; + assert!((y - 48.0).abs() < 1e-9 && (y + h - (ph - 14.0)).abs() < 1e-9, "fills the printable height: {l:?}"); + assert!((x - (pw - w) / 2.0).abs() < 1e-9, "centred between equal side margins: {l:?}"); + // Crop and registration marks keep their 36 pt inset, inside the printable area. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0], "cornerCropMarks": true})).unwrap(); + assert!((l.scale - (ph - 62.0 - 72.0) / 1000.0).abs() < 1e-9, "{l:?}"); + assert!((l.rect.1 - 84.0).abs() < 1e-9, "{l:?}"); + // Scale to fit off: the printable area changes nothing. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0], "scaleToFit": false})).unwrap(); + assert_eq!(l, a3_layout(json!({"scaleToFit": false})).unwrap()); +} + +#[test] +fn a_bad_printable_area_is_refused() { + let portrait = json!([0.0, 0.0, 842.29, 1191.14]); + for bad in [ + json!([0, 0, 100]), + json!([0, 0, 0, 100]), + json!([0, 0, 100, -5]), + json!("whole"), + json!([2000, 0, 100, 100]), + json!([0, "oops", 0, 842.29, 1191.14]), + json!([0, 0, 842.29, null]), + ] { + assert!(a3_layout(json!({ "printable": bad })).is_err(), "{bad}"); + } + // A portrait sheet's area on a landscape layout is another paper, not a rounding error. + assert!(a3_layout(json!({"printable": portrait, "orientation": "landscape"})).is_err()); +} + +#[test] +fn emulsion_down_fits_the_mirrored_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // 10 pt on the left, 40 pt on the right of the sheet as the driver sees it. The page is + // mirrored as it prints, so the image box (before mirroring) must fit the mirror image. + // A wide image, so the printable width sets the fit. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 500, "resolution": 72})).unwrap(); + let p = json!({"paper": "a3", "scaleToFit": true, "printable": [10.0, 0.0, pw - 50.0, ph], "emulsionDown": true}); + let l = layout(&s.active().unwrap().doc, &p, "file.print").unwrap(); + let (x, _, w, _) = l.rect; + let printed = (pw - x - w, pw - x); + assert!(printed.0 >= 10.0 - 1e-9 && printed.1 <= pw - 40.0 + 1e-9, "printed at {printed:?}: {l:?}"); +} + +#[test] +fn a_print_uses_the_printable_area_but_does_not_remember_it() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0.0, 0.0, 842.29, 1191.14], "dryRun": true})).unwrap(); + assert!((r["scale"].as_f64().unwrap() - 119.055).abs() < 1e-6, "{r}"); + // The next print (Print One Copy, or the dialog on another printer or to PDF) reads its own. + let last = last_print(&s).unwrap(); + assert!(last.get("printable").is_none() && last["scaleToFit"] == true, "{last}"); +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index a84bfe7779..cd5d8327ac 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -778,10 +778,12 @@ fn driver_page(f: &mut Map) { match craft_print::rgb::page(&printer, settings.as_ref()) { Ok(pg) => { f.insert("paper".into(), json!([pg.paper.0, pg.paper.1])); + f.insert("printable".into(), json!([pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3])); f.insert("orientation".into(), json!("portrait")); f.remove("__pageError"); } Err(e) => { + f.remove("printable"); f.insert("__pageError".into(), json!(e.to_string())); } } @@ -882,7 +884,9 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Date: Mon, 19 Oct 2026 17:19:18 +0200 Subject: [PATCH 0839/1452] fix(raster): support sample counts beyond stack capacity --- crates/raster/src/lib.rs | 61 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 694cdb5751..2e3dc53811 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -117,6 +117,16 @@ pub struct Surface { tiles: BTreeMap>, } +/// Use stack storage when it fits, otherwise reuse a dynamically sized sample buffer. +fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + if let Some(samples) = stack.get_mut(..channels) { + samples + } else { + heap.resize(channels, 0.0); + heap + } +} + /// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned /// once, and one tile kept per value so the targets share it. #[derive(Default)] @@ -239,10 +249,10 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } - /// Decode the default pixel into fixed storage without allocating. - fn default_samples(&self) -> [f32; 8] { - let mut px = [0.0; 8]; - for (i, v) in px.iter_mut().enumerate().take(self.channels()) { + /// Decode the default pixel, using dynamic storage only when the stack buffer is too small. + fn default_samples<'a>(&self, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + let px = sample_buffer(self.channels(), stack, heap); + for (i, v) in px.iter_mut().enumerate() { *v = read_sample(&self.default_pixel, self.format.sample, i); } px @@ -427,8 +437,9 @@ impl Surface { fn read_samples_into(&self, r: Rect, out: &mut [f32]) { let n = self.channels(); let w = r.width() as usize; - let dp = self.default_samples(); - let dp = &dp[..n]; + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = self.default_samples(&mut stack, &mut heap); let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); @@ -467,7 +478,10 @@ impl Surface { debug_assert_eq!(out.len(), w * r.height() as usize); let fmt = self.format; let n = fmt.channels(); - let dp = to_rgba(&fmt, &self.default_samples()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let px = self.default_samples(&mut stack, &mut heap); + let dp = to_rgba(&fmt, px); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -511,12 +525,11 @@ impl Surface { } } _ => { - let mut px = [0.0f32; 8]; for (i, d) in dst.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate().take(n) { + for (c, v) in px.iter_mut().enumerate() { *v = read_sample(&t.data, fmt.sample, base + i * n + c); } - *d = to_rgba(&fmt, &px[..n]); + *d = to_rgba(&fmt, px); } } } @@ -532,7 +545,9 @@ impl Surface { let fmt = self.format; let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - let dp = to_rgba(&fmt, &self.default_samples()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; let mut row = Vec::new(); for tc in r.tiles() { @@ -1293,6 +1308,24 @@ mod tests { } } + /// Sample counts above the stack capacity use writable storage without truncating channels. + #[test] + fn sample_buffer_keeps_stack_fast_path_and_supports_dynamic_channels() { + for channels in [1, 8, 9, 32] { + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let samples = sample_buffer(channels, &mut stack, &mut heap); + assert_eq!(samples.len(), channels); + samples.fill(0.25); + if channels <= stack.len() { + assert!(heap.is_empty()); + assert_eq!(&stack[..channels], vec![0.25; channels]); + } else { + assert_eq!(heap, vec![0.25; channels]); + } + } + } + /// Compare sparse reads and converted accessors with the previous allocating path. #[test] fn zero_alloc_accessors_match_read_region() { @@ -1310,6 +1343,12 @@ mod tests { .flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default))) { let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + for capacity in [0, stack.len()] { + let decoded = s.default_samples(&mut stack[..capacity], &mut heap); + assert!(decoded.iter().map(|v| v.to_bits()).eq(s.default_pixel().into_iter().map(f32::to_bits))); + } let r = Rect::new(-300, -20, 300, 40); for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() { let px: Vec = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect(); From 716d1e24c977ac23120654cc283a127c3d2bd9df Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Mon, 19 Oct 2026 17:24:19 +0200 Subject: [PATCH 0840/1452] fix(io): keep variable noise GdFl fills as fill layers constant_rgb_noise_color's None propagated out of fill_from_desc for every noise gradient that is not provably constant - Photoshop's default Noise gradient included - so the layer imported as pixels and the export dropped its GdFl (RASTER_STALE_KEYS): an open-and-save data loss. The exact colour now applies only when provable; everything else keeps the placeholder ramp, the fill_cache pixels and the raw GdFl block, which the re-save writes back byte for byte. Round-trip test included (fails on the old head: the layer came back as pixels without a GdFl). --- crates/io/src/blocks.rs | 27 ++++++++--- crates/io/tests/psd_noise_fill_roundtrip.rs | 54 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 crates/io/tests/psd_noise_fill_roundtrip.rs diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index f356c13204..4cad23ce44 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -446,12 +446,17 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { opacity_stops.clear(); } if stops.is_empty() { - if let Some(grad) = gradient.filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) { - let color = constant_rgb_noise_color(grad)?; - stops = vec![(0.0, color), (1.0, color)]; - } else { - stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; - } + // A provably constant-RGB noise gradient renders one exact colour. Any other + // noise gradient (variable ranges, transparency, other models) has no stops to + // read: keep the placeholder ramp rather than failing the whole fill — the + // layer stays a fill layer, Photoshop's own pixels ride along as `fill_cache`, + // and the raw `GdFl` block survives the round trip byte for byte. Failing + // here would import the layer as pixels and drop `GdFl` on save (#6272). + stops = gradient + .filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) + .and_then(constant_rgb_noise_color) + .map(|color| vec![(0.0, color), (1.0, color)]) + .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); } let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0); let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc"))); @@ -811,9 +816,15 @@ mod tests { } #[test] - fn variable_or_transparent_noise_fill_is_not_fabricated_as_a_black_to_white_ramp() { + fn variable_or_transparent_noise_stays_a_fill_with_the_placeholder_ramp() { + use photocraft_doc::Fill; + // Photoshop's default Noise gradient (min 0, max 100) and a transparent variant: no + // exact colour is provable, but the fill must parse — the layer keeps its type, its + // stored pixels (`fill_cache`) and its raw `GdFl` block. Only the uncached preview + // shows the placeholder ramp. for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] { - assert!(fill_from_desc(b"GdFl", &descriptor).is_none()); + let Some(Fill::Gradient { stops, .. }) = fill_from_desc(b"GdFl", &descriptor) else { panic!("variable noise gradient must stay a fill layer") }; + assert_eq!(stops, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); } } diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/psd_noise_fill_roundtrip.rs new file mode 100644 index 0000000000..e8fb38b88d --- /dev/null +++ b/crates/io/tests/psd_noise_fill_roundtrip.rs @@ -0,0 +1,54 @@ +//! #6272: a Photoshop noise `GdFl` gradient that is not provably constant-RGB still opens as +//! a fill layer — the placeholder ramp stands in where the model has no stops, Photoshop's +//! own pixels ride along as the fill cache — and its raw `GdFl` block survives a re-save byte +//! for byte. Before the fix, `fill_from_desc` returned `None` for those gradients: the layer +//! imported as pixels and the export dropped the `GdFl` (`RASTER_STALE_KEYS`), so opening and +//! re-saving a file silently turned the fill layer into a plain pixel layer. + +use photocraft_doc::{Fill, LayerContent}; +use photocraft_io::*; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::testgen; +use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; + +/// Photoshop's default Noise gradient (RGB, min 0/0/0, max 100/100/100) as `GdFl` block data. +fn default_noise_gdfl() -> Vec { + let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect()); + let gradient = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") }) + .with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") }) + .with("ShTr", Value::Boolean(false)) + .with("VctC", Value::Boolean(true)) + .with("RndS", Value::Integer(9052369)) + .with("Smth", Value::Integer(2048)) + .with("Mnm ", values([0, 0, 0, 0])) + .with("Mxm ", values([100, 100, 100, 100])); + let d = Descriptor::new("null") + .with("Grad", Value::Descriptor(gradient)) + .with("Angl", Value::UnitFloat { unit: *b"#Ang", value: 0.0 }) + .with("Type", Value::Enumerated { type_id: Id::new("Type"), value: Id::new("CstS") }); + VersionedDescriptor::new(d).to_bytes() +} + +#[test] +fn variable_noise_gradient_stays_a_fill_layer_and_keeps_its_gdfl() { + let _ = UnicodeString::new_nul("x"); + let mut f = testgen::small(Version::Psd, Compression::Raw); + let gdfl = default_noise_gdfl(); + f.layers_mut().push(LayerRecord { + name: b"noise fill".to_vec(), + blocks: vec![TaggedBlock::unicode_name("noise fill"), TaggedBlock::new(*b"GdFl", gdfl.clone())], + ..Default::default() + }); + for b in f.layers().last().unwrap().blocks.iter() { + b.check_structure().unwrap(); + } + let bytes = f.to_bytes().unwrap(); + let imp = import("in.psd", &bytes).unwrap(); + let l = imp.document.layers.iter().find(|l| l.name == "noise fill").unwrap(); + assert!(matches!(l.content, LayerContent::Fill(Fill::Gradient { .. })), "a variable noise GdFl opens as a gradient fill layer, not pixels"); + let out = export(&imp.document, "out.psd", &ExportOptions::default()).unwrap().bytes; + let back = PsdFile::from_bytes(&out).unwrap(); + let rec = back.layers().iter().find(|l| l.block(b"GdFl").is_some()).expect("the re-saved layer carries its GdFl"); + assert_eq!(rec.block(b"GdFl").unwrap().data, gdfl, "the raw GdFl survives the round trip byte for byte"); +} From db28e5da0306995a04dc5257d125513cbe25dfd2 Mon Sep 17 00:00:00 2001 From: Cl4VD3 <344569071+Cl4VD3@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:31:15 +0200 Subject: [PATCH 0841/1452] Brush stepping: place groups by every preset before skipping unselectable names Review (CodeRabbit): skipping a case-insensitive duplicate before grouping dropped its group's first appearance, so a group could step out of the panel's order. Groups and folders are now ordered from all presets, and unselectable presets are removed from the result afterwards. Test added. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_key_cmds.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index 351f4df65f..eada73490b 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -44,7 +44,8 @@ pub const UNGROUPED: &str = "My Brushes"; /// Brush preset indices in the Brushes panel's order: groups in order of first appearance (no /// group is [`UNGROUPED`]), and in each group its folders where their first preset is, depth first /// (as `brushes_tab` draws them). A preset its name can't select (an earlier one has the same name -/// up to case, and `tools.setBrush` picks that one) is left out, so stepping never gets stuck. +/// up to case, and `tools.setBrush` picks that one) is left out afterwards, so stepping never +/// gets stuck. fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { fn path(p: &photocraft_paint::BrushPreset) -> &[String] { p.folder.get(..p.folder.len().min(crate::brush_preset_cmds::MAX_FOLDER_DEPTH)).unwrap_or_default() @@ -66,10 +67,6 @@ fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { } let mut groups: Vec<(&str, Vec)> = Vec::new(); for (i, p) in presets.iter().enumerate() { - let selectable = photocraft_paint::presets::find(presets, &p.name).is_some_and(|q| std::ptr::eq(q, p)); - if !selectable { - continue; - } let group = if p.group.is_empty() { UNGROUPED } else { p.group.as_str() }; match groups.iter_mut().find(|(g, _)| *g == group) { Some((_, v)) => v.push(i), @@ -80,6 +77,9 @@ fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { for (_, items) in &groups { flatten(presets, items, 0, &mut out); } + // Groups and folders are placed by every preset, as the panel places them; only then are the + // presets their names can't select left out. + out.retain(|&i| presets.get(i).is_some_and(|p| photocraft_paint::presets::find(presets, &p.name).is_some_and(|q| std::ptr::eq(q, p)))); out } @@ -236,6 +236,11 @@ mod tests { let presets = vec![preset("Foo", "", &[], 1.0), preset("foo", "", &[], 1.0), preset("Bar", "", &[], 1.0)]; let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); assert_eq!(names, ["Foo", "Bar"]); + // A skipped duplicate still places its group where the panel does: G2 (from `foo`) comes + // before G3, so the panel's Foo, foo, Baz, Bar steps as Foo, Baz, Bar. + let presets = vec![preset("Foo", "G1", &[], 1.0), preset("foo", "G2", &[], 1.0), preset("Bar", "G3", &[], 1.0), preset("Baz", "G2", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["Foo", "Baz", "Bar"]); } #[test] From 1489dafb4a5b64c56eaf753ceaeea972c897e3e1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 19 Oct 2026 17:40:09 +0200 Subject: [PATCH 0842/1452] fix(ui): guides draw over the Puppet Warp, Perspective Warp and Vanishing Point previews (#7781) Free Transform's preview already goes under the guides (#5086), but the on-canvas distort modes still painted their warped pixels together with their pins and planes in distort_ui::draw_overlay, after the grid and guides, so every guide vanished across the layer being warped. Split each mode's drawing: draw_content paints the warped pixels (Puppet Warp, Perspective Warp) or the paste (Vanishing Point) right after the document, next to transform_tool::draw_content and under the grid and guides; draw_overlay keeps the mesh lines, pins, planes and nodes over them, as in Photoshop. Test: guides_draw_over_the_puppet_perspective_and_vanishing_point_previews checks the painted order preview < guide (< mesh/plane) for all three modes; it failed for each of them before the fix. --- crates/ui-egui/src/canvas.rs | 81 ++++++++++++++++++++++++++++ crates/ui-egui/src/distort_ui.rs | 17 +++++- crates/ui-egui/src/perspective_ui.rs | 9 +++- crates/ui-egui/src/puppet_ui.rs | 8 ++- crates/ui-egui/src/vp_ui.rs | 16 ++++-- 5 files changed, 123 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..cc50aba6ab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3907,6 +3907,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The transform preview is image content: it goes under the grid and guides, which // stay visible across a layer being transformed (#5086); the box is drawn later. crate::transform_tool::draw_content(app, &painter, &xf); + // So are Puppet Warp's, Perspective Warp's and Vanishing Point's warped pixels (#7781). + crate::distort_ui::draw_content(app, &painter, &xf); if app.ui.extras.grid && app.ui.view.extras { crate::rulers::draw_grid(app, &painter, &xf, &doc); } @@ -8088,6 +8090,85 @@ mod tests { assert!(guide < frame, "the box ({frame}) draws over the guide ({guide})"); } + #[test] + fn guides_draw_over_the_puppet_perspective_and_vanishing_point_previews() { + // #7781: like Free Transform's (#5086), the warped pixels of Puppet Warp and Perspective + // Warp and Vanishing Point's paste are image content. They were drawn with their pins and + // planes, after the guides, hiding every guide across them; the guides now go over them, + // the mesh lines, pins and planes over the guides, as in Photoshop. + for command in ["edit.puppetWarp", "edit.perspectiveWarp", photocraft_engine::vp_cmds::VP] { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + if command == photocraft_engine::vp_cmds::VP { + app.run("edit.copy", json!({})).unwrap(); + } + app.run("select.deselect", json!({})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 150})).unwrap(); + app.ui.extras.guides = true; + app.ui.extras.rulers = false; + app.sync_views(); + crate::distort_ui::menu(&mut app, &ctx, command, &json!({})).expect("an on-canvas mode").expect("it starts"); + if command == photocraft_engine::vp_cmds::VP { + // A plane over the document; the clipboard pastes onto it. + for (x, y) in [(20.0, 280.0), (380.0, 280.0), (300.0, 40.0), (100.0, 40.0)] { + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x, y }, egui::Modifiers::NONE); + } + } + let mut view = app.ui.views[0].clone(); + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let input = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + canvas_view(&mut app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + // At 100 % centred on the document, screen and document coordinates coincide. + let layer = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 160.0)); + // Puppet Warp's mesh reaches its Expansion (2 px) past the pixels. + let near = |r: Rect| (r.min - layer.min).length() < 4.0 && (r.max - layer.max).length() < 4.0; + let shapes: Vec<&egui::Shape> = out.shapes.iter().map(|s| &s.shape).collect(); + // The warped pixels (or the paste): a textured mesh over the layer (or on the plane, + // well inside the document, unlike the checkerboard and document image). + let preview = shapes.iter().position(|s| match s { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => { + let r = m.calc_bounds(); + if command == photocraft_engine::vp_cmds::VP { + r.width() > 10.0 && r.width() < 300.0 && r.min.x < 150.0 && r.max.x > 150.0 + } else { + near(r) + } + } + _ => false, + }); + // The guide: a vertical line at x = 150 across the whole view. + let is_guide = |s: &egui::Shape| matches!(s, egui::Shape::LineSegment { points, .. } if points.iter().all(|p| (p.x - 150.0).abs() < 0.01) && (points[1].y - points[0].y).abs() > 200.0); + let guide = shapes.iter().position(|s| is_guide(s)); + // Over the guide: Puppet Warp's mesh lines, the Vanishing Point plane's outline. + let overlay = shapes.iter().position(|s| match s { + egui::Shape::LineSegment { points, .. } => !is_guide(s) && points.iter().all(|p| layer.expand(3.0).contains(*p)), + egui::Shape::Path(p) => p.closed && p.points.len() == 4, + _ => false, + }); + let (Some(preview), Some(guide)) = (preview, guide) else { + panic!("{command}: all painted: preview {preview:?}, guide {guide:?}"); + }; + assert!(preview < guide, "{command}: the guide ({guide}) draws over the warped pixels ({preview})"); + if command != "edit.perspectiveWarp" { + // Perspective Warp has no plane yet: nothing of its own over the guide. + let Some(overlay) = overlay else { panic!("{command}: its mesh or plane is drawn") }; + assert!(guide < overlay, "{command}: the mesh or plane ({overlay}) draws over the guide ({guide})"); + } + } + } + #[test] fn brush_drags_paint_no_stand_in_shape_over_the_canvas() { // #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 4987d8cc4d..8f6412f489 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -226,7 +226,22 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } -/// Canvas overlay for the on-canvas modes. +/// The warped (or pasted) pixels of the open on-canvas mode: image content, which the canvas +/// draws right after the document and under the grid and guides (#7781), as Free Transform's +/// preview is (#5086). Its pins, planes and meshes are [`draw_overlay`], drawn over the guides. +pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + if let Some(p) = &app.distort.puppet { + crate::puppet_ui::draw_content(p, painter, xf); + } + if let Some(p) = &app.distort.perspective { + crate::perspective_ui::draw_content(p, painter, xf); + } + if let Some(v) = &app.distort.vanishing { + crate::vp_ui::draw_content(v, painter, xf); + } +} + +/// Canvas overlay for the on-canvas modes: pins, planes and meshes over the guides. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { if let Some(p) = &app.distort.puppet { crate::puppet_ui::draw(p, painter, xf); diff --git a/crates/ui-egui/src/perspective_ui.rs b/crates/ui-egui/src/perspective_ui.rs index 8afe1113dc..e39a791d4f 100644 --- a/crates/ui-egui/src/perspective_ui.rs +++ b/crates/ui-egui/src/perspective_ui.rs @@ -255,7 +255,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { } } -pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { +/// The warped pixels: image content, drawn under the grid and guides like the document so a +/// guide stays visible across the layer being warped (#7781). The planes and pins are [`draw`]. +pub fn draw_content(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); let mut mesh = egui::Mesh::with_texture(s.texture.id()); let tint = Color32::from_white_alpha((s.opacity.clamp(0.0, 1.0) * 255.0) as u8); @@ -273,6 +275,11 @@ pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { } } painter.add(mesh); +} + +/// The planes, their grids and pins over the warped pixels ([`draw_content`]) and the guides. +pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); let accent = crate::theme::Tokens::get(painter.ctx()).accent; let warp = s.mode == PerspMode::Warp; for p in &s.planes { diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index e233396cfd..3508b46b25 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -318,7 +318,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } } -pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { +/// The warped pixels: image content, drawn under the grid and guides like the document so a +/// guide stays visible across the layer being warped (#7781). The mesh lines and pins are [`draw`]. +pub fn draw_content(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { let b = s.bounds; let (w, h) = (f64::from(b.width()), f64::from(b.height())); let mut mesh = egui::Mesh::with_texture(s.texture.id()); @@ -332,6 +334,10 @@ pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { mesh.add_triangle(a as u32, bb as u32, c as u32); } painter.add(mesh); +} + +/// The mesh lines and pins over the warped pixels ([`draw_content`]) and the guides. +pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { if s.show_mesh { let line = Stroke::new(0.6, Color32::from_rgba_unmultiplied(40, 40, 40, 150)); let mut seen = std::collections::HashSet::new(); diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index bd0353d678..3034c1c5f2 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -438,18 +438,24 @@ pub fn escape(app: &mut PhotocraftApp) { } } -pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { +/// The paste on its plane: image content, drawn under the grid and guides like the document so a +/// guide stays visible across it (#7781). The planes and their grids are [`draw`]. +pub fn draw_content(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); - let tokens = crate::theme::Tokens::get(painter.ctx()); - let center = s.center(); - // The paste first, under the grids. if let Some(i) = s.paste_plane() - && s.planes.get(i).is_some_and(|p| p.check(center) != PlaneCheck::Invalid) + && s.planes.get(i).is_some_and(|p| p.check(s.center()) != PlaneCheck::Invalid) && let (Some((h, lu, lv)), Some(c)) = (s.frame(i), s.clip.as_ref()) && let Some(ext) = s.paste_extent(lu, lv) { draw_paste(painter, &scr, &h, s.paste_at, ext, &c.texture); } +} + +/// The planes, their grids and nodes over the paste ([`draw_content`]) and the guides. +pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let tokens = crate::theme::Tokens::get(painter.ctx()); + let center = s.center(); for (i, p) in s.planes.iter().enumerate() { let check = p.check(center); let colour = match check { From 362bdf135e044c80acac0485291cb33013ea4fdf Mon Sep 17 00:00:00 2001 From: Hodd Date: Mon, 19 Oct 2026 17:47:09 +0200 Subject: [PATCH 0843/1452] Guides: show the X or Y position beside the pointer while dragging (#7680) --- crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/rulers.rs | 102 +++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..5f378a56d3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3913,6 +3913,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.ui.view.shows(app.ui.view.show.canvas_guides) || crate::guide_layout_ui::preview(app, &doc).is_some() { crate::rulers::draw_guides(app, &painter, &xf, &doc); } + if app.session.active_index() == Some(idx) { + crate::rulers::draw_guide_drag_readout(app, &ctx); + } // The pending crop frame shows on its own document only, not on the other tiles (#1918). let foreign_crop = if app.session.active_index() == Some(idx) { None } else { app.ui.crop_rect.take() }; draw_drag_preview(app, &painter, &xf); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 6caa6f00a9..3c5e33c290 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -213,6 +213,30 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, } } +/// The position readout of a guide being dragged: `X:` for a vertical guide, `Y:` for a horizontal +/// one, measured from the ruler zero point in the ruler unit. Pixels keep one decimal, as +/// Photoshop's "X: 103.2 px". +pub fn guide_drag_readout(app: &PhotocraftApp, d: GuideDrag) -> Option<(&'static str, String)> { + let doc = &app.session.active()?.doc; + let ur = &app.session.prefs().units_and_rulers; + let axis = usize::from(!d.vertical); + let px = d.pos - doc.ruler_origin.get(axis).copied().unwrap_or(0.0); + let extent = if d.vertical { doc.size.width } else { doc.size.height } as f64; + let value = match ur.rulers { + Unit::Pixels => photocraft_engine::prefs::fmt_decimals(px, 1), + _ => ur.format(px, f64::from(doc.resolution_dpi), extent), + }; + Some((if d.vertical { "X:" } else { "Y:" }, format!("{value} {}", ur.rulers.suffix()))) +} + +/// Draw [`guide_drag_readout`] beside the pointer while a guide is dragged. +pub fn draw_guide_drag_readout(app: &PhotocraftApp, ctx: &egui::Context) { + let (Some(d), Some(at)) = (app.guide_drag, ctx.pointer_latest_pos()) else { return }; + if let Some((label, value)) = guide_drag_readout(app, d) { + crate::canvas::draw_value_readout(app, ctx, "guide-drag-readout", at, [label], [value]); + } +} + /// Existing guide under a document point (within 4 screen px). pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { // Hidden canvas guides are not interactive: the Move tool must reach the layer below. @@ -737,6 +761,84 @@ mod tests { assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); } + /// A dragged guide shows its position beside the pointer: X for a vertical guide, Y for a + /// horizontal one, from the zero point, in the ruler unit (pixels to one decimal). + #[test] + fn guide_drag_shows_its_position_beside_the_pointer() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let v = GuideDrag { vertical: true, index: Some(0), pos: 103.24, press: 50.0 }; + assert_eq!(guide_drag_readout(&app, v), Some(("X:", "103.2 px".to_owned()))); + let h = GuideDrag { vertical: false, index: None, pos: 40.0, press: 40.0 }; + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "40 px".to_owned()))); + app.run("view.setRulerOrigin", json!({"x": 3.0, "y": 10.0})).unwrap(); + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "30 px".to_owned())), "from the zero point"); + app.run("view.resetRulerOrigin", json!({})).unwrap(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + assert_eq!(guide_drag_readout(&app, GuideDrag { vertical: true, index: None, pos: 103.0, press: 0.0 }).map(|r| r.0), Some("X:")); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "pixels"})).unwrap(); + + app.guide_drag = Some(v); + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(|ui, app| draw_guide_drag_readout(app, ui.ctx()), app); + h.event(egui::Event::PointerMoved(pos2(50.0, 50.0))); + h.run_steps(2); + assert!(h.query_by_label("X:").is_some() && h.query_by_label("103.2 px").is_some()); + h.state_mut().guide_drag = None; + h.run_steps(2); + assert!(h.query_by_label("X:").is_none(), "no readout once the drag ends"); + } + + /// Moving a horizontal guide with the Move tool on the real canvas: `Y:` follows the pointer. + #[test] + fn moving_a_guide_on_the_canvas_shows_its_y() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + app.ui.tool = crate::state::Tool::Move; + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (1.0, [200.0, 150.0], false); + h.run_steps(2); + let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let rect = content_rect(app, app.last_canvas_rect); + ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 }.to_screen(x, y) + }; + let press = at(&h, 200.0, 100.0); + h.event(egui::Event::PointerMoved(press)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for y in [110.0, 125.0, 140.0] { + h.event(egui::Event::PointerMoved(at(&h, 210.0, y))); + h.run_steps(2); + } + assert!(h.state().guide_drag.is_some_and(|d| !d.vertical), "a horizontal guide is being dragged"); + assert!(h.query_by_label("Y:").is_some() && h.query_by_label("140 px").is_some(), "Y readout at the guide"); + assert!(h.query_by_label("X:").is_none()); + let up = at(&h, 210.0, 140.0); + h.event(egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.guides.horizontal, vec![140.0]); + assert!(h.query_by_label("Y:").is_none(), "gone after the drop"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..d68aec572f 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-15 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (a dragged guide shows its X or Y beside the pointer, #7680; 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) · **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, @@ -156,6 +156,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Smart guide distance and spacing labels | missing | | | Ruler units px/in/cm/mm/pt/pica/% | done | | | ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) | +| A dragged guide shows its position beside the pointer (X for vertical, Y for horizontal, in the ruler unit) | done | `rulers.rs` `guide_drag_readout` (#7680) | | Status bar follows the ruler unit | partial | #2602 | | Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | | Guides stored as f64 | missing | `Vec` (UI-217-20) | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From aa5497958322dd70194de092823f7c017c2a4e41 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Mon, 19 Oct 2026 21:07:06 +0530 Subject: [PATCH 0844/1452] Record a duplicate's inPlace only when a copy is an artboard Alt-drag, Cmd+Opt-drag and Free Transform on a copy duplicate with `inPlace: true` for every layer, but the flag only changes anything for an artboard. With in-place duplicates now refused on strict .atn export, a recorded copy of a plain layer could no longer be exported, although a plain Duplicate replays it exactly. Recording now drops `inPlace` when no copy is an artboard, as `layer.select` already rewrites its recorded params. Refs #7200 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 37 ++++++++++++++++++++++++++++++ crates/engine/src/artboard_cmds.rs | 14 +++++++++++ crates/engine/src/jobs.rs | 7 ++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..59b52fb404 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -1025,6 +1025,43 @@ mod tests { } } + /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain + /// layer's recorded copy must still export to .atn, while an artboard's must be refused rather + /// than exported as a copy beside the original. + #[test] + fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Layer copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("actions.record", json!({"name": "Board copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[0]], &[], false).is_ok()); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).is_err()); + // Several selected layers are copied together: one artboard among them keeps `inPlace`. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let photo = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Layers copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both layers were copied"); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Mixed copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + } + #[test] fn named_selection_preserves_modes_and_duplicate_occurrences() { let mut s = Session::new(); diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 4bc4a829b2..cf17acb99f 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -183,6 +183,20 @@ pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<()> { Ok(()) } +/// Record a Duplicate's `inPlace` only when a copy (the selection after it) is an artboard: for +/// any other layer it changes nothing, and Photoshop's Duplicate can't express it, so a recorded +/// Alt-drag copy of a plain layer must stay exportable to .atn (#7200). +pub(crate) fn recorded_duplicate(s: &Session, mut p: Value) -> Value { + let Some(st) = s.active() else { return p }; + if st.selected_layers().into_iter().any(|id| st.doc.layer(id).is_some_and(|l| l.artboard().is_some())) { + return p; + } + if let Some(fields) = p.as_object_mut() { + fields.remove("inPlace"); + } + p +} + /// Grow the canvas so every artboard fits. Photoshop's artboard documents grow in every /// direction: a board left of or above the origin shifts the whole document (boards, their /// contents, guides, paths…) right/down by its overhang, then the canvas grows to show it. An diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b30e56da3a..504fe3a015 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -799,8 +799,11 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = match id { + "layer.select" if self.actions.recording.is_some() => crate::layer_multi_cmds::recorded_selection(self, params), + "layer.duplicate" if self.actions.recording.is_some() => crate::artboard_cmds::recorded_duplicate(self, params), + _ => params, + }; self.journal.push((id.to_string(), params)); } } From 0e67ff8e4f7e92723945d28de42738f70a813ce1 Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:22:56 +0200 Subject: [PATCH 0845/1452] Print: marks need room in the printable area only when fitting (#7782) Also test a rejected printable area through `file.print`: the command fails and leaves the document and the remembered print settings as they were. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds.rs | 5 +++-- crates/engine/src/print_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index af06fd2e95..019dd7330a 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -526,13 +526,15 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { // borderless paper); marks keep their inset inside it. Without one (CUPS, PDF), the paper // minus a fixed margin. let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); + let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); let fit_box = match printable(p, (pw, ph), cmd)? { Some((x, y, w, h)) => { // Emulsion Down mirrors the page as it prints: fit the mirror of the printable area, // so the printed image lands inside the real one. let x = if emulsion_down { pw - x - w } else { x }; let inset = if has_marks { 36.0 } else { 0.0 }; - if !(w > 2.0 * inset && h > 2.0 * inset) { + // Only Scale to Fit Media needs room for the marks; a manual scale is the user's. + if fit && !(w > 2.0 * inset && h > 2.0 * inset) { return Err(bad(cmd, "the printable area is too small for crop and registration marks")); } (x + inset, y + inset, w - 2.0 * inset, h - 2.0 * inset) @@ -542,7 +544,6 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { (margin, margin, pw - 2.0 * margin, ph - 2.0 * margin) } }; - let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); let scale = if fit { (fit_box.2 / iw).min(fit_box.3 / ih) } else { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; let (w, h) = (iw * scale, ih * scale); let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 0a41a2247a..8093547c2d 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -860,3 +860,21 @@ fn a_print_uses_the_printable_area_but_does_not_remember_it() { let last = last_print(&s).unwrap(); assert!(last.get("printable").is_none() && last["scaleToFit"] == true, "{last}"); } + +#[test] +fn a_rejected_printable_area_fails_the_print_and_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + s.execute("file.print", json!({"paper": "a3", "scale": 50, "dryRun": true})).unwrap(); + let before = (last_print(&s), s.prefs.get().dialogs.get(REMEMBERED).cloned(), s.active().unwrap().doc.clone()); + for printable in [json!([2000, 0, 100, 100]), json!([0, "oops", 0, 842.29, 1191.14])] { + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": printable, "dryRun": true})); + assert!(r.is_err(), "{printable}: {r:?}"); + } + assert!(before == (last_print(&s), s.prefs.get().dialogs.get(REMEMBERED).cloned(), s.active().unwrap().doc.clone())); + // Marks need room only to fit: a small manual scale in a 60 pt-wide area prints. + let r = s.execute("file.print", json!({"paper": "a3", "scale": 5, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); + assert!(r.is_ok(), "{r:?}"); + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); + assert!(r.is_err(), "{r:?}"); +} From 6e20764ab603bd45f45122c01fe70056a91b5395 Mon Sep 17 00:00:00 2001 From: Brian Blakely Date: Mon, 19 Oct 2026 12:25:50 -0400 Subject: [PATCH 0846/1452] Add a draggable center diagram to Zoom radial blur --- contributors/people.toml | 3 + crates/ui-egui/src/filter_dialog.rs | 8 + .../ui-egui/src/filter_dialog/zoom_center.rs | 101 ++++++++ crates/ui-egui/tests/radial_blur_center.rs | 237 ++++++++++++++++++ docs/ui-design.md | 1 + 5 files changed, 350 insertions(+) create mode 100644 crates/ui-egui/src/filter_dialog/zoom_center.rs create mode 100644 crates/ui-egui/tests/radial_blur_center.rs diff --git a/contributors/people.toml b/contributors/people.toml index 102c69e66c..f079f73d87 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/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index d09caf4953..2994641b4e 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -13,6 +13,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 { @@ -744,6 +746,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; 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..d68a82eb82 --- /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 }; + 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/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/ui-design.md b/docs/ui-design.md index d4ac18d297..a49e0e5400 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. From 21cca70465a0902270f6679ea59e79bc6817f2f4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 10:38:51 -0600 Subject: [PATCH 0847/1452] Glow Range: document the 1..100 % range its slider and clamp use The outer and inner glow commands documented range as 0..100, while the slider starts at 1 % and the command holds values below it at 1 %, as Photoshop's Range does. Document 1..100=50 (like Bevel's contourRange) and test the boundaries. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index bfaa756eb7..cf1b4de82b 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -1002,12 +1002,12 @@ pub fn specs() -> Vec { style_cmd!( "outerGlow", "Outer Glow…", - r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## ), style_cmd!( "innerGlow", "Inner Glow…", - r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## ), style_cmd!( "stroke", @@ -1144,6 +1144,26 @@ mod tests { assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true)); } + /// #6698 review: a glow's Range runs 1..100 %, as its slider and Photoshop's do. A 0 is + /// held at the 1 % floor, and 100 is kept. + #[test] + fn glow_range_is_held_to_one_through_one_hundred() { + for kind in ["outerGlow", "innerGlow"] { + for (sent, kept) in [(0.0, 0.01), (-5.0, 0.01), (1.0, 0.01), (50.0, 0.5), (100.0, 1.0), (250.0, 1.0)] { + let mut s = session(); + s.execute(&format!("layer.layerStyle.{kind}"), json!({"range": sent})).unwrap(); + let range = match &effects(&s)[0] { + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.range, + other => panic!("{other:?}"), + }; + assert!((range - kept).abs() < 1e-6, "{kind} range {sent} kept as {range}, not {kept}"); + } + } + let doc = |id: &str| specs().into_iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("layer.layerStyle.outerGlow").contains(r#""range":1..100=50"#)); + assert!(doc("layer.layerStyle.innerGlow").contains(r#""range":1..100=50"#)); + } + #[test] fn every_style_command_adds_its_effect() { for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { From c706af6a3727859a40e27c71dc54cc5ac3759202 Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:50:19 +0200 Subject: [PATCH 0848/1452] Print: only a fitted print reads the printable area (#7782) A print at a manual scale doesn't use the area, so it no longer validates it (a stale one from a replayed action can't fail the print) or asks the Windows driver for it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds.rs | 11 +++++++---- crates/engine/src/print_cmds/tests.rs | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 019dd7330a..414aa49b82 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -527,14 +527,16 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { // minus a fixed margin. let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); - let fit_box = match printable(p, (pw, ph), cmd)? { + // Only a fitted print reads the area, so a stale one (a replayed action, another paper) can't + // fail a print at a manual scale. + let area = if fit { printable(p, (pw, ph), cmd)? } else { None }; + let fit_box = match area { Some((x, y, w, h)) => { // Emulsion Down mirrors the page as it prints: fit the mirror of the printable area, // so the printed image lands inside the real one. let x = if emulsion_down { pw - x - w } else { x }; let inset = if has_marks { 36.0 } else { 0.0 }; - // Only Scale to Fit Media needs room for the marks; a manual scale is the user's. - if fit && !(w > 2.0 * inset && h > 2.0 * inset) { + if !(w > 2.0 * inset && h > 2.0 * inset) { return Err(bad(cmd, "the printable area is too small for crop and registration marks")); } (x + inset, y + inset, w - 2.0 * inset, h - 2.0 * inset) @@ -638,7 +640,8 @@ fn with_driver_printable(p: &Value) -> Option { } fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { - let driver = native_route(p, spooler).then(|| with_driver_printable(p)).flatten(); + let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); + let driver = (fit && native_route(p, spooler)).then(|| with_driver_printable(p)).flatten(); let p = driver.as_ref().unwrap_or(p); let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 8093547c2d..90628167ff 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -872,6 +872,9 @@ fn a_rejected_printable_area_fails_the_print_and_changes_nothing() { assert!(r.is_err(), "{printable}: {r:?}"); } assert!(before == (last_print(&s), s.prefs.get().dialogs.get(REMEMBERED).cloned(), s.active().unwrap().doc.clone())); + // A print at a manual scale doesn't read the area, so a stale one doesn't fail it. + let r = s.execute("file.print", json!({"paper": "a3", "scale": 50, "printable": [0.0, 0.0, 842.29, 1191.14], "orientation": "landscape", "dryRun": true})); + assert!(r.is_ok(), "{r:?}"); // Marks need room only to fit: a small manual scale in a 60 pt-wide area prints. let r = s.execute("file.print", json!({"paper": "a3", "scale": 5, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); assert!(r.is_ok(), "{r:?}"); From 1d6948134931baa706ba09598f1e235c2a5cee27 Mon Sep 17 00:00:00 2001 From: Hodd Date: Mon, 19 Oct 2026 18:54:31 +0200 Subject: [PATCH 0849/1452] Guides: draw the drag readout after ruler drags so it is current every frame --- crates/ui-egui/src/canvas.rs | 13 ++++++-- crates/ui-egui/src/rulers.rs | 61 +++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5f378a56d3..6361d3a346 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3913,9 +3913,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.ui.view.shows(app.ui.view.show.canvas_guides) || crate::guide_layout_ui::preview(app, &doc).is_some() { crate::rulers::draw_guides(app, &painter, &xf, &doc); } - if app.session.active_index() == Some(idx) { - crate::rulers::draw_guide_drag_readout(app, &ctx); - } // The pending crop frame shows on its own document only, not on the other tiles (#1918). let foreign_crop = if app.session.active_index() == Some(idx) { None } else { app.ui.crop_rect.take() }; draw_drag_preview(app, &painter, &xf); @@ -4237,10 +4234,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.hover_doc = response.hover_pos().map(|p| xf.to_doc(p)); } if primary && app.ui.extras.rulers { + let before = app.guide_drag; crate::rulers::draw_rulers(app, ui, full, &xf); + // A guide dragged out of a ruler moved after this frame drew it: draw it again where it is. + if app.guide_drag != before { + ctx.request_repaint(); + } } else if app.ui.extras.rulers { crate::rulers::paint_rulers(app, ui, full, &xf, &doc); } + // After the ruler drags above, so the readout shows where the guide is now, and not after a + // release ended the drag. + if app.session.active_index() == Some(idx) { + crate::rulers::draw_guide_drag_readout(app, &ctx); + } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { // Tool input can change the stored camera while navigation edits the local view. diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 3c5e33c290..a8e6c0674d 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -789,15 +789,16 @@ mod tests { assert!(h.query_by_label("X:").is_none(), "no readout once the drag ends"); } - /// Moving a horizontal guide with the Move tool on the real canvas: `Y:` follows the pointer. - #[test] - fn moving_a_guide_on_the_canvas_shows_its_y() { - use egui_kittest::kittest::Queryable; + type AppHarness = egui_kittest::Harness<'static, PhotocraftApp>; + + /// The real canvas on a 400 × 300 document with a horizontal guide at y = 100, at 100 %, + /// with the Move tool and snapping off. + fn canvas_harness(rulers: bool) -> AppHarness { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); app.sync_views(); - app.ui.extras.rulers = false; + app.ui.extras.rulers = rulers; app.ui.extras.snap = false; app.ui.tool = crate::state::Tool::Move; let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( @@ -814,12 +815,23 @@ mod tests { let v = &mut h.state_mut().ui.views[0]; (v.zoom, v.center, v.fit_pending) = (1.0, [200.0, 150.0], false); h.run_steps(2); - let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { - let app = h.state(); - let v = &app.ui.views[0]; - let rect = content_rect(app, app.last_canvas_rect); - ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 }.to_screen(x, y) - }; + h + } + + /// Screen point of document point `(x, y)` in [`canvas_harness`]. + fn canvas_at(h: &AppHarness, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let rect = content_rect(app, app.last_canvas_rect); + ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 }.to_screen(x, y) + } + + /// Moving a horizontal guide with the Move tool on the real canvas: `Y:` follows the pointer. + #[test] + fn moving_a_guide_on_the_canvas_shows_its_y() { + use egui_kittest::kittest::Queryable; + let mut h = canvas_harness(false); + let at = canvas_at; let press = at(&h, 200.0, 100.0); h.event(egui::Event::PointerMoved(press)); h.run_steps(1); @@ -839,6 +851,33 @@ mod tests { assert!(h.query_by_label("Y:").is_none(), "gone after the drop"); } + /// A guide dragged out of the top ruler: in each frame the readout shows where the guide is + /// after that frame's pointer move (the ruler handles the drag after the canvas is drawn), and + /// the frame that drops it shows none. + #[test] + fn ruler_drag_readout_is_current_every_frame() { + use egui_kittest::kittest::Queryable; + let mut h = canvas_harness(true); + let from = pos2(canvas_at(&h, 200.0, 0.0).x, h.state().last_canvas_rect.top() + RULER / 2.0); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for y in [40.0, 60.0, 75.0] { + h.event(egui::Event::PointerMoved(canvas_at(&h, 200.0, y))); + h.run_steps(1); + let pos = h.state().guide_drag.map(|d| d.pos); + assert_eq!(pos, Some(f64::from(y)), "the drag follows the pointer"); + assert!(h.query_by_label(&format!("{y} px")).is_some(), "readout at {y} in the same frame"); + } + let up = canvas_at(&h, 200.0, 75.0); + h.event(egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().guide_drag.is_none()); + assert!(h.query_by_label("Y:").is_none(), "no readout in the frame that drops the guide"); + assert!(h.state().session.active().unwrap().doc.guides.horizontal.contains(&75.0), "the guide lands at y = 75"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; From 640873de7ba800dd6b3b8388e0f27c5bf2c63791 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:18 +0200 Subject: [PATCH 0850/1452] PNG: keep a stereo file's sTER pairing on save (#6063) (#6285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PNG: keep a stereo file's sTER pairing on save (#6063) Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it on the document's FileEncoding, and write it back when saving, so a stereo PNG still says it is a stereo pair. Covers the ordinary and the palette PNG encoders, including interlaced output. * io tests: pass the new sTER argument to encode_png_indexed Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: highperfocused Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 130 ++++++++++++++++++++++++++++++-- crates/codecs/src/lib.rs | 4 +- crates/codecs/src/options.rs | 32 ++++++++ crates/doc/src/lib.rs | 8 +- crates/engine/src/web_cmds.rs | 2 +- crates/io/src/flat.rs | 13 ++-- crates/io/tests/flat.rs | 25 +++++- crates/io/tests/modes.rs | 6 +- docs/file-format-parity.md | 5 +- 9 files changed, 202 insertions(+), 23 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..ede70d296d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; +use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result, CodecError> { Ok(Some(PngPalette { colors, transparent })) } +/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read +/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a +/// defined mode byte. The first valid chunk wins, as a later one would contradict it. +pub fn stereo(bytes: &[u8]) -> Option { + let mut rest = bytes.get(8..)?; + while let Some((length, tail)) = rest.split_first_chunk::<4>() { + let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break }; + let length = u32::from_be_bytes(*length); + if length > i32::MAX as u32 { + break; + } + let Ok(length) = usize::try_from(length) else { break }; + let Some(data) = tail.get(..length) else { break }; + let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break }; + rest = next; + if kind == b"IEND" { + break; + } + if kind != b"sTER" || data.len() != 1 { + continue; + } + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + if crc.sum() != u32::from_be_bytes(*checksum) { + continue; + } + return PngStereo::from_mode(data[0]); + } + None +} + fn map_png_err(e: png::DecodingError) -> CodecError { match e { png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()), @@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, dpi: Option<(f32, f32)>, + stereo: Option, ) -> Result, CodecError> { if width == 0 || height == 0 || indices.len() != width as usize * height as usize { return Err(CodecError::InvalidImage("index data does not match the size".into())); @@ -603,12 +640,95 @@ pub fn encode_indexed( { let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = stereo { + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?; writer.finish().map_err(|e| CodecError::encode(F, e))?; } Ok(out) } +#[cfg(test)] +mod stereo_tests { + use super::*; + + /// One CRC-valid chunk as it appears in a file. + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + fn png_with(stereo: &[u8]) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0])); + if !stereo.is_empty() { + out.extend_from_slice(&chunk(b"sTER", stereo)); + } + out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0])); + out.extend_from_slice(&chunk(b"IEND", &[])); + out + } + + #[test] + fn reads_both_defined_views() { + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + assert_eq!(stereo(&png_with(&[])), None); + // Reserved and unknown mode bytes are not a pairing we can keep. + assert_eq!(stereo(&png_with(&[2])), None); + assert_eq!(stereo(&png_with(&[255])), None); + // A two-byte payload is not sTER data. + assert_eq!(stereo(&png_with(&[0, 0])), None); + assert_eq!(stereo(b"not a png"), None); + } + + #[test] + fn a_broken_crc_is_not_a_stereo_mark() { + let mut bytes = png_with(&[0]); + let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + // The CRC trails the type and the one-byte payload of the chunk. + bytes[kind + 8] ^= 0xff; + assert_eq!(stereo(&bytes), None); + } + + #[test] + fn encode_writes_the_chunk_before_idat_and_it_reads_back() { + let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); + for view in [PngStereo::Horizontal, PngStereo::Vertical] { + let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(view)); + let back = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(back.data(), img.data()); + let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap(); + assert!(ter < idat, "sTER before IDAT"); + } + let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(stereo(&plain), None); + } + + #[test] + fn interlaced_and_indexed_encoders_keep_the_mark() { + let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); + let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Vertical)); + let pal = [[255, 0, 0], [0, 0, 255]]; + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); + } +} + #[cfg(test)] mod indexed_tests { use super::*; @@ -617,14 +737,14 @@ mod indexed_tests { fn png8_roundtrips_through_the_decoder() { let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]]; let idx = [0u8, 1, 2, 1, 0, 2]; - let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap(); + let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap(); let img = decode(&bytes, &Limits::default()).unwrap(); assert_eq!(img.dimensions(), (3, 2)); let px = img.convert(ChannelLayout::Rgba, SampleType::U8); assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]); assert_eq!(px.data()[11], 0, "transparent entry"); - assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err()); - assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err()); + assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err()); + assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err()); } fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec { @@ -643,7 +763,7 @@ mod indexed_tests { #[test] fn palette_reads_the_table_and_its_one_transparent_entry() { - let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap(); + let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap(); let colors = vec![[1, 2, 3], [4, 5, 6]]; assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) })); assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None })); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..67369d0266 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -28,7 +28,7 @@ pub mod web; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; +pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; @@ -38,7 +38,7 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index eee5b38c0a..e6ca3cb94f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -100,6 +100,34 @@ pub enum PngCompression { Best, } +/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngStereo { + /// Left and right views side by side, left first. + Horizontal, + /// Left and right views stacked, left on top. + Vertical, +} + +impl PngStereo { + /// The view for an `sTER` mode byte; reserved and unknown values are `None`. + pub fn from_mode(mode: u8) -> Option { + match mode { + 0 => Some(Self::Horizontal), + 1 => Some(Self::Vertical), + _ => None, + } + } + + /// The `sTER` mode byte. + pub fn mode(self) -> u8 { + match self { + Self::Horizontal => 0, + Self::Vertical => 1, + } + } +} + /// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the /// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -164,6 +192,9 @@ pub struct EncodeOptions { pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, + /// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6), + /// so a stereo PNG keeps its mark when saved again (#6063). + pub png_stereo: Option, /// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see /// `codecs::vp8`) with [`Self::webp_quality`]. pub webp_lossless: bool, @@ -189,6 +220,7 @@ impl Default for EncodeOptions { jpeg_chroma_subsampling: true, png_compression: PngCompression::Default, png_interlaced: false, + png_stereo: None, webp_lossless: true, webp_quality: 80, tiff_compression: TiffCompression::None, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a1214e7938..268b0b6b43 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -912,8 +912,8 @@ pub struct Document { pub variables: Variables, /// Window › Timeline (None until a video timeline is created). pub timeline: Option, - /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so - /// a save writes it back the same way rather than at fixed defaults. + /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless, + /// PNG stereo pairing), so a save writes it back the same way rather than at fixed defaults. pub file_encoding: FileEncoding, } @@ -932,6 +932,10 @@ pub struct FileEncoding { /// Lossy WebP quality. #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, + /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` + /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + #[serde(skip_serializing_if = "Option::is_none")] + pub png_stereo: Option, } impl FileEncoding { diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 29237fd5ad..755ba07320 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -586,7 +586,7 @@ pub fn optimize( let bytes = if st.format == WebFormat::Gif { photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)? } else { - photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? + photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None, None).map_err(e)? }; if preview { indices = idx; diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 773e692729..76f53d9703 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -67,8 +67,9 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document } /// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality -/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its -/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. +/// (estimated from its quantisation tables), a WebP's lossless or lossy encoding with its +/// quality, and a PNG's `sTER` stereo pairing. A lossy WebP whose quality can't be read gets +/// [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { file_encoding_as(codecs::detect(bytes), bytes) } @@ -78,6 +79,7 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil let mut e = photocraft_doc::FileEncoding::default(); match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), + Some(Format::Png) => e.png_stereo = codecs::png_stereo(bytes).map(codecs::PngStereo::mode), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), Some(codecs::WebpEncoding::Lossy { quality }) => { @@ -95,8 +97,8 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75; /// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a -/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or -/// lossless. +/// save that chose no quality writes, so a JPEG keeps its quality, a WebP stays lossy or lossless, +/// and a stereo PNG keeps its `sTER` pairing (#6063). pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { let e = &doc.file_encoding; if let Some(q) = e.jpeg_quality { @@ -108,6 +110,7 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { if let Some(q) = e.webp_quality { opts.webp_quality = q.min(100); } + opts.png_stereo = e.png_stereo.and_then(codecs::PngStereo::from_mode); } /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the @@ -835,7 +838,7 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> })?; // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); - let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; + let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi, opts.encode.png_stereo)?; let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())]; if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() { warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into()); diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index edaff2ec81..e591fbd5c1 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -105,7 +105,7 @@ fn indexed_bmp_opens_with_its_color_table() { #[test] fn indexed_png_opens_with_its_color_table() { let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]]; - let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None, None).unwrap(); let doc = import("indexed.png", &png).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.depth, SampleType::U8); @@ -396,8 +396,8 @@ fn transparent_flat_images_open_as_layer_0() { ("ga.png", enc(ChannelLayout::GrayA, CS::U8, Format::Png), true), ("a.tif", enc(ChannelLayout::Rgba, CS::U8, Format::Tiff), true), ("ga.tif", enc(ChannelLayout::GrayA, CS::U16, Format::Tiff), true), - ("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None).unwrap(), true), - ("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None).unwrap(), false), + ("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None, None).unwrap(), true), + ("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None, None).unwrap(), false), ("rgb.png", enc(ChannelLayout::Rgb, CS::U8, Format::Png), false), ("g.png", enc(ChannelLayout::Gray, CS::U16, Format::Png), false), ("rgb.tif", enc(ChannelLayout::Rgb, CS::U8, Format::Tiff), false), @@ -1114,3 +1114,22 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { let r = export(&two, "tif", &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("2 layer(s) flattened")), "{:?}", r.warnings); } + +#[test] +fn a_stereo_png_keeps_its_stereo_mark_on_save() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; + let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); + for view in [PngStereo::Horizontal, PngStereo::Vertical] { + let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); + let doc = import("stereo.png", &bytes).unwrap().document; + assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); + // What File > Save does: reuse the settings the file was opened with (#6063). + let mut opts = ExportOptions::default(); + apply_file_encoding(&doc, &mut opts.encode); + let r = export(&doc, "png", &opts).unwrap(); + assert_eq!(photocraft_codecs::png_stereo(&r.bytes), Some(view), "{view:?}"); + } + // An ordinary PNG gains no mark. + let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); + assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None); +} diff --git a/crates/io/tests/modes.rs b/crates/io/tests/modes.rs index c166766215..6760c7afe7 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/modes.rs @@ -31,7 +31,7 @@ fn indexed_png_keeps_duplicate_palette_colors_opaque() { // choose the first red entry for both pixels, making the opaque one disappear on save. let palette = [[255, 0, 0], [0, 255, 0], [255, 0, 0], [0, 0, 255]]; for transparent in [0, 2] { - let png = photocraft_codecs::encode_png_indexed(4, 1, &[0, 1, 2, 3], &palette, Some(transparent), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(4, 1, &[0, 1, 2, 3], &palette, Some(transparent), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; assert_eq!(d.mode, ColorMode::Indexed); let before = d.color_table.clone(); @@ -102,7 +102,7 @@ fn indexed_png_keeps_the_last_palette_index() { palette[0] = [255, 0, 0]; palette[255] = [255, 0, 0]; for transparent in [0, 255] { - let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 255], &palette, Some(transparent), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 255], &palette, Some(transparent), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); @@ -112,7 +112,7 @@ fn indexed_png_keeps_the_last_palette_index() { #[test] fn indexed_png_keeps_a_single_transparent_entry() { - let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 0], &[[255, 0, 0]], Some(0), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 0], &[[255, 0, 0]], Some(0), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..a2a21c9c37 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 5c513b8c88706b7dda113fb6d2d0f56d0146fcc4 Mon Sep 17 00:00:00 2001 From: Tarek Mohamed <30214297+seotarek@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:02:37 +0300 Subject: [PATCH 0851/1452] fix(actions): flush pending recorded steps on save (#7193) (#7244) When actions.save was called while recording was active, it exported only already-committed action steps, silently omitting any pending steps shown in the Actions panel. Call flush_recording at the beginning of actions.save to commit pending journal entries to the action and advance the recording pointer, ensuring all displayed recorded steps are saved while allowing ongoing recording to continue uninterrupted. Fixes #7193 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 1 + crates/engine/src/atn_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..41badd86f3 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3227,6 +3227,7 @@ fn load(s: &mut Session, p: &Value) -> Result { fn save(s: &mut Session, p: &Value) -> Result { let cmd = "actions.save"; + crate::actions_cmds::flush_recording(s)?; let skip = match p.get("skipUnsupported") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..098dc9806d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1033,6 +1033,26 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +#[test] +fn save_while_recording_includes_pending_steps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Pending"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Recorded"})).unwrap(); + + // While still recording (without calling actions.stop), save the action. + let saved = s.execute("actions.save", json!({"action": "Pending"})).unwrap(); + assert_eq!(saved["steps"], 1, "pending steps must be flushed and included in save"); + assert_eq!(saved["skipped"], json!([])); + + // After save, recording continues and additional steps are flushed on stop without duplication. + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + + let action = s.execute("actions.get", json!({"action": "Pending"})).unwrap(); + assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication"); +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From 96c151525a7755986765d52c281018d949ba5b92 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:53 +0200 Subject: [PATCH 0852/1452] feat(prefs): Show File Name In Title keeps the title bar to the app name (#5037) (#5129) Co-authored-by: highperfocused Co-authored-by: Brandon Thomas --- crates/engine/src/prefs.rs | 5 +++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels.rs | 49 +++++++++++++++++++++++++++-- 22 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3e87d43fc7..b551032e9d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -286,6 +286,10 @@ pub struct Interface { /// own one-row title bar (tiling window managers, desktops that draw their own decorations; /// #1271, #1316). Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Show the active document's file name in the title bar and the OS window title. The tabs + /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). + /// The unsaved-changes mark stays either way. + pub show_file_name_in_title: bool, /// Notices (the lower-right cards) hide themselves after /// [`Interface::notification_duration_seconds`] unless the pointer rests on them (#2022). pub notification_auto_hide: bool, @@ -317,6 +321,7 @@ impl Default for Interface { enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, + show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..7da5cbc30e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3812,6 +3812,7 @@ navigator Zoom Zvětšení There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte. set name název sady {set} (modified) {set} (změněno) + Show file name in title Zobrazit název souboru v titulu Accept and Go to Conflict Přijmout a přejít ke konfliktu Changes undone. Změny vráceny. Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..6faa41f3ea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3208,6 +3208,7 @@ curves Smooth Glätten There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen. set name Name des Satzes {set} (modified) {set} (geändert) + Show file name in title Dateiname im Titel anzeigen Accept and Go to Conflict Übernehmen und zum Konflikt Changes undone. Änderungen rückgängig gemacht. Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..ac3b9fbd36 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3904,6 +3904,7 @@ curves Smooth Εξομάλυνση There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) + Show file name in title Εμφάνιση ονόματος αρχείου στον τίτλο Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση Changes undone. Οι αλλαγές αναιρέθηκαν. Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..b2e63bbc84 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4166,6 +4166,7 @@ navigator Zoom Zoom There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. set name nombre del conjunto {set} (modified) {set} (modificado) + Show file name in title Mostrar nombre de archivo en el título Accept and Go to Conflict Aceptar e ir al conflicto Changes undone. Cambios deshechos. Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..2acba73d50 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3211,6 +3211,7 @@ curves Smooth Lisser set name nom du jeu {set} (modified) {set} (modifié) Random Aléatoire + Show file name in title Afficher le nom de fichier dans le titre Accept and Go to Conflict Accepter et aller au conflit Changes undone. Modifications annulées. Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..7393ba2954 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4123,6 +4123,7 @@ cameraRaw Yellow Sárga There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet. set name készlet neve {set} (modified) {set} (módosítva) + Show file name in title Fájlnév megjelenítése a címben Accept and Go to Conflict Elfogadás és ugrás az ütközéshez Changes undone. Változások visszavonva. Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..3246a9c371 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3198,6 +3198,7 @@ curves Smooth Haluskan set name nama set {set} (modified) {set} (diubah) Random Acak + Show file name in title Tampilkan nama file di judul Accept and Go to Conflict Terima dan Buka Konflik Changes undone. Perubahan dibatalkan. Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..8b664de9f3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4325,6 +4325,7 @@ shape Appearance Aspetto There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. set name nome del set {set} (modified) {set} (modificato) + Show file name in title Mostra nome file nel titolo Accept and Go to Conflict Accetta e vai al conflitto Changes undone. Modifiche annullate. Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..c41d46a320 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3880,6 +3880,7 @@ curves Smooth スムーズ There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 set name セット名 {set} (modified) {set}(変更あり) + Show file name in title タイトルにファイル名を表示 Accept and Go to Conflict 適用して競合へ移動 Changes undone. 変更を取り消しました。 Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d5d58b2ff2..21ef94cff1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3384,6 +3384,7 @@ curves Smooth 매끄럽게 There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. set name 세트 이름 {set} (modified) {set}(수정됨) + Show file name in title 제목에 파일 이름 표시 Accept and Go to Conflict 적용 후 충돌 항목으로 이동 Changes undone. 변경 사항을 취소했습니다. Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..ca9253c349 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3964,6 +3964,7 @@ curves Smooth Vloeiend maken There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een. set name naam van de set {set} (modified) {set} (gewijzigd) + Show file name in title Bestandsnaam in titel weergeven Accept and Go to Conflict Accepteren en naar conflict Changes undone. Wijzigingen ongedaan gemaakt. Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..4f8194ccba 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3802,6 +3802,7 @@ curves Smooth Wygładź There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden. set name nazwa zestawu {set} (modified) {set} (zmieniony) + Show file name in title Pokaż nazwę pliku w tytule Accept and Go to Conflict Akceptuj i przejdź do konfliktu Changes undone. Zmiany cofnięte. Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..65b81cb194 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3994,6 +3994,7 @@ layerLabel No Color Sem cor There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. set name nome do conjunto {set} (modified) {set} (modificado) + Show file name in title Exibir nome do arquivo no título Accept and Go to Conflict Aceitar e ir para o conflito Changes undone. Alterações desfeitas. Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..aa40b583b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3176,6 +3176,7 @@ curves Smooth Сгладить set name имя набора {set} (modified) {set} (изменён) Random Случайно + Show file name in title Показывать имя файла в заголовке Accept and Go to Conflict Принять и перейти к конфликту Changes undone. Изменения отменены. Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..474fedaf22 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4277,6 +4277,7 @@ shape Appearance Izgled There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. set name ime skupa {set} (modified) {set} (izmenjen) + Show file name in title Prikaži naziv datoteke u naslovu Accept and Go to Conflict Prihvati i idi na sukob Changes undone. Izmene su poništene. Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..8fb253f142 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3934,6 +3934,7 @@ cameraData Lens Lens There are too many sets: delete one first. Çok fazla küme var: önce birini silin. set name küme adı {set} (modified) {set} (değiştirildi) + Show file name in title Başlıkta dosya adını göster Accept and Go to Conflict Kabul Et ve Çakışmaya Git Changes undone. Değişiklikler geri alındı. Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..ee29fb4a08 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3903,6 +3903,7 @@ curves Smooth Згладити There are too many sets: delete one first. Забагато наборів: спершу видаліть один. set name назва набору {set} (modified) {set} (змінено) + Show file name in title Показувати ім’я файла в заголовку Accept and Go to Conflict Прийняти й перейти до конфлікту Changes undone. Зміни скасовано. Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..03cdfb972b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3690,6 +3690,7 @@ curves Smooth Làm mượt There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước. set name tên bộ {set} (modified) {set} (đã sửa đổi) + Show file name in title Hiển thị tên tệp trong tiêu đề Accept and Go to Conflict Chấp nhận và đến xung đột Changes undone. Đã hoàn tác thay đổi. Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f242f8f63..42704b01b0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2826,6 +2826,7 @@ curves Smooth 平滑 set name 组名称 {set} (modified) {set}(已修改) Random 随机 + Show file name in title 标题中显示文件名 Accept and Go to Conflict 接受并转到冲突 Changes undone. 已撤消更改。 Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 621bee5518..1e1a8f04a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2811,6 +2811,7 @@ curves Smooth 平滑 set name 組合名稱 {set} (modified) {set}(已修改) Random 隨機 + Show file name in title 標題中顯示檔案名稱 Accept and Go to Conflict 接受並前往衝突 Changes undone. 已還原變更。 Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..609007a5e4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -602,7 +602,14 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } - let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into()); + // Preferences › Interface › Show File Name In Title: with it off the bar keeps the + // app name and the unsaved dot, since the tabs already name the file (#5037). + let show_name = app.session.prefs().interface.show_file_name_in_title; + let title = app + .session + .active() + .map(|d| format!("{}{}", if show_name { d.doc.name.as_str() } else { "PhotoCraft" }, if d.is_dirty() { " •" } else { "" })) + .unwrap_or_else(|| "PhotoCraft".into()); // The menus and the far-end controls are laid out first; the title is centred in // whatever room is left between them (`free`, left to right: menus then controls, or // controls then menus in a right-to-left UI), shortened or dropped rather than drawn @@ -728,8 +735,12 @@ pub fn system_title_bar(app: &PhotocraftApp) -> bool { /// The OS window title: the active document's name suffixed with the app name (a `*` prefix /// marks unsaved changes, like the `•` in the app-drawn title), or just the app name with no -/// document open. +/// document open. With Preferences › Interface › Show File Name In Title off, the file name +/// drops out and only the app name (and the `*`) remains (#5037). pub fn window_title(app: &PhotocraftApp) -> String { + if !app.session.prefs().interface.show_file_name_in_title { + return format!("{}PhotoCraft", if app.session.active().is_some_and(|d| d.is_dirty()) { "*" } else { "" }); + } app.session.active().map(|d| format!("{}{} \u{2014} PhotoCraft", if d.is_dirty() { "*" } else { "" }, d.doc.name)).unwrap_or_else(|| "PhotoCraft".into()) } @@ -820,6 +831,40 @@ mod title_tests { assert_eq!(window_title(&app), "*foo.psd \u{2014} PhotoCraft"); } + #[test] + fn hiding_the_file_name_keeps_the_app_name_and_the_dirty_mark() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + assert_eq!(window_title(&app), "PhotoCraft"); + if let Some(st) = app.session.active_mut() { + st.revision += 1; + } + assert_eq!(window_title(&app), "*PhotoCraft"); + } + + #[test] + fn hidden_file_name_leaves_the_app_name_in_the_drawn_title() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + let texts = |app: &mut crate::PhotocraftApp| { + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::title_bar(app, ui)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for shape in &out.shapes { + collect_text(&shape.shape, &mut texts); + } + texts + }; + assert!(texts(&mut app).iter().any(|t| t.contains("foo.psd")), "shown by default"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + let hidden = texts(&mut app); + assert!(!hidden.iter().any(|t| t.contains("foo.psd")), "file name hidden: {hidden:?}"); + assert!(hidden.iter().any(|t| t.contains("PhotoCraft")), "app name kept: {hidden:?}"); + } + #[test] fn system_mode_hides_the_mark_and_the_centred_title() { for custom in [true, false] { From adbd40ef55032d1455db6101dbf0791ddea07192 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:03:20 -0400 Subject: [PATCH 0853/1452] Actions: Photoshop Shadows/Highlights steps play (#5830) (#5854) Imported actions stopped at Shadows/Highlights ("adaptCorrect has no PhotoCraft equivalent yet"), although Image > Adjustments > Shadows/Highlights exists. The step now maps to it both ways: shadowMode/highlightMode (amount, width, radius), black/white clip, midtone contrast and colour correction, each read as its char ID or string ID. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 54 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 43 +++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 41badd86f3..999ba5c193 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2372,6 +2372,52 @@ fn image_size_from(d: &Descriptor) -> Mapped { step("image.imageSize", Value::Object(p)) } +/// Shadows/Highlights (`adaptCorrect`, #5830): the shadow and highlight tones are `shadowMode` +/// and `highlightMode` objects (amount and width in %, radius in px), then the black and white +/// clips (%), midtone contrast (`center`) and colour correction. Each key is read as its char ID +/// or its string ID, whichever the file used; absent keys keep the command's defaults. +const SHADOWS_HIGHLIGHTS_TONES: [(&str, &str, &str); 2] = [("sdwM", "shadowMode", "shadow"), ("hglM", "highlightMode", "highlight")]; +const SHADOWS_HIGHLIGHTS_TONE: [(&str, &str, &str); 3] = [("Amnt", "amount", "Amount"), ("Wdth", "width", "Tone"), ("Rds ", "radius", "Radius")]; +const SHADOWS_HIGHLIGHTS: [(&str, &str, &str); 4] = + [("BlcC", "blackClip", "blackClip"), ("WhtC", "whiteClip", "whiteClip"), ("Cntr", "center", "midtone"), ("ClrC", "colorCorrection", "color")]; + +fn shadows_highlights_from(d: &Descriptor) -> Mapped { + let mut m = Map::new(); + for (code, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let Some(t) = get_obj(d, code).or_else(|| get_obj(d, name)) else { continue }; + for (code, name, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = get_num(t, code).or_else(|| get_num(t, name)) { + m.insert(format!("{prefix}{suffix}"), Value::from(v)); + } + } + } + for (code, name, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = get_num(d, code).or_else(|| get_num(d, name)) { + m.insert(param.into(), Value::from(v)); + } + } + step("image.adjustments.shadowsHighlights", Value::Object(m)) +} + +fn shadows_highlights_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + for (_, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let mut t = desc("adaptCorrectTones"); + for (_, key, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = jnum(p, &format!("{prefix}{suffix}")) { + t = t.with(key, if suffix == "Radius" { long(v) } else { pct(v) }); + } + } + d = d.with(name, D::Descriptor(t)); + } + for (_, key, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = jnum(p, param) { + d = d.with(key, if matches!(param, "midtone" | "color") { long(v) } else { D::Double(v) }); + } + } + Some(d) +} + fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); if let Some(pc) = jnum(p, "percent") { @@ -2661,6 +2707,14 @@ const EVENTS: &[Event] = &[ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). + Event { + code: "", + name: "adaptCorrect", + label: "Shadows/Highlights", + ids: &["image.adjustments.shadowsHighlights"], + from: shadows_highlights_from, + to: shadows_highlights_to, + }, Event { code: "Lvls", name: "levels", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 098dc9806d..59dbbf13c8 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -186,6 +186,10 @@ fn every_mapped_command_exports_and_imports_back() { json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}), ), ("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})), + ( + "image.adjustments.shadowsHighlights", + json!({"shadowAmount": 60, "shadowTone": 40, "shadowRadius": 25, "highlightAmount": 10, "highlightTone": 50, "highlightRadius": 30, "blackClip": 0.02, "whiteClip": 0.05, "midtone": 15, "color": 20}), + ), ("image.adjustments.threshold", json!({"level": 100})), ("image.adjustments.posterize", json!({"levels": 4})), ("image.adjustments.invert", json!({})), @@ -1033,6 +1037,45 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as +/// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. +#[test] +fn photoshop_shadows_highlights_steps_play() { + let tones = |amount: f64, width: f64, radius: i32| { + D::Descriptor(desc("adaptCorrectTones").with("amount", pct(amount)).with("width", pct(width)).with("radius", D::Integer(radius))) + }; + let d = desc("null") + .with("shadowMode", tones(35.0, 50.0, 30)) + .with("highlightMode", tones(20.0, 40.0, 24)) + .with("blackClip", D::Double(0.01)) + .with("whiteClip", D::Double(0.02)) + .with("center", D::Integer(5)) + .with("colorCorrection", D::Integer(20)); + let e = event_for(&item("adaptCorrect", "Sombra/Realce", None).event).expect("adaptCorrect is mapped"); + let Mapped::Steps(steps) = (e.from)(&d) else { panic!("Shadows/Highlights maps to a step") }; + assert_eq!(steps.len(), 1); + let (id, p) = &steps[0]; + assert_eq!(id, "image.adjustments.shadowsHighlights"); + let mut p = p.clone(); + normalise(&mut p); + assert_eq!( + p, + json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "highlightAmount": 20, "highlightTone": 40, "highlightRadius": 24, "blackClip": 0.01, "whiteClip": 0.02, "midtone": 5, "color": 20}) + ); + // The same settings as char IDs read the same; absent keys are left to the defaults. + let codes = desc("null") + .with("sdwM", D::Descriptor(desc("adaptCorrectTones").with("Amnt", pct(35.0)).with("Wdth", pct(50.0)).with("Rds ", D::Integer(30)))) + .with("BlcC", D::Double(0.01)); + let Mapped::Steps(steps) = (e.from)(&codes) else { panic!("char IDs map too") }; + let mut q = steps[0].1.clone(); + normalise(&mut q); + assert_eq!(q, json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "blackClip": 0.01})); + // And it runs on a document. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#404040"})).unwrap(); + s.execute(id, p).unwrap(); +} + #[test] fn save_while_recording_includes_pending_steps() { let mut s = Session::new(); From 94a135bc6b32b2dabc9fe0bf37bd27118b546791 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:03:26 +0200 Subject: [PATCH 0854/1452] Free Transform: show a flip as -100 % on the flipped axis (#7749) (#7750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options bar read the box's X scale as never negative and dropped the sign of the Y scale, so Flip Horizontal read W 100 %, H 100 %, 180° (typing 0° then flipped the layer vertically) and Flip Vertical read the same as an untouched box. A mirrored box now reads as a negative W or H, whichever keeps the angle nearer 0, as in Photoshop (Flip Horizontal: W -100 % at 0°, Flip Vertical: H -100 % at 0°). Typed W/H divide by a sign-preserving guard instead of max(1e-9). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/transform_tool.rs | 57 ++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 45ef0b1528..561621e8c9 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -2614,9 +2614,23 @@ fn quad_scale(t: &TransformSession) -> f32 { } /// W and H (%), the angle (°) and the horizontal skew (°) the options bar shows for the box. +/// A mirrored box reads as a negative W or H, whichever keeps the angle nearer 0 (Photoshop: +/// Flip Horizontal is W -100 % at 0°, Flip Vertical H -100 % at 0°). Both are the same box: +/// negating W and H and turning by a half turn cancel out. fn readout(t: &TransformSession) -> (f64, f64, f64, f64) { let p = Parts::of(t); - (p.sx * 100.0, p.sy.abs() * 100.0, p.angle.to_degrees(), p.skew_h.to_degrees()) + let wrap = |a: f64| { + let a = a.rem_euclid(std::f64::consts::TAU); + if a > std::f64::consts::PI { a - std::f64::consts::TAU } else { a } + }; + let turned = wrap(p.angle + std::f64::consts::PI); + let (sx, sy, angle) = if p.sy < 0.0 && turned.abs() < wrap(p.angle).abs() { (-p.sx, -p.sy, turned) } else { (p.sx, p.sy, p.angle) }; + (sx * 100.0, sy * 100.0, angle.to_degrees(), p.skew_h.to_degrees()) +} + +/// `v`, kept off zero with its sign: a divisor for a W or H scale that may be negative. +fn nonzero(v: f64) -> f64 { + if v.abs() < 1e-9 { 1e-9f64.copysign(v) } else { v } } /// The box's affine part read as Photoshop's options bar does: the frame scaled (W, H), skewed @@ -2877,12 +2891,12 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (w, h) = (size_pct(&u, w, side_w) as f32, size_pct(&u, h, side_h) as f32); if rw.changed() || rh.changed() { let (kx, ky) = if link { - let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) }; + let k = if rw.changed() { w as f64 / nonzero(sx) } else { h as f64 / nonzero(sy) }; // Linked, the other side follows: keep it within its own limit too. let k = k.min(f64::from(wlim) / sx.abs().max(1e-9)).min(f64::from(hlim) / sy.abs().max(1e-9)); (k, k) } else { - (w as f64 / sx.max(1e-9), h as f64 / sy.max(1e-9)) + (w as f64 / nonzero(sx), h as f64 / nonzero(sy)) }; scale_about_pivot(app, kx, ky); } @@ -3342,6 +3356,43 @@ mod tests { assert!(eq6(skew, 20.0), "{skew}"); } + /// The test box flipped horizontally and vertically, as Edit › Transform › Flip leaves it. + const FLIPPED_H: [[f64; 2]; 4] = [[100.0, 0.0], [0.0, 0.0], [0.0, 50.0], [100.0, 50.0]]; + const FLIPPED_V: [[f64; 2]; 4] = [[0.0, 50.0], [100.0, 50.0], [100.0, 0.0], [0.0, 0.0]]; + + /// Photoshop puts a flip's minus on the flipped axis and keeps the angle at 0: Flip Horizontal + /// reads W -100 %, Flip Vertical H -100 % (it read 180° and nothing at all). + #[test] + fn a_flipped_box_reads_as_a_negative_scale_on_that_axis() { + let mut t = session(); + for (quad, want) in [(FLIPPED_H, (-100.0, 100.0)), (FLIPPED_V, (100.0, -100.0))] { + t.quad = quad; + let (w, h, angle, skew) = readout(&t); + assert!(eq6(w, want.0) && eq6(h, want.1) && eq6(angle, 0.0) && eq6(skew, 0.0), "{quad:?}: {w} {h} {angle} {skew}"); + } + // Flipped both ways is a half turn, not a mirror. + t.quad = [[100.0, 50.0], [0.0, 50.0], [0.0, 0.0], [100.0, 0.0]]; + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, 100.0) && eq6(h, 100.0) && eq6(angle.abs(), 180.0), "{w} {h} {angle}"); + } + + /// The bar's fields act on a flipped box without undoing the flip: angle 0 keeps it as it is, + /// and a typed W scales it and keeps it mirrored. + #[test] + fn bar_fields_keep_a_flipped_box_flipped() { + let flip_h = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_H; + let t = type_into_bar_field_with(flip_h, 4, "0"); + assert!(t.quad.iter().flatten().zip(FLIPPED_H.iter().flatten()).all(|(a, b)| eq6(*a, *b)), "{:?}", t.quad); + let t = type_into_bar_field_with(flip_h, 2, "-50"); + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, -50.0) && eq6(h, 50.0) && eq6(angle, 0.0), "{w} {h} {angle}"); + assert!(eq6(t.quad[0][0] - t.quad[1][0], 50.0), "50 px wide, still mirrored: {:?}", t.quad); + let flip_v = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_V; + let t = type_into_bar_field_with(flip_v, 3, "-200"); + let (w, h, _, _) = readout(&t); + assert!(eq6(w, 200.0) && eq6(h, -200.0), "{w} {h}"); + } + /// Typing H and V skews leans the box about the reference point; the angle, W and H fields keep /// their values, and V stays as typed. #[test] From baa005d4760ab69cf180848cdb84689ba9048cde Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:03:32 +0200 Subject: [PATCH 0855/1452] Type: formatting commands respect Lock All (#7718) (#7720) Character (type.setStyle), Warp Text, Anti-Alias, Orientation, OpenType and Load Default Type Styles all run through with_text_targets, which never checked Lock All, so they changed a locked type layer and added a history step. Photoshop refuses all of them ("the layer is locked"). Check every target with check_text_unlocked before the edit, as type.edit already does. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/type_cmds.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 712cc3c89d..439d1f008b 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -664,6 +664,10 @@ pub(crate) fn with_text_targets( mut f: impl FnMut(&mut TextLayer, (usize, usize)) -> Result<()>, ) -> Result> { let ids = format_targets(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + for id in &ids { + check_text_unlocked(doc, *id)?; + } let damage = s.edit(label, |doc, _| { let snapshot = doc.clone(); let mut damage = Some(photocraft_geom::Rect::EMPTY); @@ -1421,6 +1425,35 @@ mod tests { assert_eq!(s.active().unwrap().history.past_len(), history); } + #[test] + fn all_locked_type_layer_rejects_formatting_without_history() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 20, "text": "a", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("type.create", json!({"x": 20, "y": 60, "text": "b", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let before = text_layer(&s, id); + let history = s.active().unwrap().history.past_len(); + + for (cmd, p) in [ + ("type.setStyle", json!({"layer": id, "size": 40, "fauxBold": true})), + ("type.setStyle", json!({"layers": [other, id], "size": 40})), + ("type.warpText", json!({"layer": id, "style": "arc"})), + ("type.antiAlias.none", json!({"layer": id})), + ("type.orientation.vertical", json!({"layer": id})), + ("type.openType.swash", json!({"layer": id})), + ] { + let result = s.execute(cmd, p); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + } + assert_eq!(text_layer(&s, id), before); + assert_eq!(text_layer(&s, other).size_pt, 12.0); + assert_eq!(s.active().unwrap().history.past_len(), history); + + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "size": 40})).unwrap(); + assert_eq!(text_layer(&s, id).size_pt, 40.0); + } + #[test] fn trailing_empty_paragraph_styles_without_changing_previous_lines() { let mut s = session(); From c9d7f1d984512358b7fc2ee53998f0c7803dfea9 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:03:40 -0500 Subject: [PATCH 0856/1452] Docs: install the CLI and register it with MCP clients (#7663) --- book/src/automation/mcp.md | 3 +++ docs/mcp.md | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 4f3d02496a..2c36b464c2 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -5,6 +5,9 @@ - **Headless:** an in-process `Headless` session owns the engine and performs file I/O. - **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control `. +For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register). +The installed binary survives `cargo clean`; a symlink into `target/release` does not. + Start the headless server with: ```sh diff --git a/docs/mcp.md b/docs/mcp.md index 3046a669bc..9a8c573ce6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,52 @@ # MCP conventions +## Install and register + +From the repository root, with the [source-build prerequisites](development.md#prerequisites) +installed, install the CLI independently of the build directory: + +```sh +cargo install --path apps/photocraft-cli --locked +# POSIX shells, for Cargo's default install location: +export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +photocraft-cli --help +photocraft-cli mcp --help +``` + +Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`. +Re-run the install command after updating the checkout. +By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or +`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or +Cargo's `install.root`, use that installation root's `bin` directory instead. Add it +to the `PATH` inherited by the MCP client, not just an interactive shell, and restart +the client after changing its environment. + +Register the installed command explicitly (replace `/work/project` with an existing folder): + +```sh +claude mcp add photocraft -- photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project +``` + +Other MCP clients can use this bare-name configuration: + +```json +{"mcpServers":{"photocraft":{"command":"photocraft-cli","args":["mcp","--automation-read-root","/work/project","--automation-write-root","/work/project"]}}} +``` + +Linux `.deb` and `.rpm` packages already install `/usr/bin/photocraft-cli`; those users +can skip the source install. Other release locations are listed in the +[installed-release guide](../book/src/automation/mcp.md#from-an-installed-release). +Installing the CLI does not start or register an MCP server automatically. + +If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with +`command -v photocraft-cli` (PowerShell: `Get-Command photocraft-cli`) in the client's +environment. If that environment cannot be changed, set JSON `command` to the absolute +installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into +`target/release`: cleaning the build directory breaks them. A source build alone does +not put the CLI on `PATH`. + +## Start a session + PhotoCraft follows the same conventions as FilmCraft #28. Start a headless session: ```sh From 6385fced3df9653a0f4812b57e08b15fdad4b5c7 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 13:03:48 -0400 Subject: [PATCH 0857/1452] refactor: use photocraft-color's srgb transfer functions in algo and proof simulation (#7604) * share the srgb transfer functions from photocraft-color * document the touched srgb helpers --- crates/algo/src/camera_raw.rs | 10 ++----- crates/algo/src/camera_raw/whites_blacks.rs | 4 +-- crates/algo/src/photo_util.rs | 22 ++++++++------ crates/engine/src/proof_sim.rs | 32 +++++++++++++++------ 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index f855b08bfe..3cadd1d20c 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -67,22 +67,16 @@ impl Encoding { pub(crate) fn of(gamma: f32) -> Self { Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) } + /// A value in this encoding as linear light. pub(crate) fn decode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } } + /// Linear light back into this encoding, the inverse of [`Encoding::decode`]. pub(crate) fn encode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } } } -fn srgb_encode(v: f32) -> f32 { - linear_to_srgb(v) -} - -fn srgb_decode(v: f32) -> f32 { - srgb_to_linear(v) -} - fn luma_linear(c: [f32; 3]) -> f32 { luma(c) } diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 382ade50e9..93bb9cff31 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco if per_channel { for c in 0..3 { let over = (lin[c] - 1.0).max(0.0); - q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over); } } else { let y = super::luma_linear(lin).max(0.0); if y <= 1e-7 { continue; } - let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0); let k = y2 / y; for c in 0..3 { q[c] = enc.encode(lin[c] * k); diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index bf65bc4459..b29612769b 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -183,15 +183,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 { (z >> 40) as f32 / (1u64 << 24) as f32 } -/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1). -pub(crate) fn srgb_to_linear(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - -/// sRGB transfer function, encode. -pub(crate) fn linear_to_srgb(v: f32) -> f32 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } -} +pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; /// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges. pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 { @@ -217,6 +209,18 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + #[test] fn dense_and_sparse_solvers_agree() { // Overdetermined line fit y = 2x + 1. diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index f225a5a7a9..6c7afc15d9 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -18,6 +18,7 @@ use photocraft_cms::{Builtin, Intent, Lut3d, Transform}; use photocraft_color::SampleType; +use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -91,14 +92,16 @@ impl Default for HdrPreview { } impl HdrPreview { + /// Whether the preview passes values through unchanged. pub fn is_identity(&self) -> bool { self.highlight_compression || (self.exposure == 0.0 && self.gamma == 1.0) } + /// One encoded channel value through the 32-bit preview's exposure and gamma. fn apply(&self, v: f32) -> f32 { if self.is_identity() { return v; } - let lin = srgb_decode(v) * 2f32.powf(self.exposure); + let lin = srgb_to_linear(v) * 2f32.powf(self.exposure); srgb_encode(lin.max(0.0).powf(1.0 / self.gamma).min(1.0)) } } @@ -115,13 +118,9 @@ pub fn signature(c: &ColorState, doc: &Document) -> String { format!("{:?} {:?}", pv.setup.kind, h) } -fn srgb_decode(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - +/// sRGB-encodes linear light after clamping it to 0..=1. fn srgb_encode(v: f32) -> f32 { - let v = v.clamp(0.0, 1.0); - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + linear_to_srgb(v.clamp(0.0, 1.0)) } /// Viénot et al. (1999): linear RGB → LMS. @@ -201,7 +200,7 @@ pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr let protan = kind == ProofKind::Protanopia; Box::new(move |v| { let s = eval3(&to_srgb, &v); - let sim = dichromat(s.map(srgb_decode), protan).map(srgb_encode); + let sim = dichromat(s.map(srgb_to_linear), protan).map(srgb_encode); eval3(&to_mon, &sim) }) } @@ -262,6 +261,7 @@ fn run_kind(s: &mut Session, id: &str) -> Result { set_kind(s, kind) } +/// View › 32-bit Preview Options for the active document. fn preview_32(s: &mut Session, p: &Value) -> Result { const CMD: &str = "view.thirtyTwoBitPreviewOptions"; let id = s.active().ok_or(EngineError::NoDocument)?.doc.id; @@ -298,6 +298,7 @@ macro_rules! kind_spec { }; } +/// The View › Proof Setup commands. pub fn specs() -> Vec { vec![ kind_spec!("view.proofSetup.workingCyanPlate", "Working Cyan Plate"), @@ -325,6 +326,21 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(srgb_encode(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + fn session(mode: &str, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8, "mode": mode, "depth": depth})).unwrap(); From 5071472e4d9478ec668dd81aeeed15506b98801e Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:03:56 +0200 Subject: [PATCH 0858/1452] =?UTF-8?q?Layers:=20=E2=8C=98-click=20on=20the?= =?UTF-8?q?=20Background=20thumbnail=20is=20a=20plain=20click,=20as=20in?= =?UTF-8?q?=20Photoshop=20(#7165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop ⌘-clicking a layer thumbnail loads its transparency as a selection (⇧ adds, ⌥ subtracts, ⇧⌥ intersects) without changing the selected layer. Measured on Photoshop 25.4: the Background, which has no transparency, is the exception — the click loads nothing and selects the row like a plain click, where PhotoCraft selected the whole canvas. A hidden layer still loads. Co-Authored-By: Claude Fable 5.1 * Layers: the Background thumbnail's ⌘-click selects the Background alone Review: the fallback to the row handler kept ⌘ set, so `select_mode` toggled the Background into (or out of) the selected set instead of the plain click's replace. The Background thumbnail's ⌘-click now selects it alone; the test asserts the selected set, including when the Background was already part of a multi-selection. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/layer_shortcut_tests.rs | 66 ++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 14 +++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs index a363c340bf..96ab2520dc 100644 --- a/crates/ui-egui/src/layer_shortcut_tests.rs +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -122,3 +122,69 @@ fn alt_click_on_a_layer_thumbnail_fits_the_layer() { assert_eq!(v.center, [310.0, 205.0], "centred on the layer's content"); assert!(v.zoom > before.zoom * 4.0, "zoomed in on 20 × 10 px: {} -> {}", before.zoom, v.zoom); } + +/// ⌘-click a layer's thumbnail loads its transparency as a selection without changing the +/// selected layer; on the Background, which has no transparency, it is a plain click, and a hidden +/// layer still loads (measured on Photoshop 25.4, 2026-10-17). +#[test] +fn command_click_on_a_thumbnail_loads_transparency_except_on_the_background() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let background = s.active().unwrap().active_layer.unwrap().0; + let shape = s.execute("layer.new.layer", json!({"name": "Shape"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 100, "y": 50, "width": 120, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, layer: u64, modifiers: Modifiers| { + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer).unwrap().row; + let at = pos2(row.left() + 46.0, row.center().y); + h.hover_at(at); + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let bounds = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.selection.as_ref().map(|s| s.content_bounds()) + }; + let top = h.state().session.active().unwrap().active_layer.unwrap(); + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "the layer's pixels are selected"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "the selected layer stays"); + assert_eq!(selected(&h), vec![top]); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!( + bounds(&h), + Some(photocraft_geom::Rect::new(100, 50, 220, 150)), + "the Background has no transparency to load: a plain click, which leaves the selection and selects the row" + ); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "the Background alone, not toggled into the set"); + // Already in a multi-selection: the ⌘-click still leaves it the only selected layer. + h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": background, "mode": "add"})).unwrap(); + h.run_steps(2); + assert_eq!(selected(&h).len(), 2); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "replaced, not removed from the set"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + h.state_mut().run("layer.setProps", json!({"layer": shape, "visible": false})).unwrap(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "a hidden layer's transparency loads too"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 609007a5e4..75ade83b8d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3144,7 +3144,9 @@ fn layer_row( if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } - thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + // The Background has no transparency to load: Photoshop treats the click as a plain one. + (!doc.is_background(l.id) && thumb.expand(2.0).contains(p)) + .then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) }); // ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -3156,14 +3158,17 @@ fn layer_row( } else if let Some(cmd) = mask_toggle { actions.push(cmd); } else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { - let mode = select_mode(ui.input(|i| i.modifiers)); + let m = ui.input(|i| i.modifiers); + let pos = resp.interact_pointer_pos(); + let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); + // ⌘-click on the Background thumbnail stands in for the plain click (there is no + // transparency to load): it selects the Background alone, not toggled into the set. + let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) }; actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode}))); crate::smart_ui::deselect(ctx, doc.id); // A thumbnail explicitly selects content or a mask. Selecting an adjustment/fill row // defaults to its mask, but clicking its content thumbnail must leave the mask target. - let pos = resp.interact_pointer_pos(); let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p)); - let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector); // Clicking the layer thumbnail leaves mask view (#196). let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id); @@ -3186,7 +3191,6 @@ fn layer_row( actions.push(("ui.maskTarget".into(), json!(false))); } // ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does. - let m = ui.input(|i| i.modifiers); if on_thumb && m.alt && !m.shift && !m.command { actions.push(("view.fitLayersOnScreen".into(), Value::Null)); } From aa8621788eb9553c0c0ed224640b6889c872b5ba Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:02 +0200 Subject: [PATCH 0859/1452] Minimum / Maximum: CMYK spreads paper white with Maximum, ink with Minimum (#7074) (#7075) Co-authored-by: Claude Opus 5.5 --- crates/algo/src/lib.rs | 21 +++++++++++++++++++-- crates/algo/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ac4b3b1fcc..6cefbbd4c1 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -745,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), FilterParams::Median { radius } => noise::median(src, out, *radius, None), FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), - FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve), - FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve), + FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve), + FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve), FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), @@ -910,6 +910,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec Vec { + if !fxutil::subtractive(ctx) { + return other::min_max_preserve(src, out, radius, max, preserve); + } + let n = src.ch.max(1); + let cc = if ctx.alpha { n - 1 } else { n }; + let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v)); + let mut inv = src.clone(); + flip(&mut inv.data); + let mut res = other::min_max_preserve(&inv, out, radius, max, preserve); + flip(&mut res); + res +} + /// Output tile size used by [`apply`]. pub const TILE: i32 = 256; diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 06beabebbe..b7fc92113c 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -269,6 +269,32 @@ fn minimum_maximum_spread() { assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); } +/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop +/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps +/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones). +#[test] +fn minimum_maximum_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true)); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]); + s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]); + for preserve in [Preserve::Squareness, Preserve::Roundness] { + let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve }); + for x in 17..24 { + assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15)); + } + assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha"); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve }); + for x in 17..24 { + let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 }; + assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15)); + } + assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha"); + } + } +} + #[test] fn maximum_roundness_grows_a_point_into_a_disc() { // One white pixel on black: Maximum spreads it over the structuring element itself. From 4bf0625b04d10dff415a2d494649638db5e7ac00 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 04:04:14 +1100 Subject: [PATCH 0860/1452] Export and import saved tool presets (#6560) (#6889) * Include tool presets in export and import so crop presets are portable. * Pass presets_io_body to the harness without a redundant closure (#6560). CI clippy -D warnings failed on clippy::redundant_closure in the presets export/import regression. --- crates/engine/src/edit_menu_cmds.rs | 42 +++++++++++++++++++-- crates/engine/src/edit_menu_cmds/tests.rs | 46 +++++++++++++++++++++++ crates/engine/src/presets/tools.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 23 ++++++++++-- 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 0b2aad8854..b620230a61 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -1354,6 +1354,7 @@ pub struct PresetFile { pub version: u32, pub brushes: Vec, pub custom_shapes: Vec, + pub tool_presets: Vec, } pub const PRESET_FORMAT: &str = "photocraft-presets"; @@ -1364,7 +1365,7 @@ fn export_import(s: &mut Session, p: &Value) -> Result { .get("kinds") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); + .unwrap_or_else(|| ["brushes", "customShapes", "tools"].iter().map(|k| k.to_string()).collect()); let want = |k: &str| kinds.iter().any(|x| x == k); match str_or(p, "action", "export") { "export" => { @@ -1383,8 +1384,14 @@ fn export_import(s: &mut Session, p: &Value) -> Result { Vec::new() }, custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() }, + tool_presets: if want("tools") { + let builtins = crate::presets::tools::builtin(); + s.presets.tool_presets.iter().filter(|preset| bool_or(p, "includeBuiltins", false) || !builtins.contains(preset)).cloned().collect() + } else { + Vec::new() + }, }; - Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len()})) + Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len(), "tools": file.tool_presets.len()})) } "import" => { let data = p.get("data").cloned().ok_or_else(|| bad(cmd, "missing `data` (an exported preset file)"))?; @@ -1396,6 +1403,29 @@ fn export_import(s: &mut Session, p: &Value) -> Result { if file.format != PRESET_FORMAT { return Err(bad(cmd, format!("not a preset file (format `{}`)", file.format))); } + // Validate the whole tool import before changing any preset library. + let mut tools = s.presets.tool_presets.clone(); + let nt = if want("tools") { file.tool_presets.len() } else { 0 }; + if nt > crate::presets::tools::MAX_PRESETS { + return Err(bad(cmd, "too many tool presets")); + } + if want("tools") { + for mut preset in file.tool_presets { + preset.name = crate::presets::tools::checked_name(&preset.name, cmd)?; + if preset.tool.trim().is_empty() || !preset.options.is_object() { + return Err(bad(cmd, "tool presets require a non-empty tool and an options object")); + } + if let Some(old) = tools.iter_mut().find(|old| old.name == preset.name && old.tool.eq_ignore_ascii_case(&preset.tool)) { + *old = preset; + } else if tools.iter().any(|old| old.name == preset.name) { + return Err(bad(cmd, "a preset of that name belongs to another tool; rename it before importing")); + } else if tools.len() < crate::presets::tools::MAX_PRESETS { + tools.push(preset); + } else { + return Err(bad(cmd, "too many tool presets")); + } + } + } let (mut nb, mut ns) = (0, 0); if want("brushes") { for mut b in file.brushes { @@ -1420,7 +1450,11 @@ fn export_import(s: &mut Session, p: &Value) -> Result { ns += 1; } } - Ok(json!({"brushes": nb, "customShapes": ns})) + if want("tools") { + s.presets.tool_presets = tools; + s.presets_changed(); + } + Ok(json!({"brushes": nb, "customShapes": ns, "tools": nt})) } other => Err(bad(cmd, format!("unknown action `{other}` (export|import)"))), } @@ -1512,7 +1546,7 @@ pub fn specs() -> Vec { "Export/Import Presets…", ["Edit", "Presets"], None, - r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, + r##"{"action":"export|import","kinds":["brushes","customShapes","tools"]?,"data":json (import),"includeBuiltins":bool=false}"##, always, export_import ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 7564cc53d1..16e98b8719 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -693,3 +693,49 @@ fn fade_allocation_failure_keeps_document_and_history() { assert_eq!(*s.active().unwrap().doc, *before); } } + +#[test] +fn tool_presets_round_trip_through_export_import() { + let mut source = Session::new(); + source.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"2:3"}}})).unwrap(); + let exported = source.execute("edit.presets.exportImportPresets", json!({"action":"export"})).unwrap(); + assert_eq!(exported["tools"], 1); + assert_eq!(exported["data"]["toolPresets"][0]["name"], "2 : 3"); + let mut target = Session::new(); + target.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"1:1"}}})).unwrap(); + for _ in 0..2 { + let result = target.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":exported["data"]})).unwrap(); + assert_eq!(result["tools"], 1); + } + let selected = target.execute("tool.presets.select", json!({"preset":"2 : 3"})).unwrap(); + assert_eq!(selected["options"]["toolOptions"]["crop_ratio"], "2:3"); + assert_eq!(target.presets.tool_presets.iter().filter(|p| p.name == "2 : 3").count(), 1); + let only_brushes = source.execute("edit.presets.exportImportPresets", json!({"kinds":["brushes"]})).unwrap(); + assert_eq!(only_brushes["tools"], 0); + let with_builtins = source.execute("edit.presets.exportImportPresets", json!({"kinds":["tools"], "includeBuiltins":true})).unwrap(); + assert_eq!(with_builtins["tools"].as_u64().unwrap() as usize, source.presets.tool_presets.len()); +} + +#[test] +fn tool_preset_import_is_validated_before_mutation_and_accepts_old_files() { + let mut s = Session::new(); + let before = s.presets.tool_presets.clone(); + for preset in [ + json!({"name":"", "tool":"crop", "options":{}}), + json!({"name":"x".repeat(256), "tool":"crop", "options":{}}), + json!({"name":"Crop", "tool":" ", "options":{}}), + json!({"name":"Crop", "tool":"crop", "options":[]}), + ] { + let result = s.execute( + "edit.presets.exportImportPresets", + json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":[{"name":"Valid", "tool":"crop", "options":{}}, preset]}}), + ); + assert!(result.is_err()); + assert_eq!(s.presets.tool_presets, before); + } + let too_many: Vec<_> = (0..crate::presets::tools::MAX_PRESETS + 1).map(|i| json!({"name":format!("Crop {i}"), "tool":"crop", "options":{}})).collect(); + assert!(s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":too_many}})).is_err()); + assert_eq!(s.presets.tool_presets, before); + let result = s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "version":1}})).unwrap(); + assert_eq!(result["tools"], 0); +} diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 796a66623e..2746c1576e 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -37,7 +37,7 @@ impl Named for ToolPreset { pub const MAX_PRESETS: usize = 1000; /// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters. -fn checked_name(name: &str, cmd: &str) -> Result { +pub(crate) fn checked_name(name: &str, cmd: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err(bad(cmd, "the preset name is empty")); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 4156e380cd..7857b0f8f7 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -916,7 +916,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V Some(Ok(json!({"dialog": open_kind(app, "presets", "Preset Manager", json!({"kind": "brushes", "selected": 0, "newName": ""}))}))) } "edit.presets.exportImportPresets" => Some(Ok( - json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true}))}), + json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true, "tools": true}))}), )), _ => None, } @@ -2318,7 +2318,7 @@ fn presets_io_body(ui: &mut egui::Ui, f: &mut Map) { 180.0, ); }); - for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes"))] { + for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes")), ("tools", tl!("Tool Presets"))] { let mut on = f.get(k).and_then(Value::as_bool).unwrap_or(true); crate::widgets::checkbox(ui, &mut on, label); f.insert(k.into(), json!(on)); @@ -2442,7 +2442,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result Ok(Value::Null), "presetsIO" => { - let kinds: Vec<&str> = ["brushes", "customShapes"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); + let kinds: Vec<&str> = ["brushes", "customShapes", "tools"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); if f.get("action").and_then(Value::as_str) == Some("import") { app.pick_file_bytes(move |app, name, bytes| { let text = String::from_utf8(bytes).map_err(|_| format!("{name} is not a preset file"))?; @@ -3551,6 +3551,23 @@ mod tests { assert!(store2.lock().unwrap().as_ref().unwrap().contains("classic")); } + #[test] + fn preset_export_dialog_offers_tool_presets() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let opened = crate::menus::invoke(&mut app, &ctx, "edit.presets.exportImportPresets", json!({})).unwrap(); + let fields = app.ui.dialog_mut(opened["dialog"].as_u64().unwrap()).unwrap().fields.clone(); + assert_eq!(fields["tools"], true); + let mut h = Harness::builder().with_size(vec2(420.0, 260.0)).build_ui_state(presets_io_body, fields); + h.run_steps(2); + h.get_by_label("Tool Presets").click(); + h.run_steps(2); + assert_eq!(h.state()["tools"], false); + assert_eq!(h.state()["brushes"], true); + assert_eq!(h.state()["customShapes"], true); + } + #[test] fn brush_preset_store_attaches_when_loaded_and_persists() { use photocraft_engine::preset_store::{MemBackend, open}; From 7efaa504559a093e29b03d1d40c39f06a07e3029 Mon Sep 17 00:00:00 2001 From: codexistence Date: Mon, 19 Oct 2026 13:04:20 -0400 Subject: [PATCH 0861/1452] fix: match Photoshop Gradient Fill scale limits (#6797) --- crates/engine/src/gradient_fill_cmds.rs | 9 +++--- crates/engine/src/gradient_fill_cmds/tests.rs | 28 ++++++++++--------- crates/engine/src/presets/gradients.rs | 4 +-- .../src/gradient_ui/properties_panel.rs | 3 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..356d2252db 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,9 +28,10 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -149,7 +150,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index efcdb494d9..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..2a700aad99 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -604,7 +604,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -696,7 +696,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index 39b961ee20..d975102539 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -49,7 +49,8 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "angle": value})); } - if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, 10.0..=150.0, "%") { + let (min, max) = cmds::SCALE_RANGE; + if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, min * 100.0..=max * 100.0, "%") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "scale": value})); } ui.horizontal_wrapped(|ui| { From 566dbb3f83ecf03c093a0d8219e2123865d577a6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:26 +0200 Subject: [PATCH 0862/1452] Histogram: count the document's pixels, not a blurred thumbnail (#6538) (#6724) The Histogram panel binned a 384 px area-averaged thumbnail, so the cell holding a black/white edge came out mid-grey: a spike near 128 and slightly wrong Mean/Std Dev. Documents up to PROXY_THUMBNAIL_PIXELS are now composited in bands and every pixel is counted (Cache Level 1, as Photoshop's uncached histogram); larger ones count a nearest-neighbour proxy composite, which only holds values the layers have, and report its cache level. The Pixels row shows the pixels actually counted. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/tone.rs | 186 ++++++++++++++++++++++++++++++++----- 1 file changed, 164 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index aaf742096a..f02c108dbb 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -24,6 +24,9 @@ static EMPTY: [u32; 256] = [0; 256]; pub struct Histograms { pub tag: u64, pub ch: Vec<[u32; 256]>, + /// Photoshop's histogram cache level of a composite histogram: 1 when every document pixel + /// was counted, `n` when a proxy 2^(n-1) times smaller stood in; 0 for a gray plane's. + pub level: u32, } impl std::ops::Index for Histograms { @@ -76,6 +79,7 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut level = 0; let mut h = vec![ [0u32; 256]; match space { @@ -100,14 +104,23 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) } } HistSource::BelowLayer(_) | HistSource::Layer(_) => { - composite_bins(doc, source, space, &mut h); + level = composite_bins(doc, source, space, &mut h); } } - Histograms { tag: tag(source, space), ch: h } + Histograms { tag: tag(source, space), ch: h, level } } /// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. -fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) { +/// Returns the cache level counted: 1 when every document pixel was counted, higher when a +/// downsampled proxy stood in (like Photoshop's histogram cache levels). +/// +/// The pixels are counted as they are, never blended: an area-averaged thumbnail invented greys +/// that aren't in the image wherever an edge fell inside one of its cells (#6538). Documents up to +/// [`photocraft_compose::PROXY_THUMBNAIL_PIXELS`] (and those a proxy can't reduce faithfully) are +/// composited in bands and counted exactly, which costs what the thumbnail did (it reduced the +/// same full composite). Larger ones count a nearest-neighbour proxy composite of about the old +/// thumbnail's work: it only holds values the layers have. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) -> u32 { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -120,33 +133,87 @@ fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut } HistSource::Channel { .. } => {} } - let img = photocraft_compose::thumbnail(&d, 384); + let longest = d.size.width.max(d.size.height); + let k = longest / 768; + let (d, level) = if k >= 2 && d.size.area() > photocraft_compose::PROXY_THUMBNAIL_PIXELS && photocraft_compose::proxy::proxy_faithful(&d) { + (photocraft_compose::proxy::proxy_document(&d, k), k.next_power_of_two().ilog2() + 1) + } else { + (d, 1) + }; + let _ = photocraft_compose::render_bands(&d, d.bounds(), 0, |band| -> Result<(), ()> { + #[cfg(not(target_arch = "wasm32"))] + let part = { + use rayon::prelude::*; + band.px + .par_chunks(64 * 1024) + .map(|px| { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(px, space, &mut part); + part + }) + .reduce(|| vec![[0u32; 256]; h.len()], add_bins) + }; + #[cfg(target_arch = "wasm32")] + let part = { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(&band.px, space, &mut part); + part + }; + for (dst, src) in h.iter_mut().zip(&part) { + for (a, b) in dst.iter_mut().zip(src) { + *a = a.saturating_add(*b); + } + } + Ok(()) + }); + level +} + +/// Bin-wise sum of two sets of histograms (saturating: a hostile canvas can exceed `u32`). +#[cfg(not(target_arch = "wasm32"))] +fn add_bins(mut a: Vec<[u32; 256]>, b: Vec<[u32; 256]>) -> Vec<[u32; 256]> { + for (dst, src) in a.iter_mut().zip(&b) { + for (x, y) in dst.iter_mut().zip(src) { + *x = x.saturating_add(*y); + } + } + a +} + +/// Counts straight-alpha composite pixels into `h` (one histogram per channel of `space`); +/// transparent pixels are skipped. +fn count_pixels(px: &[[f32; 4]], space: ToneSpace, h: &mut [[u32; 256]]) { let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; - for p in img.pixels.as_chunks::<4>().0 { - if p[3] == 0 { + let mut add = |c: usize, b: usize| { + if let Some(n) = h.get_mut(c).and_then(|ch| ch.get_mut(b)) { + *n = n.saturating_add(1); + } + }; + for p in px { + // Skips NaN alpha too. + if p[3].is_nan() || p[3] <= 0.0 { continue; } match space { ToneSpace::Rgb => { - for c in 0..3 { - h[c + 1][p[c] as usize] += 1; + let q = [p[0], p[1], p[2]].map(bin); + for (c, v) in q.iter().enumerate() { + add(c + 1, *v); } - h[0][((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as usize] += 1; + add(0, (q[0] + q[1] + q[2]) / 3); } ToneSpace::Cmyk => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let ink = photocraft_color::convert::rgb_to_cmyk(rgb); + let ink = photocraft_color::convert::rgb_to_cmyk([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); for (c, v) in ink.iter().enumerate() { - h[c + 1][bin(1.0 - v)] += 1; + add(c + 1, bin(1.0 - v)); } - h[0][bin(1.0 - ink.iter().sum::() / 4.0)] += 1; + add(0, bin(1.0 - ink.iter().sum::() / 4.0)); } ToneSpace::Lab => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let l = photocraft_color::convert::srgb_to_lab(rgb); - h[0][bin(l[0] / 100.0)] += 1; - h[1][bin((l[1] + 128.0) / 255.0)] += 1; - h[2][bin((l[2] + 128.0) / 255.0)] += 1; + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); + add(0, bin(l[0] / 100.0)); + add(1, bin((l[1] + 128.0) / 255.0)); + add(2, bin((l[2] + 128.0) / 255.0)); } } } @@ -351,9 +418,8 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } let Some((_, _, _, h)) = app.doc_hist.clone() else { return }; - let size = app.session.active().map_or(photocraft_doc::Size::new(0, 0), |s| s.doc.size); - // Statistics come from a downsampled cache (like Photoshop's cache levels). - let level = (size.width.max(size.height) as f32 / 384.0).max(1.0).log2().ceil() as u32 + 1; + // 1 when the statistics count every pixel; higher when a downsampled proxy stood in. + let level = h.level.max(1); let ch = channel_picker(ui, egui::Id::new("histogram-panel-ch"), "Channel:"); let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 100.0), Sense::hover()); @@ -383,7 +449,7 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { (tl!("Mean:"), format!("{mean:.2}")), (tl!("Std Dev:"), format!("{:.2}", var.sqrt())), (tl!("Median:"), median.unwrap_or(0).to_string()), - (tl!("Pixels:"), (size.width as u64 * size.height as u64).to_string()), + (tl!("Pixels:"), n.to_string()), (tl!("Cache Level:"), level.to_string()), ] { ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); @@ -486,6 +552,82 @@ mod tests { let tiny = (h[128] as f32 / top).min(1.0); assert!(tiny < 0.05, "tiny bin draws near-flat, got {tiny}"); } + + /// A white document with black filled over columns `0..split` (a layer above the background). + fn split_doc(w: u32, h: u32, split: u32, depth: Option) -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + let mut p = json!({"width": w, "height": h}); + if let Some(bits) = depth { + p["depth"] = json!(bits); + } + s.execute("file.new", p).unwrap(); // white + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": split, "height": h})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s + } + + fn panel_histograms(s: &photocraft_engine::Session) -> Histograms { + compute_histograms(&s.active().unwrap().doc, HistSource::BelowLayer(LayerId(u64::MAX)), ToneSpace::Rgb) + } + + fn mean(h: &[u32; 256]) -> f64 { + let n: u64 = h.iter().map(|v| u64::from(*v)).sum(); + h.iter().enumerate().map(|(i, v)| i as f64 * f64::from(*v)).sum::() / n as f64 + } + + /// #6538: a black/white split counts only black and white, wherever the edge falls (the old + /// 384 px area-averaged thumbnail turned the cell holding the edge mid-grey), and counts + /// every pixel: cache level 1. + #[test] + fn split_image_histogram_counts_only_black_and_white() { + for (w, h, split) in [(1000, 1000, 334), (1000, 1000, 500), (1000, 600, 1), (777, 333, 389), (2000, 50, 1999), (64, 64, 32)] { + let s = split_doc(w, h, split, None); + let hist = panel_histograms(&s); + assert_eq!(hist.level, 1, "{w}x{h}: every pixel is counted"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{w}x{h} split {split}, channel {c}: invented values {stray:?}"); + assert_eq!(u64::from(hist[c][0]), u64::from(split) * u64::from(h), "{w}x{h} split {split}: black pixels"); + assert_eq!(u64::from(hist[c][255]), u64::from(w - split) * u64::from(h), "{w}x{h} split {split}: white pixels"); + } + } + let half = panel_histograms(&split_doc(1000, 1000, 500, None)); + assert!((mean(&half[0]) - 127.5).abs() < 1e-9, "a 50/50 split has mean 127.5, got {}", mean(&half[0])); + } + + /// The same at 16 and 32 bits per channel: the count does not go through an 8-bit thumbnail. + #[test] + fn split_image_histogram_is_exact_at_high_bit_depths() { + for bits in [16, 32] { + let s = split_doc(1000, 400, 334, Some(bits)); + assert_eq!(s.active().unwrap().doc.depth.bits(), bits, "the document is {bits}-bit"); + let hist = panel_histograms(&s); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{bits}-bit channel {c}: invented values {stray:?}"); + assert_eq!(hist[c][0], 334 * 400); + assert_eq!(hist[c][255], 666 * 400); + } + } + } + + /// Above the exact-count size a nearest-neighbour proxy stands in (cache level > 1): it is + /// smaller, yet still only holds black and white. + #[test] + fn large_split_image_counts_a_nearest_proxy_without_inventing_greys() { + let s = split_doc(6000, 3000, 2001, None); + let hist = panel_histograms(&s); + assert!(hist.level > 1, "a proxy stood in, level {}", hist.level); + let n: u64 = hist[0].iter().map(|v| u64::from(*v)).sum(); + assert!(n > 0 && n < 6000 * 3000, "counted {n} proxy pixels"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "channel {c}: invented values {stray:?}"); + } + assert!((mean(&hist[0]) - 255.0 * 3999.0 / 6000.0).abs() < 1.0, "mean {}", mean(&hist[0])); + } } #[cfg(test)] From bf321f0f5e1b7487d739017564f58bb5b93f1c1b Mon Sep 17 00:00:00 2001 From: Perchenko Anton <101743951+PerchAnton@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:04:32 +0300 Subject: [PATCH 0863/1452] Affinity: bound compound geometry expansion and reject cycles (#6486) * fix(affinity): bound compound geometry expansion and reject cycles * fix(affinity): charge every compound operand slot before filtering --- crates/affinity/README.md | 7 + crates/affinity/src/geometry.rs | 110 ++++++++++-- crates/affinity/src/model.rs | 12 +- crates/affinity/tests/compound_limits.rs | 212 +++++++++++++++++++++++ 4 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 crates/affinity/tests/compound_limits.rs diff --git a/crates/affinity/README.md b/crates/affinity/README.md index e881352c8b..535c3736c6 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -144,6 +144,13 @@ stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. +Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across +the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot +visits (including nulls and non-object values), and four million points created or copied into +compounds. Operand slots are charged before filtering, without first collecting an operand list. +A compound that crosses a limit or contains a cycle is omitted +with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform. + ## Privacy Affinity documents can hold the folder they were saved in, user names, original image paths and diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 08554134db..6c8ea602c9 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value}; /// Nodes per curve object. const MAX_NODES: usize = 4_000_000; +const MAX_COMPOUND_DEPTH: usize = 64; +const MAX_OUTLINE_VISITS: usize = 500_000; +const MAX_OUTLINE_POINTS: usize = 4_000_000; + +/// Geometry is expanded per occurrence, since parametric outlines can depend on their world +/// transform. Bound work across the entire document instead of memoizing by object id alone. +pub(crate) struct Budget { + visits_left: usize, + points_left: usize, +} + +impl Default for Budget { + fn default() -> Self { + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + } +} + +impl Budget { + /// Charge an outline call or a raw compound operand slot before inspecting it. + fn visit(&mut self) -> Result<(), &'static str> { + self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?; + Ok(()) + } + + /// Charge both leaf geometry and each copy into a compound before allocating that copy. + fn path(&mut self, path: &Path) -> Result<(), &'static str> { + let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); + let Some(left) = self.points_left.checked_sub(points) else { + // Do not rebuild an expensive leaf for every subsequent root after exhaustion. + self.visits_left = 0; + return Err("compound geometry budget exhausted"); + }; + self.points_left = left; + Ok(()) + } +} /// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. /// Instances (master-page or symbol copies) take the geometry of their master. pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { + match outline_inner(r, id, class, world, &mut Vec::new()) { + Ok(path) => path, + Err(reason) => { + r.warn(reason); + None + } + } +} + +fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec) -> Result, &'static str> { + r.geometry_budget.visit()?; + if active.contains(&id) { + return Err("cyclic compound shape"); + } + // Independent of the layer-tree limit: Comp operands do not pass through Reader::node. + if active.len() >= MAX_COMPOUND_DEPTH { + return Err("compound depth exceeds 64 levels"); + } + if class != Tag::of(b"Comp") { + let path = leaf_outline(r, id, class, world); + if let Some((path, _)) = &path { + r.geometry_budget.path(path)?; + } + return Ok(path); + } + active.push(id); + let result = compound(r, id, world, active); + active.pop(); + result +} + +fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec) -> Result, &'static str> { + let s = r.s; + // One path of all operands' contours, filled even-odd, as before. A safety failure rejects + // the whole outline: a partial add/subtract shape could silently change its meaning. + let mut path = Path::default(); + // Charge every slot before filtering nulls or malformed values. Stream::objs would scan + // and allocate the whole list first, repeating uncharged work for each shared occurrence. + let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] }; + for child in children { + r.geometry_budget.visit()?; + let Value::Obj(Some(c)) = child else { continue }; + let c = *c; + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let Some(class) = s.class(c) else { return Ok(None) }; + if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { + r.warn("compound shapes that intersect or exclude (imported as add)"); + } + if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? { + r.geometry_budget.path(&p)?; + path.subpaths.extend(p.transformed(local).subpaths); + } + } + Ok(Some((path, true))) +} + +fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { let s = r.s; match &class.0.to_be_bytes() { b"PCrv" | b"TxtC" => { @@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O let multi = path.subpaths.len() > 1; Some((path, multi)) } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - if let Some((p, _)) = outline(r, c, class, local.then(world)) { - path.subpaths.extend(p.transformed(local).subpaths); - } - } - Some((path, true)) - } _ => { let (shape, box_owner) = match s.obj(id, b"Shpe") { Some(sh) => (sh, id), diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 7fba5a4597..7c05072419 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so /// far hold). pixels_left: usize, @@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> { impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + nodes: 0, + active: HashSet::new(), + geometry_budget: crate::geometry::Budget::default(), + pixels_left: limits.max_pixel_bytes, + } } /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/compound_limits.rs new file mode 100644 index 0000000000..76f2e6e4a1 --- /dev/null +++ b/crates/affinity/tests/compound_limits.rs @@ -0,0 +1,212 @@ +//! #6423: compound operands form a graph, not necessarily a tree. +#![cfg(feature = "synth")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use photocraft_affinity::model::{Document, Kind}; +use photocraft_affinity::synth::{self, F, Method, tag}; +use photocraft_affinity::{Limits, read}; +use std::time::{Duration, Instant}; + +fn compound(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +fn rectangle() -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))]) +} + +fn document_bytes(definitions: Vec, roots: Vec) -> Vec { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))], + )]), + )], + ), + ), + ]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) +} + +fn document(definitions: Vec, roots: Vec) -> Document { + read(&document_bytes(definitions, roots), Limits::default()).unwrap() +} + +// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang) +// inside a child process, so the regression fails without killing the entire test runner. +fn isolated(name: &str, check: impl FnOnce()) { + if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) { + check(); + return; + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture"]) + .env("PHOTOCRAFT_COMPOUND_CASE", name) + .spawn() + .unwrap(); + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "compound import failed: {status}"); + break; + } + if start.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("compound import exceeded ten seconds"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn unsupported(doc: &Document, warning: &str) { + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings); +} + +#[test] +fn self_referencing_compound_is_reported_without_aborting() { + isolated("self_referencing_compound_is_reported_without_aborting", || { + let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]); + unsupported(&doc, "cyclic compound"); + assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes"); + }); +} + +#[test] +fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() { + isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || { + let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]); + unsupported(&doc, "cyclic compound"); + }); +} + +fn nested(levels: u32, repeated: bool) -> Document { + // Flat definitions bypass the stream parser's nesting cap: only references form the depth. + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..200 + levels { + let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] }; + definitions.push(compound(id, children)); + previous = id; + } + document(definitions, vec![F::Ref(previous)]) +} + +#[test] +fn deep_compound_graph_is_bounded_on_a_small_stack() { + isolated("deep_compound_graph_is_bounded_on_a_small_stack", || { + std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap(); + }); +} + +#[test] +fn shared_operands_cannot_expand_exponentially() { + isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget")); +} + +#[test] +fn shared_empty_operands_also_have_a_work_limit() { + isolated("shared_empty_operands_also_have_a_work_limit", || { + let mut definitions = vec![compound(100, vec![])]; + let mut previous = 100; + for id in 200..240 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget"); + }); +} + +#[test] +fn geometry_budget_is_shared_across_layer_roots() { + isolated("geometry_budget_is_shared_across_layer_roots", || { + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..214 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + let mut roots = vec![F::Ref(previous); 12]; + roots.push(F::Ref(100)); + let doc = document(definitions, roots); + let nodes = &doc.spreads[0].nodes; + assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports"); + assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget"); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn ordinary_shared_operands_keep_both_transformed_copies() { + let mut translated = compound(201, vec![F::Ref(100)]); + let F::Def(_, _, fields) = &mut translated else { unreachable!() }; + fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0]))); + let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) { + assert_eq!(b.x, a.x + 30.0); + assert_eq!(b.y, a.y + 40.0); + } +} + +#[test] +fn null_operand_slots_cannot_bypass_geometry_budget() { + isolated("null_operand_slots_cannot_bypass_geometry_budget", || { + let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]); + let start = Instant::now(); + let doc = read(&bytes, Limits::default()).unwrap(); + eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64()); + unsupported(&doc, "compound geometry budget"); + assert_eq!(doc.spreads[0].nodes.len(), 20_000); + assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported))); + }); +} + +#[test] +fn null_operand_slots_share_budget_across_layer_roots() { + isolated("null_operand_slots_share_budget_across_layer_roots", || { + let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid"); + assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn null_operand_slots_cannot_leave_a_partial_shape() { + isolated("null_operand_slots_cannot_leave_a_partial_shape", || { + let mut slots = vec![F::Ref(100)]; + slots.extend(vec![F::Null; 1_000_000]); + let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]); + unsupported(&doc, "compound geometry budget"); + }); +} + +#[test] +fn non_object_operand_slots_also_consume_budget() { + isolated("non_object_operand_slots_also_consume_budget", || { + let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]); + unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget"); + }); +} + +#[test] +fn ordinary_null_operand_slots_preserve_shared_contours() { + let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], path.subpaths[1]); +} From ac78e0bbcad7111a542f3ac890825a2e97237d5e Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:04:38 -0400 Subject: [PATCH 0864/1452] Color Settings: the working-space menus list the installed profiles of their space (#6323) (#6359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Color Settings: the working-space menus list the installed profiles of their space (#6323) Like Convert to Profile and Proof Setup, Edit › Color Settings' RGB, CMYK and Gray working-space menus now list the profiles installed in the system after the built-ins, by description, so a press condition (GRACoL, SWOP, FOGRA39) can be the CMYK working space as in Photoshop. Each menu lists only profiles of its colour space; RGB lists display and colour-space profiles, not scanner or printer ones. The engine already took .icc paths for these settings. Co-Authored-By: Claude Opus 5.5 * rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/filter_dialog.rs | 65 +++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index d09caf4953..14f24bc52e 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; +use photocraft_engine::installed_profiles::InstalledProfile; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -632,6 +633,29 @@ fn channel_index(v: &Value, names: &[String]) -> Option { /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; +/// Which installed profiles a menu of `cmd` lists after its built-ins, if it lists any: every +/// destination profile for the `profile` menus of [`PROFILE_DIALOGS`], and for Color Settings' +/// working spaces those of that space, as Photoshop lists them (#6323). An RGB working space is a +/// display or colour-space profile, not a scanner's or a printer's. +fn installed_filter(cmd: &str, key: &str) -> Option bool> { + use photocraft_cms::{ColorSpace, ProfileClass}; + match (cmd, key) { + (c, "profile") if PROFILE_DIALOGS.contains(&c) => Some(|_| true), + ("edit.colorSettings", "workingRgb") => { + Some(|i| i.color_space == ColorSpace::Rgb && matches!(i.class, ProfileClass::Display | ProfileClass::ColorSpace)) + } + ("edit.colorSettings", "workingCmyk") => Some(|i| i.color_space == ColorSpace::Cmyk), + ("edit.colorSettings", "workingGray") => Some(|i| i.color_space == ColorSpace::Gray), + _ => None, + } +} + +/// The `installed` profiles a menu of `cmd` lists (see [`installed_filter`]), by path and description. +fn installed_choices(cmd: &str, key: &str, installed: &[InstalledProfile]) -> Vec<(String, String)> { + let Some(fits) = installed_filter(cmd, key) else { return Vec::new() }; + installed.iter().filter(|i| i.is_destination() && fits(i)).map(|i| (i.path.clone(), i.description.clone())).collect() +} + /// Commands whose `angle` is a direction in document angles (counter-clockwise from three /// o'clock): a light or a blur direction, edited on a dial as in Photoshop's Motion Blur, Emboss /// and Global Light (#3152). Rotation amounts (Twirl, Rotate Arbitrary, Crop, Lens Correction, @@ -819,14 +843,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { cur = channel_choice(i); } } - // Like Photoshop, the profile menus also list every profile installed in - // the system (printer and paper profiles), by description. - let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { - photocraft_engine::installed_profiles::installed().into_iter().filter(|i| i.is_destination()).collect() - } else { - Vec::new() - }; - opts.extend(installed.iter().map(|i| (i.path.clone(), i.description.as_str()))); + // Like Photoshop, the profile menus also list the profiles installed in the + // system (printer and paper profiles, press conditions), by description. + let lists_installed = installed_filter(&cmd, &p.key).is_some(); + let installed = + if lists_installed { installed_choices(&cmd, &p.key, &photocraft_engine::installed_profiles::installed()) } else { Vec::new() }; + opts.extend(installed.iter().map(|(path, description)| (path.clone(), description.as_str()))); // Assign Profile: only what the document's colour mode can take (#4951). if p.key == "profile" && let Some(allowed) = f.get("__profiles").and_then(Value::as_array) @@ -836,7 +858,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // A profile given by path that isn't listed (typed by an agent, or spelled with // different case) still shows, by its file name, instead of a blank menu. let stem = std::path::Path::new(&cur).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) && !opts.iter().any(|(v, _)| *v == cur) { + if lists_installed && !opts.iter().any(|(v, _)| *v == cur) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); @@ -1150,6 +1172,31 @@ impl FilterPreviewKey { mod tests { use super::*; + #[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 + // Photoshop's Color Settings; each working-space menu lists only profiles of its space. + use photocraft_cms::{ColorSpace, ProfileClass}; + let p = |path: &str, class, color_space| InstalledProfile { path: path.into(), description: path.to_uppercase(), class, color_space }; + let installed = [ + p("gracol.icc", ProfileClass::Output, ColorSpace::Cmyk), + p("monitor.icc", ProfileClass::Display, ColorSpace::Rgb), + p("printer-rgb.icc", ProfileClass::Output, ColorSpace::Rgb), + p("scanner.icc", ProfileClass::Input, ColorSpace::Rgb), + p("gray.icc", ProfileClass::Output, ColorSpace::Gray), + p("link.icc", ProfileClass::DeviceLink, ColorSpace::Cmyk), + ]; + let paths = |key: &str| installed_choices("edit.colorSettings", key, &installed).into_iter().map(|(path, _)| path).collect::>(); + assert_eq!(paths("workingCmyk"), ["gracol.icc"]); + assert_eq!(paths("workingRgb"), ["monitor.icc"]); + assert_eq!(paths("workingGray"), ["gray.icc"]); + assert!(paths("policyCmyk").is_empty() && paths("intent").is_empty()); + assert_eq!(installed_choices("edit.colorSettings", "workingCmyk", &installed)[0].1, "GRACOL.ICC", "listed by description"); + // The profile dialogs keep listing every destination profile. + assert_eq!(installed_choices("edit.convertToProfile", "profile", &installed).len(), 5); + assert!(installed_choices("filter.blur.gaussianBlur", "profile", &installed).is_empty()); + } + /// #5710: the seed field's Random button always gives a different seed the field can hold. #[test] fn random_seeds_stay_in_range_and_change() { From bc4f2a783d15e20c1d147e5593c03d10984d8348 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:46 +0200 Subject: [PATCH 0865/1452] Scripts: Flatten All Layer Effects bakes with the document's Global Light and patterns (#5719) (#5720) `bake` rendered each layer through `render_layer`, which has no document context (default Global Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 25 ++++++++++ crates/engine/src/file_cmds.rs | 20 ++++---- crates/engine/src/file_cmds/tests.rs | 68 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..e35d5ad402 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -349,6 +349,31 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { buf } +/// [`render_layer`] in `doc`'s context: the layer alone over `rect`, with the document's patterns, +/// Global Light, colour mode, depth and canvas (fill layers and gradients are laid out on it), as +/// the canvas composites it. Bakes (Scripts › Flatten All Layer Effects) render through this so +/// the pixels match what was on screen (#5719). Rects past [`MAX_RENDER_PIXELS`] give an empty +/// buffer. +pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { + if rect.is_empty() { + return Buffer::transparent(rect); + } + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + // A lone layer: a deep knockout reveals transparency, not the document's Background. + let cx = Ctx { doc: None, ..Ctx::for_doc(doc, &patterns) }; + let lab = doc.mode == photocraft_color::ColorMode::Lab; + let hdr = doc.depth == photocraft_color::SampleType::F32; + let cmyk = cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let mut buf = Buffer::transparent(rect); + in_blend_space(lab, hdr, || composite_stack_at(std::slice::from_ref(layer), &mut buf, &cx, advanced::Scope::ROOT)); + buf + }) +} + /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20; diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 1fb3fcf300..2299aa3e49 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1293,10 +1293,12 @@ fn has_live_effects(l: &Layer) -> bool { l.effects.enabled && l.effects.items.iter().any(|e| e.enabled()) } -/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask` -/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them -/// live on the baked raster does not mask the pixels twice. -fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface { +/// The layer of `doc` rendered alone (keeping what `keep` says) with the document's Global Light, +/// patterns and canvas, as a surface in the document's format. With `apply_mask` off the layer's +/// pixel and vector masks are left out of the render, so a caller that keeps them live on the +/// baked raster does not mask the pixels twice. +fn bake(doc: &Document, l: &Layer, keep_effects: bool, apply_mask: bool) -> Surface { + let canvas = doc.bounds(); let mut tmp = l.clone(); tmp.opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; @@ -1313,7 +1315,7 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mas // Render over the union of the canvas and the content so off-canvas pixels survive. let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)); let area = if keep_effects { area.inflate(256) } else { area }; - buffer_surface(&photocraft_compose::render_layer(&tmp, area), fmt) + buffer_surface(&photocraft_compose::render_layer_in(doc, &tmp, area), doc.pixel_format()) } fn walk_ids(doc: &Document) -> Vec { @@ -1323,8 +1325,6 @@ fn walk_ids(doc: &Document) -> Vec { fn flatten_all_effects(s: &mut Session) -> Result { let mut n = 0; s.edit("Flatten All Layer Effects", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { @@ -1332,7 +1332,7 @@ fn flatten_all_effects(s: &mut Session) -> Result { } // The pixel and vector masks stay on the layer (baking must not destroy them), so the // pixels are baked without them and each mask then applies once to the baked raster. - let px = bake(l, canvas, fmt, true, false); + let px = bake(doc, l, true, false); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.effects = Default::default(); @@ -1349,8 +1349,6 @@ fn flatten_all_effects(s: &mut Session) -> Result { fn flatten_all_masks(s: &mut Session) -> Result { let (mut n, mut skipped) = (0, 0); s.edit("Flatten All Masks", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; let masked = l.mask.as_ref().is_some_and(|m| m.enabled) || l.vector_mask.is_some(); @@ -1363,7 +1361,7 @@ fn flatten_all_masks(s: &mut Session) -> Result { skipped += 1; continue; } - let px = bake(l, canvas, fmt, false, true); + let px = bake(doc, l, false, true); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.mask = None; diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3ab50c4a77..b07c649850 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -747,6 +747,74 @@ fn flatten_all_layer_effects_keeps_masks() { } } +/// Flattens all layer effects on a red square carrying `fx` (in a document prepared by `setup`) +/// and asserts the composite did not change: the bake uses the document's own context (#5719). +fn assert_flatten_effects_is_a_no_op(depth: u32, setup: impl FnOnce(&mut Document), fx: photocraft_doc::Effect) { + let mut s = session(40, 40, depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 14, "y": 14, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + setup(doc); + let l = doc.layer_mut(id).unwrap(); + l.effects.enabled = true; + l.effects.items.push(fx); + Ok(()) + }) + .unwrap(); + let before = photocraft_compose::flatten(doc(&s)); + assert_eq!(s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap()["flattened"], 1); + let after = photocraft_compose::flatten(doc(&s)); + for (i, (a, b)) in after.px.iter().zip(&before.px).enumerate() { + for k in 0..4 { + assert!((a[k] - b[k]).abs() < 0.02, "depth {depth}: pixel ({}, {}) changed: {b:?} -> {a:?}", i % 40, i / 40); + } + } +} + +/// A Drop Shadow that uses Global Light is baked at the document's angle, not the default 120°. +#[test] +fn flatten_all_layer_effects_uses_the_documents_global_light() { + for depth in [8, 16, 32] { + let shadow = photocraft_doc::Effect::DropShadow(photocraft_doc::effects::Shadow { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 0.0, 1.0), + angle: 120.0, + use_global_light: true, + distance: 10.0, + spread: 0.0, + size: 0.0, + contour: photocraft_doc::effects::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }); + assert_flatten_effects_is_a_no_op(depth, |d| d.global_light.angle = 0.0, shadow); + } +} + +/// A Pattern Overlay of a document pattern survives the bake instead of vanishing. +#[test] +fn flatten_all_layer_effects_keeps_a_pattern_overlay() { + for depth in [8, 16, 32] { + let mut tile = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Rgb, photocraft_color::SampleType::U8, true)); + tile.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 1.0, 0.0, 1.0]); + let pat = photocraft_doc::Pattern::new("green", tile, 2, 2); + let overlay = photocraft_doc::Effect::PatternOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + assert_flatten_effects_is_a_no_op(depth, |d| d.patterns.push(pat), overlay); + } +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); From 5c1b9425d132bdf3075f716529834429cc9cc2f5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:52 +0200 Subject: [PATCH 0866/1452] Gradient Overlay: repaint the whole layer when a stroke moves its frame (#4053) (#5340) A Gradient Overlay (and gradient strokes, linked patterns, linked bevel textures) is laid out over the layer's bounds. Painting grows those bounds, but the stroke commands reported only the stroke as damage, and the canvas's committed-stroke path repainted only what the live stroke had not shown. The canvas therefore kept the old gradient around earlier strokes: one gradient per stroke instead of one over the whole layer. Session dispatch now grows a command's reported damage by photocraft_compose::relayout_damage(before, after): the whole composite of any visible layer whose effects span its frame, when that frame moved. The canvas adds the same area to a committed live stroke. Strokes inside the existing bounds and frame-independent effects keep tight damage. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/effects.rs | 19 ++++ crates/compose/src/lib.rs | 54 +++++++---- crates/compose/src/tests.rs | 28 ++++++ crates/engine/src/jobs.rs | 17 ++++ .../engine/tests/gradient_overlay_strokes.rs | 89 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 29 ++++-- crates/ui-egui/src/stroke_timing_tests.rs | 32 +++++++ 7 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/gradient_overlay_strokes.rs diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..20657a2535 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -37,6 +37,25 @@ pub fn has_effects(layer: &Layer) -> bool { layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled) } +/// Whether an enabled effect is laid out over the layer's frame ([`crate::effects_frame`]), so +/// growing or shrinking the layer repaints it everywhere, not only where its pixels changed: a +/// Gradient Overlay, a gradient stroke (its frame is the shape's extent), and patterns linked with +/// the layer without an effects reference point (overlays, pattern strokes and glows, and bevel +/// textures tile from the frame's top-left). +pub fn spans_frame(layer: &Layer) -> bool { + let unanchored = layer.effects.reference.is_none(); + let pattern = |p: &FxPaint| unanchored && matches!(p, FxPaint::Pattern { .. }); + has_effects(layer) + && layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::GradientOverlay { .. } => true, + Effect::PatternOverlay { link, .. } => *link && unanchored, + Effect::Stroke(s) => matches!(s.paint, FxPaint::Gradient(_)) || pattern(&s.paint), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => pattern(&g.paint), + Effect::BevelEmboss(b) => unanchored && b.texture.as_ref().is_some_and(|t| t.link), + _ => false, + }) +} + /// Pixels an effect stack can reach beyond the layer's shape. pub fn margin(layer: &Layer) -> i32 { let mut m = 0.0f32; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e35d5ad402..7c0ce9098e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -890,6 +890,38 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } +/// The frame a layer's effects are laid out in: [`paint_bounds`] for a shape, else the layer's +/// bounds ([`layer_bounds`]). A Gradient Overlay spans it, linked patterns tile from its corner. +pub fn effects_frame(layer: &Layer, canvas: Rect) -> Rect { + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)) +} + +/// The pixels a change from `a` to `b` repaints beyond those it changed: where a layer whose +/// effects span its frame ([`effects::spans_frame`]) moved that frame, the layer's whole composite +/// before and after. Painting on a layer with a Gradient Overlay lays the one gradient out again +/// over the grown layer, as Photoshop does after each stroke; damage that covers only the stroke +/// leaves the old gradient around earlier strokes (#4053). Empty when no such frame moved. +pub fn relayout_damage(a: &Document, b: &Document) -> Rect { + let mut out = Rect::EMPTY; + let mut add = |r: Rect| { + if !r.is_empty() { + out = if out.is_empty() { r } else { out.union(&r) }; + } + }; + for (_, _, y) in b.walk() { + if !y.visible || !effects::spans_frame(y) { + continue; + } + let Some(x) = a.layer(y.id) else { continue }; + if effects_frame(x, a.bounds()) == effects_frame(y, b.bounds()) { + continue; + } + add(change_bounds(x, a.bounds()).unwrap_or_else(|| a.bounds())); + add(change_bounds(y, b.bounds()).unwrap_or_else(|| b.bounds())); + } + out +} + /// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { @@ -1514,15 +1546,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff advanced::composite_clipped(clipped, &mut content, cx); let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - backdrop, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -1780,7 +1804,7 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s } fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> { - let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)); + let sb = effects_frame(layer, cx.canvas); effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) } } @@ -1835,15 +1859,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - &mut opaque, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..bd7d3ceba7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1082,6 +1082,34 @@ fn glow_gradient_follows_the_shape() { assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}"); } +// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the +// new pixels (#4053). Effects that don't depend on the frame repaint only what changed. +#[test] +fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { + let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { + let mut b = a.clone(); + let old = b.layers.last().unwrap().clone(); + let mut l = solid_layer("sq", rect, rgba); + (l.id, l.effects) = (old.id, old.effects); + *b.layers.last_mut().unwrap() = l; + b + }; + let a = fx_doc(vec![overlay.clone()]); + let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]); + let d = crate::relayout_damage(&a, &b); + assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}"); + // Same bounds, other pixels: the frame stays, the pixels' own damage is enough. + assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY); + // A Color Overlay doesn't depend on the frame. + let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]); + assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); + // Nor does a disabled overlay. + let mut off = a.clone(); + off.layers.last_mut().unwrap().effects.enabled = false; + assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); +} + #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b30e56da3a..5098b958b4 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -732,6 +732,7 @@ impl Session { // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let before = self.active().map(|st| st.doc.clone()); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -759,12 +760,28 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { + if let Some(before) = before { + self.add_relayout_damage(&before); + } self.after_command(id, params, spec.journal, Some(nested_from)); Ok(Started::Done(v)) } } } + /// Grows the damage a command reported for the pixels it changed by what effects laid out + /// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a + /// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out + /// again over the whole grown layer (#4053). `before` is the active document before. + fn add_relayout_damage(&mut self, before: &Arc) { + let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && !Arc::ptr_eq(&st.doc, before)) else { return }; + let Some(d) = st.last_damage.filter(|d| !d.is_empty()) else { return }; + let extra = photocraft_compose::relayout_damage(before, &st.doc); + if !extra.is_empty() { + st.last_damage = Some(d.union(&extra)); + } + } + /// A command that failed on a layer id the document doesn't have says which ids it does have /// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent /// should not need `document.inspect` to recover. diff --git a/crates/engine/tests/gradient_overlay_strokes.rs b/crates/engine/tests/gradient_overlay_strokes.rs new file mode 100644 index 0000000000..e233f8984d --- /dev/null +++ b/crates/engine/tests/gradient_overlay_strokes.rs @@ -0,0 +1,89 @@ +//! A Gradient Overlay spans one gradient over the layer's current bounds, so a stroke that grows +//! the layer re-lays the gradient out over all of it (#4053). The damage a stroke reports must +//! cover that, or a canvas that redraws only the damage keeps the old gradient around the earlier +//! strokes: one gradient per stroke instead of one over the whole layer. + +use photocraft_engine::Session; +use serde_json::json; + +/// What a canvas that redraws only each edit's damage shows: the cold render before the edit, +/// with the edit's damage (grown by the effects' reach, as the canvas grows it) rendered again. +fn redraw_damage(shown: &mut photocraft_compose::Buffer, s: &Session) { + let st = s.active().unwrap(); + let reach = st.doc.layers.iter().filter(|l| photocraft_compose::effects::has_effects(l)).map(photocraft_compose::effects::margin).max().unwrap_or(0); + let damage = st.last_damage.expect("a stroke reports bounded damage").inflate(reach).intersect(&st.doc.bounds()); + let (w, h) = (st.doc.size.width, st.doc.size.height); + let patch = photocraft_compose::render_reduced_damage(&st.doc, w, h, damage); + for y in patch.rect.y0..patch.rect.y1 { + for x in patch.rect.x0..patch.rect.x1 { + let i = (y * w as i32 + x) as usize; + shown.px[i] = patch.get(x, y); + } + } +} + +fn session(effect: &str, params: serde_json::Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 160, "height": 80, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute(effect, params).unwrap(); + s +} + +const GREY: &str = "#808080"; + +/// Each stroke redrawn over the last cold render equals a cold render after it. +fn strokes_redraw_like_a_cold_render(mut s: Session, strokes: &[serde_json::Value]) { + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + for (i, p) in strokes.iter().enumerate() { + let st = s.active().unwrap(); + let mut shown = photocraft_compose::render(&st.doc, st.doc.bounds()); + s.execute("paint.stroke", p.clone()).unwrap(); + redraw_damage(&mut shown, &s); + let st = s.active().unwrap(); + let cold = photocraft_compose::render(&st.doc, st.doc.bounds()); + let worst = shown.px.iter().zip(&cold.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "after stroke {i} the redrawn canvas differs from a cold render by {worst}"); + } +} + +#[test] +fn a_gradient_overlay_is_laid_out_again_after_each_stroke() { + let s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + strokes_redraw_like_a_cold_render( + s, + &[ + // Far from the first: the layer grows to the right and down. + json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY}), + // Up and to the left of both. + json!({"points": [[6, 6], [10, 8]], "size": 6, "hardness": 1.0, "color": GREY}), + // Erasing the far stroke shrinks the layer again. + json!({"points": [[110, 40], [150, 74]], "size": 40, "hardness": 1.0, "erase": true}), + ], + ); +} + +#[test] +fn a_stroke_inside_the_layer_keeps_its_own_damage() { + let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + // Within the layer's bounds: the gradient's frame stays, so only the stroke repaints. + let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap(); + let d = &r["damage"]; + let reported = photocraft_geom::Rect::new( + d[0].as_i64().unwrap() as i32, + d[1].as_i64().unwrap() as i32, + (d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32, + (d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32, + ); + assert_eq!(s.active().unwrap().last_damage, Some(reported)); +} + +#[test] +fn effects_not_laid_out_over_the_layer_keep_the_stroke_damage() { + let mut s = session("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})); + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + s.execute("paint.stroke", json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + let d = s.active().unwrap().last_damage.unwrap(); + assert!(d.x0 >= 100, "a Color Overlay repaints only the stroke: {d:?}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..474864746b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -516,12 +516,22 @@ pub(crate) struct CommittedStroke { pub(crate) revision: u64, key: u64, damage: Vec, + /// What the commit repaints beyond the stroke whatever a cache showed: the layer's whole + /// composite when effects laid out over its frame moved with it (a Gradient Overlay spans the + /// grown layer, #4053; `photocraft_compose::relayout_damage`). + relayout: DRect, } impl CommittedStroke { #[cfg(test)] pub(crate) fn for_test(key: u64, damage: Vec) -> Self { - Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage } + Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage, relayout: DRect::EMPTY } + } + + /// The raw preview key of the live stroke's last step: what a cache showed at release. + #[cfg(test)] + pub(crate) fn last_key(&self) -> u64 { + self.key + self.damage.len() as u64 } /// What a cache that showed raw preview `key` of this stroke at the revision before misses. @@ -1472,6 +1482,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no && let Some(c) = app.committed_stroke.as_ref().filter(|c| c.doc == st.doc.id && c.revision == now.0) && let Some(r) = c.since(seen.1 ^ display_key) { + let r = if c.relayout.is_empty() { + r + } else if r.is_empty() { + c.relayout + } else { + r.union(&c.relayout) + }; return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 { @@ -5845,6 +5862,7 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } let LiveStroke { stroke, doc, key, damage, .. } = l; + let before = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.doc.clone()); if let EngineStroke::Brush(stroke) = stroke && app.session.offer_live_stroke(*stroke).is_err() { @@ -5855,14 +5873,15 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); app.session.clear_live_stroke(); let Ok(r) = result else { return }; - let revision = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.revision); + let after = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| (st.revision, st.doc.clone())); if r.get("fromLive").and_then(serde_json::Value::as_bool) == Some(true) - && let Some(revision) = revision + && let Some((revision, after)) = after { - app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage }); + let relayout = before.map_or(DRect::EMPTY, |b| photocraft_compose::relayout_damage(&b, &after)); + app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage, relayout }); } else { // Rendered again from scratch: the commit's damage rect refreshes the stroke. - let shown = CommittedStroke { doc, revision: 0, key, damage }; + let shown = CommittedStroke { doc, revision: 0, key, damage, relayout: DRect::EMPTY }; // Raw preview key 0 = the document itself (its colour display folded in). shown_as_document(app, doc, |k| shown.since(k).is_some()); } diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index a472a4125c..88f218c3ba 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -338,6 +338,38 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(99), None, "not a key of this stroke"); } +/// A stroke that grows a layer with a Gradient Overlay lays the one gradient out again over the +/// whole layer (#4053): the canvas, which already showed the live stroke, still repaints where +/// the earlier strokes are, not just the new stroke's own pixels (it repainted nothing, leaving +/// one gradient per stroke). +#[test] +fn a_committed_stroke_repaints_a_gradient_overlay_over_the_whole_layer() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().run("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 10, "hardness": 1.0}})).unwrap(); + let drag = |h: &mut Harness<'static, PhotocraftApp>, pts: &[(f32, f32)]| { + move_to(h, pts[0].0, pts[0].1); + button(h, pts[0].0, pts[0].1, true); + for (x, y) in &pts[1..] { + move_to(h, *x, *y); + } + let (x, y) = pts[pts.len() - 1]; + button(h, x, y, false); + h.run_steps(2); + }; + drag(&mut h, &[(20.0, 20.0), (30.0, 25.0), (40.0, 30.0)]); + drag(&mut h, &[(150.0, 60.0), (160.0, 70.0), (170.0, 80.0)]); + let app = h.state(); + let idx = app.session.active_index().unwrap(); + let st = app.session.active().unwrap(); + let c = app.committed_stroke.as_ref().expect("the commit took the live stroke's pixels"); + assert_eq!(c.revision, st.revision); + // A cache that showed the live stroke's last step. + let missed = crate::canvas::damage_since(app, idx, (st.revision - 1, c.last_key()), (st.revision, 0), 0, st.last_damage).unwrap(); + assert!(missed.contains(25, 25), "the gradient around the first stroke is repainted: {missed:?}"); +} + #[test] fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { // The app's hooks around the canvas (`raw_input_hook` starts a frame, `ui` ends it); the From f4a81d3c169a3067b8bcef91d5053332c7f3d66f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:04:59 -0600 Subject: [PATCH 0867/1452] Add Liquify's Show Backdrop Use and Mode (#7609) (#7644) * Add Liquify's Show Backdrop Use and Mode (#7609) Show Backdrop only had an Opacity: it always drew the other layers' composite behind the image, faded. Photoshop's Liquify also has Use (All Layers, or one layer) and Mode (In Front, Behind, Blend). Use and Mode are now in the View Options and the control channel (backdropUse, backdropMode), and each mode layers the backdrop and the image as Photoshop's does: In Front lays the backdrop over the image at the Opacity, Behind the image over the backdrop, Blend fades the image to 100 - Opacity under the backdrop. All Layers is the document as it is, as in Photoshop. Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: show a hidden Use layer, forget the layer between documents A layer picked in Use, or one in a hidden group, now shows: the one-layer backdrop makes it and its groups visible, as Photoshop does. The remembered settings keep the backdrop Mode and Opacity but no longer a layer id, which belongs to one document: Liquify opens on All Layers, as Photoshop's does. Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: a control-channel Use naming no layer is All Layers Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: normalize an unknown Use even when another setting fails Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/liquify_ui.rs | 195 ++++++++++++++++++++++++--- crates/ui-egui/src/transform_tool.rs | 8 +- 22 files changed, 249 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7da5cbc30e..9cf6344d24 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3839,3 +3839,6 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Use Použít + In Front Vpředu + Behind Vzadu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6faa41f3ea..627c9eced5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3231,3 +3231,7 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Use Verwenden + In Front Davor + Behind Dahinter + Blend Mischen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ac3b9fbd36..4cd75535d8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3931,3 +3931,6 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Use Χρήση + In Front Μπροστά + Behind Πίσω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b2e63bbc84..a48e224832 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4193,3 +4193,6 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Use Usar + In Front Delante + Behind Detrás diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2acba73d50..caff033e38 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3239,3 +3239,6 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Use Utiliser + In Front Devant + Behind Derrière diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7393ba2954..c2b1e3e293 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4150,3 +4150,6 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Use Használat + In Front Elöl + Behind Mögött diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3246a9c371..9836f2262a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3226,3 +3226,6 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Use Gunakan + In Front Di Depan + Behind Di Belakang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8b664de9f3..09e20955ca 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4348,3 +4348,6 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Use Usa + In Front Davanti + Behind Dietro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c41d46a320..f43978bdfe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3908,3 +3908,6 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Use 使用 + In Front 前面 + Behind 背面 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 21ef94cff1..43dcbd7184 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3412,3 +3412,7 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Use 사용 + In Front 앞 + Behind 뒤 + Blend 혼합 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ca9253c349..2bbe405810 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3991,3 +3991,6 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Use Gebruiken + In Front Voor + Behind Achter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4f8194ccba..e086a07d93 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3830,3 +3830,6 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Use Użyj + In Front Z przodu + Behind Z tyłu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 65b81cb194..cb16fa0326 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4021,3 +4021,6 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Use Usar + In Front Na frente + Behind Atrás diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index aa40b583b9..943ebcb9c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,3 +3204,6 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Use Использовать + In Front Спереди + Behind Сзади diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 474fedaf22..389469a2fe 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4304,3 +4304,6 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Use Koristi + In Front Ispred + Behind Iza diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8fb253f142..c27af05e43 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3961,3 +3961,6 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Use Kullan + In Front Önde + Behind Arkada diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ee29fb4a08..9f68595f57 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3939,3 +3939,6 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Use Використати + In Front Спереду + Behind Ззаду diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 03cdfb972b..e6f6312760 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3718,3 +3718,6 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Use Dùng + In Front Phía trước + Behind Phía sau diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 42704b01b0..418d631a0f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2854,3 +2854,7 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Use 使用 + In Front 前面 + Behind 后面 + Blend 混合 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1e1a8f04a4..cffc27969b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2839,3 +2839,7 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Use 使用 + In Front 前面 + Behind 後面 + Blend 混合 diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..9f46020b11 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -10,7 +10,7 @@ use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, ProxyImage, auto_cell}; -use photocraft_doc::LayerId; +use photocraft_doc::{Document, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -40,10 +40,32 @@ pub struct LiquifyOpts { pub mesh_size: usize, pub show_mask: bool, pub show_backdrop: bool, + /// Show Backdrop › Use: the layer shown as the backdrop (its id), `None` for All Layers. + pub backdrop_layer: Option, + pub backdrop_mode: BackdropMode, pub backdrop_opacity: f32, pub reconstruct_amount: f32, } +/// Show Backdrop › Mode, as Photoshop draws them (#7609): In Front lays the backdrop over the +/// image at the Opacity, Behind lays the image over the backdrop at the Opacity, and Blend fades +/// the image to 100 − Opacity under the backdrop at the Opacity. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackdropMode { + #[default] + InFront, + Behind, + Blend, +} + +impl BackdropMode { + const ALL: [(BackdropMode, &'static str); 3] = [(BackdropMode::InFront, "inFront"), (BackdropMode::Behind, "behind"), (BackdropMode::Blend, "blend")]; + + fn key(self) -> &'static str { + Self::ALL.iter().find(|(m, _)| *m == self).map_or("inFront", |(_, k)| k) + } +} + impl Default for LiquifyOpts { fn default() -> Self { LiquifyOpts { @@ -56,6 +78,8 @@ impl Default for LiquifyOpts { mesh_size: 1, show_mask: true, show_backdrop: false, + backdrop_layer: None, + backdrop_mode: BackdropMode::InFront, backdrop_opacity: 50.0, reconstruct_amount: 100.0, } @@ -88,6 +112,13 @@ impl LiquifyOpts { *slot = b; } } + // `"all"` (or anything but a layer id) is All Layers. + if let Some(u) = v.get("backdropUse") { + self.backdrop_layer = u.as_u64(); + } + if let Some(m) = v.get("backdropMode").and_then(Value::as_str).and_then(|m| BackdropMode::ALL.iter().find(|(_, k)| *k == m)) { + self.backdrop_mode = m.0; + } if let Some(m) = v.get("meshSize").and_then(Value::as_str) { self.mesh_size = match m { "small" => 0, @@ -114,6 +145,8 @@ impl LiquifyOpts { "meshSize": mesh, "showMask": self.show_mask, "showBackdrop": self.show_backdrop, + "backdropUse": self.backdrop_layer.map_or(json!("all"), |id| json!(id)), + "backdropMode": self.backdrop_mode.key(), "backdropOpacity": self.backdrop_opacity, }) } @@ -143,7 +176,8 @@ pub struct LiquifyDialog { view_source_scratch: Vec<[f32; 4]>, view_tex: Option, view_dirty: Option, - backdrop: Option, + /// The backdrop and the Use it shows (see [`LiquifyOpts::backdrop_layer`]). + backdrop: Option<(Option, TextureHandle)>, mask_tex: Option, mask_dirty: bool, pub opts: LiquifyOpts, @@ -495,6 +529,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { let _ = d.opts.apply(saved); } + // Layer ids belong to one document: a remembered backdrop Use starts at All Layers. + d.opts.backdrop_layer = None; d.upload(ctx); app.distort.liquify = Some(d); Ok(()) @@ -536,8 +572,16 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { cancel(app); return Ok(json!({"cancelled": true})); } + // A `backdropUse` that names no layer of the document is All Layers, as the Use list shows it. + let missing = |id: u64| app.session.active().is_none_or(|st| st.doc.layer(LayerId(id)).is_none()); + let unknown_use = ui.get("backdropUse").and_then(Value::as_u64).is_some_and(missing); let d = app.distort.liquify.as_mut().ok_or(tl!("Liquify is not open"))?; - d.opts.apply(ui)?; + // `apply` takes every setting it can before a bad one fails it, so normalize first. + let applied = d.opts.apply(ui); + if unknown_use { + d.opts.backdrop_layer = None; + } + applied?; let num = |k: &str| ui.get(k).and_then(Value::as_f64).map(|v| v as f32); let flag = |k: &str| ui.get(k).and_then(Value::as_bool); if flag("undo") == Some(true) { @@ -710,26 +754,85 @@ fn shortcut(t: LiquifyTool) -> &'static str { } } -fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId, canvas: Rect) -> Option { +/// The backdrop for Show Backdrop › Use `of`: the document as it is (All Layers, the liquified +/// layer's own pixels included, as in Photoshop) or that one layer alone. A layer that is gone +/// shows All Layers. +fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, of: Option, canvas: Rect) -> Option { let st = app.session.active()?; - let doc = crate::distort_ui::without_layer(&st.doc, layer); - let buf = photocraft_compose::render(&doc, canvas); + let buf = photocraft_compose::render(&backdrop_doc(&st.doc, of), canvas); let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); let flat: Vec = buf.px.iter().flat_map(|p| *p).collect(); s.write_region(canvas, &flat); Some(ctx.load_texture("liquify-backdrop", crate::distort_ui::surface_image(&s, canvas, PROXY_SIDE), egui::TextureOptions::LINEAR)) } +/// The document [`load_backdrop`] renders for Use `of`. +fn backdrop_doc(doc: &Document, of: Option) -> std::borrow::Cow<'_, Document> { + match of.map(LayerId).filter(|id| doc.layer(*id).is_some()) { + Some(id) => { + let mut d = doc.clone(); + crate::transform_tool::solo_layer(&mut d.layers, id); + // A hidden layer, or one in a hidden group, shows too: Use lists every layer. + reveal(&mut d.layers, id); + std::borrow::Cow::Owned(d) + } + None => std::borrow::Cow::Borrowed(doc), + } +} + +/// Makes layer `id` and the groups holding it visible; whether it was found. +fn reveal(layers: &mut [photocraft_doc::Layer], id: LayerId) -> bool { + layers.iter_mut().any(|l| { + let found = l.id == id + || match &mut l.content { + photocraft_doc::LayerContent::Group(g) => reveal(&mut g.children, id), + _ => false, + }; + if found { + l.visible = true; + } + found + }) +} + +/// How the preview layers the image and the backdrop texture `T`: the image's tint, and the +/// backdrop drawn behind it or in front of it, with its tint. +#[derive(Debug, PartialEq)] +struct Layering { + image: Color32, + behind: Option<(T, Color32)>, + front: Option<(T, Color32)>, +} + +/// The [`Layering`] for the backdrop `backdrop` (when shown) in `mode` at `opacity` (0..1): +/// Photoshop's modes, measured on its Liquify (#7609). +fn backdrop_layering(backdrop: Option, mode: BackdropMode, opacity: f32) -> Layering { + let alpha = |a: f32| Color32::from_white_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let (image, behind, front) = match (backdrop, mode) { + (None, _) => (Color32::WHITE, None, None), + (Some(b), BackdropMode::InFront) => (Color32::WHITE, None, Some((b, alpha(opacity)))), + (Some(b), BackdropMode::Behind) => (alpha(opacity), Some((b, Color32::WHITE)), None), + (Some(b), BackdropMode::Blend) => (alpha(1.0 - opacity), None, Some((b, alpha(opacity)))), + }; + Layering { image, behind, front } +} + /// Draws the dialog (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - if app.distort.liquify.as_ref().is_some_and(|d| d.opts.show_backdrop && d.backdrop.is_none()) { - let tex = app.distort.liquify.as_ref().map(|d| (d.layer, d.canvas)).and_then(|(layer, canvas)| load_backdrop(app, ctx, layer, canvas)); + // The backdrop is built when it's first shown and again when its Use changes. + let wanted = app.distort.liquify.as_ref().filter(|d| d.opts.show_backdrop && d.backdrop.as_ref().is_none_or(|(of, _)| *of != d.opts.backdrop_layer)); + if let Some((of, canvas)) = wanted.map(|d| (d.opts.backdrop_layer, d.canvas)) { + let tex = load_backdrop(app, ctx, of, canvas); if let Some(d) = app.distort.liquify.as_mut() { - d.backdrop = tex; + d.backdrop = tex.map(|t| (of, t)); } } + // Show Backdrop › Use: All Layers or one layer, bottom to top as Photoshop lists them. + let backdrop_layers: Vec<(Option, String)> = std::iter::once((None, tl!("All Layers").to_string())) + .chain(app.session.active().into_iter().flat_map(|st| st.doc.walk().into_iter().map(|(_, _, l)| (Some(l.id.0), l.name.clone())).collect::>())) + .collect(); let mut action: Option<&str> = None; let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; @@ -818,6 +921,16 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::checkbox(ui, &mut d.opts.show_mask, tl!("Show Mask")); widgets::checkbox(ui, &mut d.opts.show_backdrop, tl!("Show Backdrop")); if d.opts.show_backdrop { + ui.horizontal(|ui| { + ui.label(tl!("Use")); + let names: Vec<(Option, &str)> = backdrop_layers.iter().map(|(id, n)| (*id, n.as_str())).collect(); + widgets::dropdown_literal(ui, "liquify-backdrop-use", &mut d.opts.backdrop_layer, &names, 150.0); + }); + ui.horizontal(|ui| { + ui.label(tl!("Mode")); + let modes = [(BackdropMode::InFront, tl!("In Front")), (BackdropMode::Behind, tl!("Behind")), (BackdropMode::Blend, tl!("Blend"))]; + widgets::dropdown(ui, "liquify-backdrop-mode", &mut d.opts.backdrop_mode, &modes, 150.0); + }); widgets::slider_row(ui, tl!("Opacity"), &mut d.opts.backdrop_opacity, 0.0..=100.0, "", None); } widgets::hairline(ui); @@ -875,10 +988,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let clip = painter.with_clip_rect(area); widgets::checker(&clip, img.intersect(area), 8.0); let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - if d.opts.show_backdrop - && let Some(b) = &d.backdrop - { - clip.image(b.id(), img, uv, Color32::from_white_alpha((d.opts.backdrop_opacity / 100.0 * 255.0) as u8)); + let backdrop = d.backdrop.as_ref().filter(|_| d.opts.show_backdrop).map(|(_, t)| t.id()); + let Layering { image: image_tint, behind, front } = backdrop_layering(backdrop, d.opts.backdrop_mode, d.opts.backdrop_opacity / 100.0); + if let Some((b, c)) = behind { + clip.image(b, img, uv, c); } if d.zoom > 1.0 && let (Some(bounds), Some(tex)) = (d.view_bounds, &d.view_tex) @@ -887,7 +1000,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { to_screen(d, area, [f64::from(bounds.x0), f64::from(bounds.y0)]), to_screen(d, area, [f64::from(bounds.x1), f64::from(bounds.y1)]), ); - clip.image(tex.id(), detail, uv, Color32::WHITE); + clip.image(tex.id(), detail, uv, image_tint); } else { // The proxy covers whole proxy pixels, a little past the canvas edge. let pr = ERect::from_min_max( @@ -895,9 +1008,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { img.min + vec2(d.proxy.w as f32 * d.proxy.scale as f32 * d.zoom, d.proxy.h as f32 * d.proxy.scale as f32 * d.zoom), ); if let Some(tex) = &d.tex { - clip.image(tex.id(), pr, uv, Color32::WHITE); + clip.image(tex.id(), pr, uv, image_tint); } } + if let Some((b, c)) = front { + clip.image(b, img, uv, c); + } if d.opts.show_mask && let Some(m) = &d.mask_tex { @@ -1111,6 +1227,55 @@ mod tests { assert!(d.preview_ms.is_finite()); } + /// #7609: Show Backdrop › Use and Mode, as in Photoshop: the settings, how each mode layers the + /// backdrop and the image, and what each Use shows. + #[test] + fn backdrop_use_and_mode() { + let mut o = LiquifyOpts::default(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::InFront), "All Layers, In Front"); + o.apply(&json!({"backdropUse": 3, "backdropMode": "behind"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (Some(3), BackdropMode::Behind)); + assert_eq!((&o.to_json()["backdropUse"], &o.to_json()["backdropMode"]), (&json!(3), &json!("behind"))); + o.apply(&json!({"backdropUse": "all", "backdropMode": "nope"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Behind), "a bad mode is ignored"); + let (w, a) = (Color32::WHITE, |x: f32| Color32::from_white_alpha((x * 255.0).round() as u8)); + let l = |image, behind, front| Layering { image, behind, front }; + assert_eq!(backdrop_layering(Some(1), BackdropMode::InFront, 0.25), l(w, None, Some((1, a(0.25)))), "backdrop over the image"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Behind, 0.25), l(a(0.25), Some((1, w)), None), "image over the backdrop"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Blend, 0.25), l(a(0.75), None, Some((1, a(0.25)))), "faded together"); + assert_eq!(backdrop_layering::(None, BackdropMode::Blend, 0.25), l(w, None, None), "no backdrop: the image as is"); + // All Layers is the document with the liquified layer in it; one layer is that layer alone. + let app = app_with_layer(); + let st = app.session.active().unwrap(); + let (layer, background) = (st.active_layer.unwrap(), st.doc.layers[0].id); + let at = |doc: &Document| photocraft_compose::render(doc, Rect::new(10, 10, 11, 11)).px[0]; + let own = at(&st.doc); + assert_eq!(at(&backdrop_doc(&st.doc, None)), own, "All Layers"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(layer.0))), own, "the liquified layer alone"); + assert_ne!(at(&backdrop_doc(&st.doc, Some(background.0))), own, "the Background alone"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(9999))), own, "a layer that's gone: All Layers"); + // A hidden layer shows when it is the one used. + let mut hidden = (*st.doc).clone(); + hidden.layer_mut(layer).unwrap().visible = false; + assert_eq!(at(&backdrop_doc(&hidden, Some(layer.0))), own, "the hidden liquified layer, alone"); + // A remembered Use is a layer of another document: Liquify opens on All Layers. + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"backdropUse": 42, "backdropMode": "blend"}))); + open(&mut app, &ctx).unwrap(); + let o = &app.distort.liquify.as_ref().unwrap().opts; + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Blend), "the mode is kept, the layer is not"); + // Over the control channel, a layer of the document is kept and one that isn't is All Layers. + let layer = app.session.active().unwrap().active_layer.unwrap().0; + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, Some(layer)); + control(&mut app, &json!({"backdropUse": 4242})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "no such layer: All Layers"); + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert!(control(&mut app, &json!({"backdropUse": 4242, "tool": "nope"})).is_err()); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "also when another setting fails"); + } + /// #418: the brush settings survive Cancel, OK and Esc, and come back the next time Liquify /// opens (also after a restart: they live in the preferences). #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 561621e8c9..bf0c4d8e87 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -793,7 +793,7 @@ fn preview_image( _ => { let mut solo = doc.clone(); if layer.is_some() { - show_preview_target(&mut solo.layers, id); + solo_layer(&mut solo.layers, id); } else { // Keep the previous fallback for an invalid id: render the document, with only // top-level clipping disabled. Production callers get ids from this document. @@ -991,7 +991,9 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { /// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path /// is detached from its clipping base because the base is hidden in this isolated render (#5239). -fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { +/// Hide every layer but `id` and the groups holding it, and unclip those, so a render shows that +/// layer alone (the Free Transform preview, Liquify's one-layer backdrop). +pub(crate) fn solo_layer(layers: &mut [photocraft_doc::Layer], id: LayerId) { for l in layers { if l.id == id { l.clipped = false; @@ -1000,7 +1002,7 @@ fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { l.clipped = false; - show_preview_target(&mut g.children, id); + solo_layer(&mut g.children, id); } _ => l.visible = false, } From 21fa6cf1dd3eea7a3c4314865bafe50d46a9eb39 Mon Sep 17 00:00:00 2001 From: jlange <15385135+jlange91@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:41:22 +0200 Subject: [PATCH 0868/1452] =?UTF-8?q?Vanishing=20Point:=20read=20a=20sampl?= =?UTF-8?q?e's=202=C3=972=20in=20one=20call=20outside=20the=20window=20(#7?= =?UTF-8?q?491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the source window is skipped (cloning near to far toward the horizon), four `read_pixel` calls looked the tile up four times and made that regime ~20% slower than `main`. One `read_region_into` into the reused corner buffer looks it up once. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/vanishing.rs | 47 ++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 3077371976..1cf39b23ec 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -416,21 +416,30 @@ struct Window { data: Vec, } -/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), using `corners` (four pixels) as -/// scratch, so the per-pixel clone loop does not allocate. Corners inside `win` come from it. -fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut [f32], out: &mut [f32]) { +/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four +/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region +/// read from `s`. +fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec, out: &mut [f32]) { let n = out.len(); let (x0, y0) = (x.floor() as i32, y.floor() as i32); let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1)); - let w = win.area.width() as usize; - for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() { - let dst = &mut corners[k * n..(k + 1) * n]; - if win.area.contains(px, py) { - let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n; - dst.copy_from_slice(&win.data[o..o + n]); - } else { - s.read_pixel(px, py, dst); + let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1)); + if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 { + // Outside the window (or none): one region read looks the tile up once, where four + // `read_pixel` calls would look it up four times. Same corner order as below. + s.read_region_into(foot, corners); + } else { + corners.resize(4 * n, 0.0); + let w = win.area.width() as usize; + for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() { + let dst = &mut corners[k * n..(k + 1) * n]; + if win.area.contains(px, py) { + let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n; + dst.copy_from_slice(&win.data[o..o + n]); + } else { + s.read_pixel(px, py, dst); + } } } let r = corners; @@ -534,7 +543,7 @@ mod tests { fn sample_at_the_edge_of_i32_does_not_overflow() { let s = Surface::new(PixelFormat::RGBA8); let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; - let (mut corners, mut out) = ([0.0; 16], [1.0; 4]); + let (mut corners, mut out) = (Vec::new(), [1.0; 4]); sample(&s, &win, 3e9, 3e9, &mut corners, &mut out); assert_eq!(out, [0.0; 4]); } @@ -546,7 +555,7 @@ mod tests { // Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge. s.fill_rect(Rect::new(0, 0, 256, 512), &a); s.fill_rect(Rect::new(256, 0, 512, 512), &b); - let (mut corners, mut out) = ([0.0; 16], [0.0; 4]); + let (mut corners, mut out) = (Vec::new(), [0.0; 4]); let want = [0.75, 0.0, 0.25, 1.0]; // From the surface, from a window holding the footprint, and from one holding half of it. for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] { @@ -555,6 +564,18 @@ mod tests { sample(&s, &win, 255.25, 255.5, &mut corners, &mut out); assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}"); } + // Four different corners, one per tile, so both axes of the corner order count. + let mut q = Surface::new(PixelFormat::RGBA8); + for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] { + q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px); + } + let want = [0.375, 0.25, 0.75, 1.0]; + for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] { + let mut win = Window { area, data: Vec::new() }; + q.read_region_into(area, &mut win.data); + sample(&q, &win, 255.25, 255.75, &mut corners, &mut out); + assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}"); + } // Outside every tile, the default (transparent) pixel. let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; sample(&s, &win, -10.5, -10.5, &mut corners, &mut out); From 06896086191dfcf1c41ed251b498c730b63caec1 Mon Sep 17 00:00:00 2001 From: Daniel Campos Ramos Date: Mon, 19 Oct 2026 14:08:44 -0300 Subject: [PATCH 0869/1452] PNG: name sTER modes by their specified eye order Both sTER modes describe side-by-side subimages: mode 0 is cross-fuse (right eye first), and mode 1 is diverging-fuse (left eye first). Correct the enum and document comments without changing their serialized byte values. Add a labelled-pixel check against the literal mode bytes in PNG Extensions 1.4.0 section 3.6. Co-Authored-By: OpenAI Codex --- crates/codecs/src/codecs/png.rs | 32 +++++++++++++++++++++++++------- crates/codecs/src/options.rs | 16 ++++++++-------- crates/doc/src/lib.rs | 3 ++- crates/io/tests/flat.rs | 2 +- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index ede70d296d..3fadc4eac8 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -679,8 +679,8 @@ mod stereo_tests { #[test] fn reads_both_defined_views() { - assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); - assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::CrossFuse)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::DivergingFuse)); assert_eq!(stereo(&png_with(&[])), None); // Reserved and unknown mode bytes are not a pairing we can keep. assert_eq!(stereo(&png_with(&[2])), None); @@ -690,6 +690,24 @@ mod stereo_tests { assert_eq!(stereo(b"not a png"), None); } + #[test] + fn ster_mode_bytes_describe_eye_order_not_vertical_packing() { + // PNG Extensions 1.4.0 §3.6: both views occupy horizontal halves. + // Each view is eight columns, so the second starts on an eight-column boundary. + for (mode, left_pixel, right_pixel) in [(0, [0, 0, 255], [255, 0, 0]), (1, [255, 0, 0], [0, 0, 255])] { + let pixels: Vec = [left_pixel; 8].into_iter().chain([right_pixel; 8]).flatten().collect(); + let img = Image::from_raw(16, 1, ChannelLayout::Rgb, SampleType::U8, pixels.clone()).unwrap(); + let pairing = if mode == 0 { PngStereo::CrossFuse } else { PngStereo::DivergingFuse }; + let opts = EncodeOptions { png_stereo: Some(pairing), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + let chunk = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + assert_eq!(bytes[chunk + 4], mode, "the specification's literal mode byte"); + let decoded = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(decoded.dimensions(), (16, 1)); + assert_eq!(decoded.data(), pixels); + } + } + #[test] fn a_broken_crc_is_not_a_stereo_mark() { let mut bytes = png_with(&[0]); @@ -702,7 +720,7 @@ mod stereo_tests { #[test] fn encode_writes_the_chunk_before_idat_and_it_reads_back() { let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); assert_eq!(stereo(&bytes), Some(view)); @@ -719,12 +737,12 @@ mod stereo_tests { #[test] fn interlaced_and_indexed_encoders_keep_the_mark() { let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); - let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() }; + let opts = EncodeOptions { png_stereo: Some(PngStereo::DivergingFuse), png_interlaced: true, ..Default::default() }; let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); - assert_eq!(stereo(&bytes), Some(PngStereo::Vertical)); + assert_eq!(stereo(&bytes), Some(PngStereo::DivergingFuse)); let pal = [[255, 0, 0], [0, 0, 255]]; - let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap(); - assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::CrossFuse)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::CrossFuse)); assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); } } diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index e6ca3cb94f..430b412a91 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -103,18 +103,18 @@ pub enum PngCompression { /// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PngStereo { - /// Left and right views side by side, left first. - Horizontal, - /// Left and right views stacked, left on top. - Vertical, + /// Side by side with the right-eye view first (cross-eyed viewing, mode 0). + CrossFuse, + /// Side by side with the left-eye view first (parallel viewing, mode 1). + DivergingFuse, } impl PngStereo { /// The view for an `sTER` mode byte; reserved and unknown values are `None`. pub fn from_mode(mode: u8) -> Option { match mode { - 0 => Some(Self::Horizontal), - 1 => Some(Self::Vertical), + 0 => Some(Self::CrossFuse), + 1 => Some(Self::DivergingFuse), _ => None, } } @@ -122,8 +122,8 @@ impl PngStereo { /// The `sTER` mode byte. pub fn mode(self) -> u8 { match self { - Self::Horizontal => 0, - Self::Vertical => 1, + Self::CrossFuse => 0, + Self::DivergingFuse => 1, } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 268b0b6b43..8752bbd7bb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -933,7 +933,8 @@ pub struct FileEncoding { #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` - /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + /// cross-fuse (right eye first), `Some(1)` diverging-fuse (left eye first). Both are + /// side by side; saving a stereo PNG keeps this eye-order mark (#6063). #[serde(skip_serializing_if = "Option::is_none")] pub png_stereo: Option, } diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index e591fbd5c1..9dc27a45c3 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -1119,7 +1119,7 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { fn a_stereo_png_keeps_its_stereo_mark_on_save() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); let doc = import("stereo.png", &bytes).unwrap().document; assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); From 125d15961e697bacd2e054c8a41c30cad5f7f7da Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:08:50 +0200 Subject: [PATCH 0870/1452] Actions: Smart Blur quality uses Photoshop's SBQL/SBQM/SBQH codes (#6312) (#6313) A Smart Blur step saved to .atn wrote its quality as `Hgh `/`Mdm `/`Low `, codes Photoshop ignores (it plays them back with its fallback, low quality), and Photoshop's own `SmBQ` values did not import as a quality. The quality table now writes `SBQL`/`SBQM`/`SBQH` (enum type `SmBQ`) and reads both those and the old `Hgh `/`Mdm `/`Low `, so actions PhotoCraft wrote before still load. Unknown codes stay kept-verbatim Photoshop steps. Measured in Photoshop by rendering: Smart Blur applied through Action Manager with each code; SBQH and SBQM each give a distinct image, while `Hgh `, `Mdm `, `Low `, a nonsense code and SBQL give the identical (fallback) image. The smart filter Photoshop saves uses the same SmBQ values (applied through Action Manager to a smart object, saved, filterFX read back). Tests: every quality round-trips through .atn as SBQ*; Photoshop-shaped SmrB steps and the legacy codes import as the right quality; unknown, wrong-type, NaN and missing values never panic; Smart Blur joins the export/import round-trip table. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 5 ++- crates/engine/src/atn_cmds/tests.rs | 69 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 999ba5c193..040afaca42 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -922,7 +922,10 @@ const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center" const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; -const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +/// Smart Blur quality. Photoshop's codes come first, so they are what an export writes (measured in +/// Photoshop: `Hgh `/`Mdm `/`Low ` play back as its fallback, low quality). The old codes after +/// them still read, for actions PhotoCraft wrote before. +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("SBQL", "low"), ("SBQM", "medium"), ("SBQH", "high"), ("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 59dbbf13c8..f50189fb0f 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -174,6 +174,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), ("filter.noise.median", json!({"radius": 2})), ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), ( @@ -416,7 +417,6 @@ fn every_mapped_command_exports_and_imports_back() { "filter.blur.blurMore", "filter.blur.boxBlur", "filter.blur.radialBlur", - "filter.blur.smartBlur", "filter.blur.surfaceBlur", "filter.noise.despeckle", "filter.noise.dustAndScratches", @@ -1037,6 +1037,73 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in +/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. +#[test] +fn smart_blur_quality_uses_photoshop_codes() { + let smart_blur = + |quality: D, mode: &str| desc("null").with("Rds ", D::Double(6.0)).with("Thsh", D::Double(60.0)).with("SmBQ", quality).with("SmBM", enm("SmBM", mode)); + // Every quality writes Photoshop's code, as a double radius/threshold and both enums, + // and reads back as the same step. + for (quality, code) in [("low", "SBQL"), ("medium", "SBQM"), ("high", "SBQH")] { + let params = json!({"radius": 6, "threshold": 60, "quality": quality, "mode": "edgeOnly"}); + let a = Action::new("Q", vec![("filter.blur.smartBlur".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let it = &file.actions[0].items[0]; + assert_eq!(it.event.id(), "SmrB"); + assert_eq!(it.descriptor.as_ref(), Some(&smart_blur(enm("SmBQ", code), "SBME")), "{quality}"); + let back = import(&bytes).unwrap(); + assert!(back.unsupported.is_empty(), "{quality}"); + assert_eq!(back.actions[0].steps, vec![("filter.blur.smartBlur".to_string(), params)]); + } + + // Photoshop-shaped steps (each quality and mode, as Photoshop stores them) and the codes + // PhotoCraft wrote before this fix read as the right quality. + let cases = [ + ("SBQH", "SBMN", "high", "normal"), + ("SBQM", "SBMO", "medium", "overlayEdge"), + ("SBQL", "SBME", "low", "edgeOnly"), + ("Hgh ", "SBMN", "high", "normal"), + ("Mdm ", "SBMN", "medium", "normal"), + ("Low ", "SBMN", "low", "normal"), + ]; + let items = cases.iter().map(|(q, m, _, _)| item("SmrB", "Smart Blur", Some(smart_blur(enm("SmBQ", q), m)))).collect(); + let data = set_of("Blurs", vec![("Smart", items)]); + let mut s = Session::new(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let steps = &s.actions.list[0].steps; + for ((q, _, quality, mode), (id, p)) in cases.iter().zip(steps) { + assert_eq!(id, "filter.blur.smartBlur", "{q}"); + assert_eq!(p, &json!({"radius": 6, "threshold": 60, "quality": quality, "mode": mode}), "{q}"); + } + + // An unknown code, a quality of the wrong type, NaN and a missing quality never panic: the + // unknown ones stay Photoshop steps, kept verbatim. + let odd = [ + smart_blur(enm("SmBQ", "Xxxx"), "SBMN"), + smart_blur(D::Integer(2), "SBMN"), + smart_blur(enm("SmBQ", "SBQH"), "Xxxx"), + desc("null").with("Rds ", D::Double(f64::NAN)).with("SmBQ", enm("SmBQ", "SBQM")), + desc("null"), + ]; + let items: Vec = odd.iter().map(|d| item("SmrB", "Smart Blur", Some(d.clone()))).collect(); + let imported = import(&photocraft_paint::tile::b64_decode(&set_of("Odd", vec![("Odd", items)])).unwrap()).unwrap(); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), odd.len()); + assert_eq!(steps[0].0, PHOTOSHOP_STEP, "an unknown quality is kept verbatim"); + assert_eq!(steps[2].0, PHOTOSHOP_STEP, "an unknown mode is kept verbatim"); + assert_eq!(imported.unsupported.iter().map(|u| u.step).collect::>(), [0, 2]); + // A wrong-type quality is left out (the filter's default applies), as for every enum field. + assert_eq!(steps[1], ("filter.blur.smartBlur".to_string(), json!({"radius": 6, "threshold": 60, "mode": "normal"}))); + // An unknown quality or a non-string one is not exported. + for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] { + let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]); + assert!(export("S", &[&a], &[], false).is_err(), "{bad}"); + } +} + /// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as /// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. #[test] From b892c0412e2e67c402af073dc3a3ff1f2b9d1cd3 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 13:16:53 -0400 Subject: [PATCH 0871/1452] Choose the graphics adapter on multi-GPU Linux machines (#1885) With the backend on auto, Linux installed no adapter selector, so wgpu took the first discrete GPU the driver listed. On a machine with an eGPU, a laptop dGPU and an iGPU that was not the one the user wanted. - performance.gpuAdapter: a case-insensitive piece of the adapter's name (several words must all appear); empty is automatic. - PHOTOCRAFT_GPU_ADAPTER overrides it for one launch. - Preferences > Performance > Advanced > GPU adapter lists the adapters the instance found; a saved adapter that is absent stays listed, marked. - A missing adapter, or one that can't present to the window, falls back to the automatic choice with a note in System Info. CPU mode ignores it. - Shown on Linux only; tested on Linux only. Windows and macOS need their own work (the startup code honours the setting there, but it is untested). Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 114 ++++++++++++++++++++++++++-- apps/photocraft/src/main.rs | 7 +- crates/engine/src/prefs.rs | 8 +- crates/engine/src/prefs/tests.rs | 20 ++++- crates/ui-egui/src/gpu_canvas.rs | 14 ++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs_ui.rs | 32 +++++++- docs/development.md | 3 +- docs/hardware-parity.md | 2 + 29 files changed, 246 insertions(+), 15 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index e79b4bafac..a709e99676 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -228,6 +228,8 @@ pub struct Candidate { pub backend: wgpu::Backend, /// Can present to the window. pub surface_ok: bool, + /// Its name matches the adapter the user asked for ([`adapter_matches`]). + pub wanted: bool, } /// Rank of an adapter (lower is better): high-performance device types first (egui's power @@ -259,9 +261,38 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { (fallback, ty, be) } -/// The adapter to use among `adapters` (`None` when there is none). +/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for +/// win over the rest when any of them can present; otherwise the ranking decides (see +/// [`wanted_missed`]). pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option { - adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i) + let usable = |a: &&Candidate| a.surface_ok; + let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted); + adapters + .iter() + .enumerate() + .filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)) + .min_by_key(|(i, a)| (rank(a, backend, os), *i)) + .map(|(i, _)| i) +} + +/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive +/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds +/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing. +pub fn adapter_matches(name: &str, wanted: &str) -> bool { + let name = name.to_lowercase(); + let mut words = wanted.split_whitespace().peekable(); + words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) +} + +/// The user asked for an adapter and none of `adapters` that can present is it. +pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool { + wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted) +} + +/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else +/// `performance.gpuAdapter`; `None` is automatic. +pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option { + [env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(str::to_string) } /// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the @@ -285,7 +316,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool { /// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter /// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the /// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default). -pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc>>) { +pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option, sentinel: SharedSentinel, note: Arc>>) { if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { if let Some(b) = backends(plan, os) { create.instance_descriptor.backends = b; @@ -298,7 +329,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel let env = std::env::var("WGPU_DX12_COMPILER").ok(); create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent)); } - let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)); + // CPU mode wants a software adapter, whatever the preference says. + let wanted = wanted.filter(|_| plan.backend != GpuBackend::Cpu); + let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows))); if select { let backend = plan.backend; create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| { @@ -311,10 +344,15 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel device_type: i.device_type, backend: i.backend, surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)), + wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)), } }) .collect(); let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?; + if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) { + *note.lock().unwrap_or_else(PoisonError::into_inner) = + Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice")); + } if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) { *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into()); @@ -443,6 +481,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window +/// opens; empty (automatic) when the file or value is missing. +pub fn read_gpu_adapter(path: Option<&Path>) -> String { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect() +} + /// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless /// turned on), read before the window opens: it picks the surface's present mode. pub fn read_low_latency(path: Option<&Path>) -> bool { @@ -604,7 +649,60 @@ mod tests { } fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate { - Candidate { vendor, device_type, backend, surface_ok: true } + Candidate { vendor, device_type, backend, surface_ok: true, wanted: false } + } + + #[test] + fn a_requested_adapter_beats_the_automatic_ranking() { + use wgpu::{Backend as B, DeviceType as T}; + // The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU. + let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc"); + a[2].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(2)); + a[2].wanted = false; + a[0].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for"); + // A wanted adapter that can't present is skipped, not an error. + a[0].surface_ok = false; + assert_eq!(pick(&a, Auto, Os::Other), Some(1)); + assert!(wanted_missed(&a, Some("iris"))); + assert!(!wanted_missed(&a, None)); + a[0].surface_ok = true; + assert!(!wanted_missed(&a, Some("iris"))); + // Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides. + let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + gl.iter_mut().for_each(|c| c.wanted = true); + assert_eq!(pick(&gl, Auto, Os::Other), Some(1)); + } + + #[test] + fn adapter_names_match_loosely_and_the_environment_wins() { + assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M ")); + assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc")); + assert!(!adapter_matches("anything", "")); + assert!(!adapter_matches("anything", " ")); + assert_eq!(wanted_adapter(None, ""), None); + assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into())); + assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into())); + } + + #[test] + fn gpu_adapter_is_read_before_the_window_opens() { + let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_gpu_adapter(Some(&file)) + }; + assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia"); + assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), ""); + assert_eq!(read(r#"{"performance":{}}"#), ""); + assert_eq!(read("not json"), ""); + assert_eq!(read_gpu_adapter(None), ""); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -894,7 +992,7 @@ mod tests { // The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path). let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let plan = plan(Auto, None, None, false, Os::Windows); - configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; if std::env::var_os("WGPU_DX12_COMPILER").is_none() { let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler { @@ -918,13 +1016,13 @@ mod tests { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") }; let before = before.instance_descriptor.flags; - configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched"); } if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); - configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..befff83026 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -678,7 +678,11 @@ fn main() -> eframe::Result { } let gpu_note: std::sync::Arc>> = Default::default(); // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. - gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone()); + let wanted_adapter = gpu_startup::wanted_adapter( + std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), + &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()), + ); + gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone()); // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers. if gpu_startup::read_low_latency(services::prefs_file().as_deref()) { @@ -774,6 +778,7 @@ fn main() -> eframe::Result { info.canvas = "cpu".into(); if let Some(rs) = cc.wgpu_render_state.clone() { app.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + app.perf.gpu_info.set_available(&rs.available_adapters); let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu; if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu { app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into()); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3e87d43fc7..eed66a3247 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -525,6 +525,11 @@ pub struct Performance { pub rendering_mode: Option, /// Graphics backend (applies at next launch; see [`GpuBackend`]). pub gpu_backend: GpuBackend, + /// Which graphics adapter the window and canvas use on a machine with several (applies at + /// next launch): part of the adapter's name, matched case-insensitively ("nvidia", "arc"). Empty + /// is automatic. `PHOTOCRAFT_GPU_ADAPTER` overrides it for one launch. If nothing matches or the + /// match can't present to the window, startup falls back to automatic and says so. + pub gpu_adapter: String, /// Live previews of large documents (adjustment and filter dialogs, an adjustment layer's /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. @@ -570,6 +575,7 @@ impl Default for Performance { use_gpu: true, rendering_mode: None, gpu_backend: GpuBackend::Auto, + gpu_adapter: String::new(), low_resolution_previews: true, low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, @@ -1059,7 +1065,7 @@ pub fn is_hidden(path: &str) -> bool { /// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They /// still load, save and round-trip on every platform. -pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"]; +pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 5314d1f197..bbb9df4ff2 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -493,6 +493,22 @@ fn right_click_with_painting_tools_pref() { assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker); } +#[test] +fn gpu_adapter_round_trips_and_defaults_to_automatic() { + let mut s = session(); + assert_eq!(s.prefs().performance.gpu_adapter, ""); + s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": "NVIDIA"})).unwrap(); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + assert!(s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": 3})).is_err()); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.prefs().performance.gpu_adapter, "NVIDIA"); + let mut u = Session::new(); + u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap(); + assert_eq!(u.prefs().performance.gpu_adapter, ""); +} + #[test] fn gpu_backend_round_trips_and_validates() { let mut s = session(); @@ -553,7 +569,9 @@ fn notification_autohide_preferences() { fn linux_only_preferences_show_only_on_linux() { assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux")); assert!(!is_hidden("performance.gpuBackend")); - assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one"); + assert!(LINUX_ONLY.contains(&"performance.gpuAdapter")); + let free_text = ["performance.gpuAdapter"]; + assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one"); } #[test] diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 65388d5049..476137ff3d 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1058,6 +1058,9 @@ pub struct GpuInfo { pub video_memory_mb: Option, /// `performance.gpuBackend` at launch. pub preference: String, + /// Every graphics adapter the window could have used, by name (Preferences › Performance › + /// Graphics Adapter lists them). Empty in hosts that don't set it. + pub adapters: Vec, /// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`). pub selected: String, /// Why startup didn't use the preference as is (previous start crashed, `--safe-gpu`, @@ -1073,6 +1076,17 @@ pub struct GpuInfo { } impl GpuInfo { + /// Record the adapters the instance found, without duplicates (one GPU shows up once per backend). + pub fn set_available(&mut self, adapters: &[wgpu::Adapter]) { + self.adapters.clear(); + for a in adapters { + let name = a.get_info().name; + if !name.is_empty() && !self.adapters.contains(&name) { + self.adapters.push(name); + } + } + } + /// Fill the adapter fields from `info`. pub fn set_adapter(&mut self, info: &wgpu::AdapterInfo) { self.adapter = info.name.clone(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..f4d8798b40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3838,3 +3838,6 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + +# Graphics Adapter preference (#1885) + GPU adapter Adaptér GPU diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..fd72546283 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3230,3 +3230,6 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + +# Graphics Adapter preference (#1885) + GPU adapter GPU-Adapter diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..3f31bec6c9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3930,3 +3930,6 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + +# Graphics Adapter preference (#1885) + GPU adapter Προσαρμογέας GPU diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..3dcfb2568d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4192,3 +4192,6 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + +# Graphics Adapter preference (#1885) + GPU adapter Adaptador de GPU diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..9f15ac1084 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3238,3 +3238,6 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + +# Graphics Adapter preference (#1885) + GPU adapter Adaptateur GPU diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..ad4d3c19fd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4149,3 +4149,6 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..e63f82bafb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3225,3 +3225,6 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + +# Graphics Adapter preference (#1885) + GPU adapter Adaptor GPU diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..6c754f1b3e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4347,3 +4347,6 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + +# Graphics Adapter preference (#1885) + GPU adapter Adattatore GPU diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..5204f384eb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3907,3 +3907,6 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + +# Graphics Adapter preference (#1885) + GPU adapter GPU アダプター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d5d58b2ff2..ff7165f709 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3411,3 +3411,6 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 어댑터 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..41bc8ca4fe 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3990,3 +3990,6 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..097f1d1050 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3829,3 +3829,6 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + +# Graphics Adapter preference (#1885) + GPU adapter Karta GPU diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..967efd5e61 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4020,3 +4020,6 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + +# Graphics Adapter preference (#1885) + GPU adapter Adaptador de GPU diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..5b52178d62 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3203,3 +3203,6 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + +# Graphics Adapter preference (#1885) + GPU adapter Адаптер GPU diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..0b9bdb75ab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4303,3 +4303,6 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + +# Graphics Adapter preference (#1885) + GPU adapter GPU adapter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..278623f6ad 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3960,3 +3960,6 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + +# Graphics Adapter preference (#1885) + GPU adapter GPU bağdaştırıcısı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..605cf3df6f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3938,3 +3938,6 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + +# Graphics Adapter preference (#1885) + GPU adapter Адаптер GPU diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..3a9f2ba4eb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3717,3 +3717,6 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + +# Graphics Adapter preference (#1885) + GPU adapter Bộ điều hợp GPU diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f242f8f63..604786a294 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2853,3 +2853,6 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 适配器 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 621bee5518..e61580cef6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2838,3 +2838,6 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 介面卡 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5948bd4edf..5634dfa72b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1013,6 +1013,7 @@ impl PhotocraftApp { let tile = self.session.prefs().performance.cache_tile_size; // Escaped driver/setup panics must leave the session and CPU canvas alive. self.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + self.perf.gpu_info.set_available(&rs.available_adapters); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, Err(payload) => { diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 4156e380cd..1b59083261 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1058,6 +1058,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, system: Option, system: Option, obj: &mut Map) { +fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, adapters: Option<&Value>, obj: &mut Map) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); ui.label(RichText::new(tl!("Graphics")).font(crate::theme::semibold(12.5)).color(t.text)); @@ -1331,9 +1332,34 @@ fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map, obj: &mut Map) { + let mut current = obj.get("gpuAdapter").and_then(Value::as_str).unwrap_or("").to_string(); + let mut names: Vec = adapters.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let missing = !current.is_empty() && !names.contains(¤t); + if missing { + names.push(current.clone()); + } + let automatic = tl!("Automatic"); + let labels: Vec = names.iter().map(|n| if missing && *n == current { format!("⚠ {n}") } else { n.clone() }).collect(); + let mut pairs: Vec<(String, &str)> = vec![(String::new(), automatic)]; + pairs.extend(names.iter().cloned().zip(labels.iter().map(String::as_str))); + ui.horizontal(|ui| { + ui.label(tl!("GPU adapter")); + crate::widgets::dropdown_literal(ui, "graphics-adapter", &mut current, &pairs, 320.0); + }); + ui.label(RichText::new(tl!("Applies at next launch.")).color(Tokens::get(ui.ctx()).text_faint)); + obj.insert("gpuAdapter".into(), json!(current)); +} + /// One user-facing choice; legacy flags remain compatible with older settings. fn rendering_mode_row(ui: &mut egui::Ui, obj: &mut Map) { let mut current = rendering_mode_value(obj); @@ -1566,7 +1592,7 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" - || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "gpuAdapter" | "renderingMode")) // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..bf4898482d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. On a machine with several GPUs (say an eGPU, a discrete laptop GPU and an iGPU), `auto` takes the first discrete adapter the driver lists; choose another in Preferences › Performance › Advanced › GPU adapter (`performance.gpuAdapter`, Linux; a case-insensitive piece of the adapter's name, empty is automatic), or for one launch with `PHOTOCRAFT_GPU_ADAPTER=nvidia`. An adapter that is missing or can't present to the window falls back to the automatic choice, with a note in System Info. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). @@ -116,6 +116,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | +| `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 60175de1e5..1417821f0d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,6 +22,7 @@ HDR display to verify. | GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects | partial | | Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial | | CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial | +| Choosing the GPU on a multi-GPU machine | Preferences › Performance › Use Graphics Processor | Linux: Preferences › Performance › Advanced › GPU adapter and `PHOTOCRAFT_GPU_ADAPTER` (#1885); Windows and macOS not yet | partial | | Old / integrated GPUs | yes | black line on Intel HD (#2426), OpenCore Macs fail (#1859), Rgba32Float panic (#2020) | partial | ## Pen tablets @@ -62,3 +63,4 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | First version | +| 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) | From b8e856dffe835d9589b98f4c9bf27c9d0d3677f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 17:18:46 +0000 Subject: [PATCH 0872/1452] Integration: #7022 write_region stays serial; the text rasterizer uses the parallel variant --- crates/raster/src/lib.rs | 61 ++++++++++++++++++++++++++++++--- crates/text/src/render.rs | 2 +- docs/text-raster-performance.md | 9 +++-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 1393a8236e..958ab38b53 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -641,16 +641,34 @@ impl Surface { out } - /// Write interleaved normalised floats into a rectangle. + /// Write interleaved normalised floats into a rectangle, on the calling thread. + /// + /// This never enters Rayon, so it is safe inside a `OnceLock` initializer or any other + /// section that must not run another Rayon job on its stack. [`Surface::write_region_par`] + /// writes the same bytes on all cores. pub fn write_region(&mut self, r: Rect, data: &[f32]) { let n = self.channels(); let w = r.width() as usize; assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); let sample = self.format.sample; - // Each tile's own span of `data` is converted on its own: the tiles are disjoint, so - // converting them in any order (and on any thread) writes exactly the same bytes. - // `take_tiles` reserves the whole result before removing anything, and gives the missing - // tiles as one shared default-filled tile, copied per tile by `Arc::make_mut` below. + for tc in r.tiles() { + write_tile(self.tile_mut(tc), tc, r, data, w, n, sample); + } + } + + /// [`Surface::write_region`] with the tiles converted on all cores (serially on the web). + /// + /// This enters Rayon: while it waits for its tiles, the calling worker may run any other + /// queued Rayon job. Callers inside a `OnceLock` initializer or another section that must + /// not do that use [`Surface::write_region`]. + pub fn write_region_par(&mut self, r: Rect, data: &[f32]) { + let n = self.channels(); + let w = r.width() as usize; + assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); + let sample = self.format.sample; + // The tiles are disjoint, so converting them in any order (and on any thread) writes + // exactly the same bytes. `take_tiles` gives the missing tiles as one shared + // default-filled tile, copied per tile by `Arc::make_mut` in `convert_tiles`. let mut tiles = self.take_tiles(r); convert_tiles(&mut tiles, r, data, w, n, sample); self.put_tiles(tiles); @@ -1310,19 +1328,52 @@ mod tests { .collect(); let snapshot = actual.clone(); let snapshot_bytes = snapshot.to_interleaved(rect.inflate(1)); + let mut par = actual.clone(); actual.write_region(rect, &data); + par.write_region_par(rect, &data); write_region_reference(&mut expected, rect, &data); assert_eq!(actual, expected, "{fmt:?}, {rect:?}"); + assert_eq!(par, expected, "parallel {fmt:?}, {rect:?}"); assert_eq!(snapshot.to_interleaved(rect.inflate(1)), snapshot_bytes, "COW snapshot changed"); // Repeat against already materialised, unshared tiles too. actual.write_region(rect, &data); + par.write_region_par(rect, &data); assert_eq!(actual, expected); + assert_eq!(par, expected); } } } } } + /// Mask builds call `write_region` inside a `OnceLock` initializer, where entering Rayon can + /// run another tile's job that waits on the same lock. With every global worker held, a write + /// that queued work on Rayon could not finish. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn write_region_does_not_enter_rayon() { + use std::sync::{Barrier, mpsc}; + let workers = rayon::current_num_threads(); + let held = Arc::new(Barrier::new(workers + 1)); + let release = Arc::new(Barrier::new(workers + 1)); + let (h, rel) = (held.clone(), release.clone()); + rayon::spawn_broadcast(move |_| { + h.wait(); + rel.wait(); + }); + held.wait(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = Rect::new(0, 0, 2 * TILE_SIZE, 2 * TILE_SIZE); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_region(r, &vec![0.5; (r.width() * r.height() * 4) as usize]); + tx.send(()).ok(); + }); + let done = rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(); + release.wait(); + assert!(done, "write_region waited on the Rayon pool"); + } + #[test] fn from_rgba_into_matches_from_rgba() { for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::new(ColorMode::Lab, SampleType::F32, true)] { diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 5cf6baea12..39eef20757 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -283,7 +283,7 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } // Unpremultiply. unpremultiply(&mut acc, n, stride, format.sample); - surface.write_region(rect, &acc); + surface.write_region_par(rect, &acc); surface.prune(); Rendered { surface, rect } } diff --git a/docs/text-raster-performance.md b/docs/text-raster-performance.md index 87ccdf849f..5bc446ead1 100644 --- a/docs/text-raster-performance.md +++ b/docs/text-raster-performance.md @@ -25,8 +25,10 @@ reads and writes only its own slots. ## What runs in parallel now -`composite`, `unpremultiply` and the tile conversion of `write_region` run on all cores (rayon, -native only; the web build keeps the serial loops, since wasm has no threads). Nothing about them +`composite`, `unpremultiply` and the tile conversion run on all cores (rayon, native only; the web +build keeps the serial loops, since wasm has no threads). The rasterizer writes its tiles with +`Surface::write_region_par`; `Surface::write_region` stays serial, because mask builds call it inside +a `OnceLock` initializer, which must not enter rayon. Nothing about them is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels are identical, not merely close. @@ -37,7 +39,8 @@ are identical, not merely close. `crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing -region proptests compare `write_region` against its serial reference implementation, tile for tile. +`region_writes_match_scalar_bytes_for_every_format` compares `write_region` and `write_region_par` +against a scalar reference implementation, tile for tile. ## What stays on one thread, and why From 7b99c329234a5d40985a14c9ab0d17578426879b Mon Sep 17 00:00:00 2001 From: Ahmed Hosny Date: Mon, 19 Oct 2026 20:30:56 +0300 Subject: [PATCH 0873/1452] Color Lookup: warn when it opens without its usable table (part of #1763) (#6322) * fix(io): warn when a Color Lookup opens without its usable table A Color Lookup whose table is shorter than its size-cubed grid (or missing altogether) silently rendered as the identity. Native .pcraft loads and native-path automation opens carried no import warnings, so the loss was silent. The new io::open_warnings reports it naming the layer, and both load seams surface it. Also add a Photoshop-layout clrL regression test: a version-1 block carrying a CUBE descriptor round-trips through PSB and reopens as a working Color Lookup that recolors a red backdrop (#1763). * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/files.rs | 6 ++- crates/io/src/lib.rs | 33 +++++++++++++- crates/io/tests/adjust_warnings.rs | 69 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index cd34ab3410..fc4526eedb 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -19,7 +19,8 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { - return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let r = photocraft_io::import(name, bytes)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) @@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_path(path)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3543cd359c..02381d9831 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -69,7 +69,7 @@ pub mod vector_map; pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; -use photocraft_doc::Document; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; @@ -140,6 +140,34 @@ pub(crate) fn unequal_resolution_warning(x: f64, y: f64) -> Option { .then(|| format!("the vertical resolution ({y:.2} ppi) differs from the horizontal ({x:.2} ppi); the document keeps {x:.2} ppi for both")) } +/// Warnings about a loaded document's layers that render without their stored effect, for +/// sources that carry no import warnings of their own (a crafted `.pcraft`): a Color Lookup +/// whose table can't be applied silently renders as the identity instead (#1763). +pub fn open_warnings(doc: &Document) -> Vec { + let mut out = Vec::new(); + // Iterative, so no document's nesting can make this recurse. + let mut stack: Vec<&Layer> = doc.layers.iter().rev().collect(); + while let Some(l) = stack.pop() { + if let LayerContent::Adjustment(Adjustment::ColorLookup { name: _, size, lut, .. }) = &l.content { + match lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)) { + Some((table, len)) if table.len() >= len => {} + Some((table, len)) => out.push(format!( + "layer \"{}\": its Color Lookup table has {} values, but a {size}³ lookup needs {len}; the layer renders without the lookup", + l.name, + table.len() + )), + None => { + out.push(format!("layer \"{}\": its Color Lookup has no usable table for a {size}³ lookup; the layer renders without the lookup", l.name)) + } + } + } + if let Some(children) = l.children() { + stack.extend(children.iter().rev()); + } + } + out +} + /// Result of [`export`]. #[derive(Debug, Clone)] pub struct ExportResult { @@ -276,7 +304,8 @@ pub fn import_with_svg_group_depth_and_interrupt( fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }); } if is_psd(bytes) { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/adjust_warnings.rs index 9b91f40bcf..c5bba01827 100644 --- a/crates/io/tests/adjust_warnings.rs +++ b/crates/io/tests/adjust_warnings.rs @@ -5,6 +5,8 @@ use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_io::*; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_raster::Surface; fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec) { let mut d = Document::new("a", Size::new(16, 16), ColorMode::Rgb, SampleType::U8); @@ -40,3 +42,70 @@ fn readable_adjustments_open_without_warnings() { assert!(!warnings.iter().any(|w| w.contains("Teal Look")), "{warnings:?}"); } } + +/// The raw bytes of a `clrL` block laid out the way Photoshop writes it (Adobe spec): version 1, +/// then a version 16 descriptor carrying a small `.cube` LUT file (its entry for red maps to +/// cyan, so the table is not the identity). +fn photoshop_clr_l_block() -> Vec { + let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) }; + let text = |t: &str| Value::Text(UnicodeString::new_nul(t)); + let desc = Descriptor::new("null") + .with("NM ", text("Frost")) + .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) + .with("LUT3DFileName", text("frost.cube")) + .with("LUT3DFileData", Value::RawData(cube.to_vec())); + let mut raw = vec![0, 1]; + raw.extend(VersionedDescriptor::new(desc).to_bytes()); + assert_eq!(&raw[..6], &[0, 1, 0, 0, 0, 16], "version 1, then the descriptor's version 16"); + raw +} + +#[test] +fn photoshop_layout_color_lookup_round_trips_through_psb() { + // A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a + // working Color Lookup, with no warning naming the layer (#1763). + let raw = photoshop_clr_l_block(); + let opts = ExportOptions { force_psb: true, ..Default::default() }; + let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.clone() }, "x.psb", &opts); + assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}"); + let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false }) = &doc.layers[0].content else { + panic!("{:?}", doc.layers[0].content) + }; + assert_eq!(name, "frost.cube"); + assert_eq!(*size, 2); + let want: Vec = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0].into(); + assert_eq!(table.as_slice(), want.as_slice()); + // The exporter wrote the block verbatim (the importer keeps it for saving; the block writer + // pads to Photoshop's four-byte alignment, which the spec requires). + let kept = doc.layers[0].psd_blocks.iter().find(|(k, _)| k == b"clrL").map(|(_, d)| d.as_slice()); + let Some(kept) = kept else { panic!("{:?}", doc.layers[0].psd_blocks) }; + assert!(kept.starts_with(&raw) && kept[raw.len()..].iter().all(|&b| b == 0), "{kept:?}"); + // The reopened lookup really applies: a red backdrop renders as the table's cyan. + let mut d = doc; + let mut bg = Surface::new(d.pixel_format()); + bg.fill_rect(d.bounds(), &[1.0, 0.0, 0.0, 1.0]); + d.layers.insert(0, Layer::new("bg", LayerContent::Raster(bg))); + let px = photocraft_compose::flatten(&d).px[0]; + assert!(px[0] < 0.05 && (px[1] - 1.0).abs() < 0.05 && (px[2] - 1.0).abs() < 0.05, "{px:?}"); +} + +#[test] +fn short_color_lookup_table_warns_on_open() { + // A stored table shorter than size³×3 renders as the identity; opening must say so, naming + // the layer (#1763). + let short = Adjustment::ColorLookup { name: "t.cube".into(), size: 3, lut: Some(std::sync::Arc::new(vec![0.5; 7])), tetrahedral: false, dither: false }; + let (doc, warnings) = reopen(short, "x.pcraft", &ExportOptions::default()); + assert!(matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::ColorLookup { .. })), "{:?}", doc.layers[0].content); + let w: Vec<&String> = warnings.iter().filter(|w| w.contains("Teal Look")).collect(); + assert_eq!(w.len(), 1, "{warnings:?}"); + assert!(w[0].contains("Color Lookup") && w[0].contains("7 values") && w[0].contains("81"), "{}", w[0]); + // A table long enough for its size opens without the note. + let ok = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: Some(std::sync::Arc::new(vec![0.5; 24])), tetrahedral: false, dither: false }; + let (_, warnings) = reopen(ok, "x.pcraft", &ExportOptions::default()); + assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}"); + // A missing table renders as the identity too. + let none = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: None, tetrahedral: false, dither: false }; + let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}"); +} From acb3a66fd598f19ebb3a293f5fe767692780af13 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:31:10 +0200 Subject: [PATCH 0874/1452] Crop: a preset picked in Tool Presets fits the frame to its ratio (#7143) (#7144) select_tool_preset only merged the preset's tool options, so a crop preset's ratio was set without refitting the frame; the options bar's dropdown already refits. It now compares the ratio before and after and calls crop_ui::apply_ratio when it changed, as the dropdown does. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/preset_panels.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 1cd542dbf3..2741f9170c 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1002,6 +1002,7 @@ fn tool_id(t: Tool) -> String { /// Apply a tool preset: the engine sets the brush/colour, the shell switches tool and options. pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { + let before_ratio = crate::crop_ui::preset_ratio(app); let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return }; if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) { crate::tool_memory::select(app, tool); @@ -1023,6 +1024,10 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { app.ui.tool_options = opts; } } + // As the options bar does: a crop preset's ratio (or W x H) fits the frame at once. + if crate::crop_ui::preset_ratio(app) != before_ratio { + crate::crop_ui::apply_ratio(app); + } } /// The `tool.presets.new` params for the + button: `tool`'s own options only, so choosing the @@ -1319,6 +1324,20 @@ mod tests { assert_eq!(Tool::from_name(&tool_id(Tool::CustomShape)), Some(Tool::CustomShape)); } + /// A crop preset picked in Tool Presets fits the frame to its ratio at once, as the options + /// bar's dropdown does. + #[test] + fn a_crop_tool_preset_fits_the_frame_to_its_ratio() { + let (mut app, _) = app(); + app.ui.tool = Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 64.0, 48.0])); + app.run("tool.presets.new", json!({"name": "Square", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "1:1"}}})).unwrap(); + select_tool_preset(&mut app, "Square"); + assert_eq!(app.ui.tool_options.crop_ratio, "1:1"); + assert_eq!(app.ui.crop_rect, Some([8.0, 0.0, 56.0, 48.0])); + } + /// #7356: a preset made with + keeps only its own tool's options, so choosing it doesn't /// reset the Crop tool's Delete Cropped Pixels or the Move tool's Auto-Select. #[test] From 5b1f9372874cd2823788ad9e99c537e04d416c26 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:31:26 -0600 Subject: [PATCH 0875/1452] Actions: a function key can take Shift and Command (#7646) (#7674) Photoshop's Action Options pair the function key with Shift and Command checkboxes. The Actions row menu offered plain F-keys only, and .atn import dropped any key with a modifier. The row menu gets Shift and Command (Ctrl off the Mac) toggles, and .atn load and save keep the modifiers. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 37 ++++++++++++++---- crates/engine/src/atn_cmds/tests.rs | 28 ++++++++++++++ crates/ui-egui/src/actions.rs | 60 ++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ru.tsv | 1 + 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 040afaca42..ffa29aec14 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3123,8 +3123,8 @@ pub fn import(bytes: &[u8]) -> std::result::Result { let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); - if a.function_key > 0 && !a.shift && !a.command { - out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + if a.function_key > 0 { + out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); } out.actions.push(action); } @@ -3163,8 +3163,32 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } +/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options +/// `Shift+F5`, `Cmd+F5` or `Cmd+Shift+F5` (#7646). +pub fn function_key_name(key: u16, shift: bool, command: bool) -> String { + format!("{}{}F{key}", if command { "Cmd+" } else { "" }, if shift { "Shift+" } else { "" }) +} + +/// The `.atn` function key (1…15), Shift and Command of a shortcut string, or `None` for +/// anything else. +pub fn parse_function_key(key: &str) -> Option<(u16, bool, bool)> { + let (mut shift, mut command) = (false, false); + let mut rest = key; + loop { + if let Some(r) = rest.strip_prefix("Cmd+").filter(|_| !command) { + (command, rest) = (true, r); + } else if let Some(r) = rest.strip_prefix("Shift+").filter(|_| !shift) { + (shift, rest) = (true, r); + } else { + break; + } + } + let n = rest.strip_prefix('F')?.parse::().ok().filter(|n| (1..=15).contains(n))?; + Some((n, shift, command)) +} + /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key -/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. pub fn export( set: &str, actions: &[&Action], @@ -3175,11 +3199,8 @@ pub fn export( let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; for a in actions { let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; - if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) - && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) - && (1..=15).contains(&n) - { - out.function_key = n; + if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { + (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { let Some(mut item) = export_item(id, params, set) else { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index f50189fb0f..1b91235428 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1219,3 +1219,31 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); } } + +/// #7646: an action's function key keeps Photoshop's Shift and Command options through +/// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. +#[test] +fn function_keys_keep_shift_and_command() { + let mut file = sample(); + file.actions[1].function_key = 5; + file.actions[1].shift = true; + file.actions[1].command = true; + file.actions[0].shift = true; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "Shift+F3"}, {"action": "Finish", "key": "Cmd+Shift+F5"}])); + let shortcuts = &s.prefs().shortcuts; + assert_eq!(shortcuts.get("actions.play:Soften").map(String::as_str), Some("Shift+F3")); + assert_eq!(shortcuts.get("actions.play:Finish").map(String::as_str), Some("Cmd+Shift+F5")); + let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let keys: Vec<_> = saved.actions.iter().map(|a| (a.function_key, a.shift, a.command)).collect(); + assert_eq!(keys, [(3, true, false), (5, true, true)]); + + assert_eq!(super::parse_function_key("Cmd+F12"), Some((12, false, true))); + assert_eq!(super::parse_function_key("Shift+Cmd+F2"), Some((2, true, true))); + for bad in ["F16", "F0", "Alt+F2", "Cmd+Cmd+F2", "Shift+", "Cmd+K", ""] { + assert_eq!(super::parse_function_key(bad), None, "{bad}"); + } + assert_eq!(super::function_key_name(7, false, false), "F7"); +} diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index cdffc378dd..8b52ba88c5 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -824,16 +824,28 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.actions.expanded[i] = !expanded; } } - resp.context_menu(|ui| { + // Clicks inside stay open, so Shift and Command can both be ticked; every item that + // finishes closes the menu itself. + egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.label(tl!("Keyboard Shortcuts")); let current = app.session.prefs().shortcuts.get(&shortcut_id(name)).cloned().unwrap_or_default(); - for key in std::iter::once(String::new()).chain((1..=12).map(|n| format!("F{n}"))) { - let label = if key.is_empty() { tl!("None").to_string() } else { key.clone() }; - if ui.selectable_label(current == key, label).clicked() { - let _ = assign_shortcut(app, name, &key); + // Photoshop's Action Options: a function key, with Shift and Command (#7646). + let (key, mut shift, mut command) = photocraft_engine::atn_cmds::parse_function_key(¤t).unwrap_or((0, false, false)); + for n in std::iter::once(0).chain(1..=12) { + let label = if n == 0 { tl!("None").to_string() } else { format!("F{n}") }; + if ui.selectable_label(key == n, label).clicked() { + let chosen = if n == 0 { String::new() } else { photocraft_engine::atn_cmds::function_key_name(n, shift, command) }; + let _ = assign_shortcut(app, name, &chosen); ui.close(); } } + ui.add_enabled_ui(key > 0, |ui| { + let command_label = if cfg!(target_os = "macos") { tl!("Command") } else { tl!("Ctrl") }; + let toggled = ui.checkbox(&mut shift, tl!("Shift")).changed() | ui.checkbox(&mut command, command_label).changed(); + if toggled { + let _ = assign_shortcut(app, name, &photocraft_engine::atn_cmds::function_key_name(key, shift, command)); + } + }); ui.separator(); ui.menu_button(tl!("Color"), |ui| { for (id, label) in [ @@ -1034,6 +1046,44 @@ mod tests { assert!(crate::dock::Group::History.scrolls_itself(1)); } + /// #7646: an action's function key can take Shift and Command, as in Photoshop's Action + /// Options, and only that combination plays it. + #[test] + fn a_function_key_with_shift_and_command_plays_its_action() { + use egui::{Event, Key, Modifiers}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.session.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let key = photocraft_engine::atn_cmds::function_key_name(2, true, true); + assert_eq!(key, "Cmd+Shift+F2"); + assign_shortcut(&mut app, "Action 1", &key).unwrap(); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, modifiers| { + ctx.begin_pass(egui::RawInput { + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key: Key::F2, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::F2, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let before = layers(&app); + press(&mut app, Modifiers::NONE); + press(&mut app, Modifiers::SHIFT); + assert_eq!(layers(&app), before, "F2 and Shift+F2 alone don't play it"); + press(&mut app, Modifiers::SHIFT | Modifiers::COMMAND); + assert_eq!(layers(&app), before + 1, "Cmd+Shift+F2 plays it"); + } + #[test] fn double_clicking_an_action_name_renames_it_in_place() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 943ebcb9c9..50b0081146 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,6 +3204,7 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Command Command Use Использовать In Front Спереди Behind Сзади From 81d5c514e2a8a4f26bfef8302279deb5307b5ba3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 23:01:33 +0530 Subject: [PATCH 0876/1452] perf(photomerge): build multi-band bands in bounded batches (#7758) (#7763) * perf(photomerge): build multi-band bands in bounded batches (#7758) panorama::multiband built every source's Laplacian and weight pyramids at once and kept them all until accumulation, so Photomerge's peak memory grew with the image count (~5 GB for four 24 MP sources). Build the bands in batches under a 2 GiB estimate (at least one image) and add each batch before building the next. Each pixel still adds the bands in image order, so the output is bit-identical. Planes now move into the pyramids instead of being cloned, and the coverage and push-pull masks are freed once used: with one image built at a time those clones were serial passes. seam_blend shifts its ROIs in place instead of cloning every source for the blend. Peak during the blend: 4x24 MP 5.04 -> 2.81 GB, 8x12 MP 5.50 -> 2.61 GB, at +5% time (2x24 MP unchanged). New example bench_multiband measures it. Co-Authored-By: Claude Opus 5.5 * perf(photomerge): let up to four 24 MP sources build their bands together A 2 GiB band budget built every 24 MP source alone, which made the usual 2-4 image Photomerge 12-36% slower on a 32-thread desktop. The native budget is now 5 GiB, so those still build side by side and only larger stacks are batched. wasm32 has no threads, so it adds each band as soon as it is built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/examples/bench_multiband.rs | 42 +++++++ crates/algo/src/panorama.rs | 156 +++++++++++++++++------- crates/engine/src/photo_cmds.rs | 14 ++- 3 files changed, 164 insertions(+), 48 deletions(-) create mode 100644 crates/algo/examples/bench_multiband.rs diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs new file mode 100644 index 0000000000..9d58b81a54 --- /dev/null +++ b/crates/algo/examples/bench_multiband.rs @@ -0,0 +1,42 @@ +//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels +//! (3:2), each overlapping the next by half its width. +//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). +//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +use std::time::Instant; + +use photocraft_algo::panorama::{self, RoiImage}; + +/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. +fn peak_mib() -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb / 1024.0) +} + +fn main() { + let mut args = std::env::args().skip(1); + let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); + let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); + let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); + let sw = sh * 3 / 2; + let step = sw / 2; + let (cw, ch) = (step * (n - 1) + sw, sh); + let rois: Vec = (0..n) + .map(|k| { + let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); + RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } + }) + .collect(); + // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. + let wts: Vec> = (0..n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + .collect(); + let levels = panorama::blend_levels(cw, ch).min(7); + let before = peak_mib(); + let t0 = Instant::now(); + let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); + println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); + std::hint::black_box((px, cov)); +} diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index abe17db395..75fa9a4bfb 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -1170,10 +1170,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize }) } +/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built +/// and accumulated in batches under it, so its peak memory doesn't grow with the image count +/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side +/// uses a many-core machine better than one band's row-parallel work, and one at a time made the +/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop. +#[cfg(not(target_arch = "wasm32"))] +const BAND_BUDGET: usize = 5 << 30; +/// Without threads the bands of a batch are built one after another anyway, so each is added as +/// soon as it is built (the browser's address space is 4 GB). +#[cfg(target_arch = "wasm32")] +const BAND_BUDGET: usize = 0; + /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image /// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour /// channels (`ch` per pixel) and the total coverage. pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET) +} + +/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one +/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it. +fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) { let ch = imgs.first().map_or(1, |i| i.ch); let levels = levels.max(1); let align = 1i32 << (levels - 1); @@ -1187,13 +1205,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect(); let margin = align * 4; // Each image on its ROI grown by a margin and aligned to the coarsest level's grid. - let bands = par_map(imgs.len(), |k| { - let im = &imgs[k]; + let grid = |im: &RoiImage| { let gx0 = ((im.x0 - margin).max(0) / align) * align; let gy0 = ((im.y0 - margin).max(0) / align) * align; let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); - let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize); + (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) + }; + // Building one band holds its grid's colour and weight planes about three times over: the + // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top. + let cost = |im: &RoiImage| { + let (_, _, gw, gh) = grid(im); + gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) + }; + let band = |k: usize| -> PyramidBand { + let im = &imgs[k]; + let (gx0, gy0, gw, gh) = grid(im); // Each grid pixel takes the image pixel that lands on it (every image pixel lands on at // most one), so rows of the grid are independent and fill in parallel. let src_of = |gx: usize, gy: usize| -> Option { @@ -1225,68 +1252,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } }); let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); + drop(cov); push_pull(gw, gh, ch, &mut px, &bin); - // Laplacian of the colours, Gaussian of the weights. + drop(bin); + // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather + // than being copied: with one image built at a time, a copy is a serial pass over it. let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); let mut gau: Vec> = Vec::new(); let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt); for l in 0..levels { if l + 1 == levels { - lap.push((cw, chh, cur.clone())); - gau.push(cwt.clone()); + lap.push((cw, chh, cur)); + gau.push(cwt); break; } let (nw, nh, down) = reduce(cw, chh, ch, &cur); let up = expand(nw, nh, ch, &down, cw, chh); lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); - gau.push(cwt.clone()); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); + gau.push(cwt); cw = nw; chh = nh; cur = down; cwt = wdown; } (gx0, gy0, lap, gau) - }); - // Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per - // level, bands one after another), but rows of a level are independent, so they run in - // parallel: every row walks the bands itself. - for l in 0..levels { - let pw = sizes[l].0; - par_rows(&mut wsum[l], pw, 1, |py, row| { - for b in &bands { - let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - row[px] += k; - } - } - }); - par_rows(&mut acc[l], pw, ch, |py, row| { - for b in &bands { - let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - for c in 0..ch { - row[px * ch + c] += k * band[x * ch + c]; + }; + // Build the bands a batch at a time and add each batch before building the next. Each pixel + // adds the bands in the same order as the serial loop (per level, bands one after another; + // batches follow each other in image order), but rows of a level are independent, so they + // run in parallel: every row walks the batch's bands itself. + let mut start = 0; + while start < imgs.len() { + let mut end = start + 1; + let mut bytes = cost(&imgs[start]); + while let Some(im) = imgs.get(end) + && bytes.saturating_add(cost(im)) <= budget + { + bytes = bytes.saturating_add(cost(im)); + end += 1; + } + let bands = par_map(end - start, |j| band(start + j)); + for l in 0..levels { + let pw = sizes[l].0; + par_rows(&mut wsum[l], pw, 1, |py, row| { + for b in &bands { + let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + row[px] += k; } } - } - }); + }); + par_rows(&mut acc[l], pw, ch, |py, row| { + for b in &bands { + let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + for c in 0..ch { + row[px * ch + c] += k * band[x * ch + c]; + } + } + } + }); + } + start = end; } for l in 0..levels { let pw = sizes[l].0; @@ -1910,6 +1954,28 @@ mod parallel_tests { assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); } + /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none + /// (one image per batch) up to everything in steps of a fifth pass through every split of the + /// three images. + #[test] + fn multiband_batches_match_the_serial_code() { + let budgets: Vec = + std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect(); + for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64); + for levels in [1usize, 3, 5] { + let want = ref_multiband(w, h, &imgs, &wts, levels); + for &budget in &budgets { + let got = multiband_batched(w, h, &imgs, &wts, levels, budget); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}"); + } + } + } + } + } + /// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so /// they hold on every platform). #[test] diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 72851b3679..c729afd0d5 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -402,10 +402,18 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam .collect() }) .collect(); - // Shift ROIs to canvas-relative coordinates for the blend. - let local: Vec = rois.iter().map(|r| RoiImage { x0: r.x0 - canvas.x0, y0: r.y0 - canvas.y0, ..r.clone() }).collect(); + // Shift ROIs to canvas-relative coordinates for the blend, and back after: a shifted copy + // would hold every source's pixels twice (#7758). + for r in &mut rois { + r.x0 -= canvas.x0; + r.y0 -= canvas.y0; + } let levels = if seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; - let (blend, cov) = panorama::multiband(cw, chh, &local, &weights, levels); + let (blend, cov) = panorama::multiband(cw, chh, &rois, &weights, levels); + for r in &mut rois { + r.x0 += canvas.x0; + r.y0 += canvas.y0; + } let cc = fmt.mode.color_channels(); let nch = fmt.channels(); let mut composite = Surface::new(fmt); From cbe6c4e13187a52316f4000275769cbbb9e94232 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:31:41 +0200 Subject: [PATCH 0877/1452] Guides: colours follow Crop, Canvas Size and rotations (#7724) (#7725) transform_doc_marks moved guide positions but not their colour entries, which are keyed by exact position: after a crop the coloured guide fell back to the preference colour and a guide that landed on its old place took the colour. Map the entries with the same expressions as the guides (swap orientation on quarter turns, clear on arbitrary angles). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 9 +++++++++ crates/engine/src/canvas_geom/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 76142f9a75..99f7e4cc80 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -156,15 +156,24 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if m1.abs() < EPS && m2.abs() < EPS { g.horizontal.iter_mut().for_each(|y| *y = m3 * *y + ty); g.vertical.iter_mut().for_each(|x| *x = m0 * *x + tx); + // Colours are keyed by exact position, so they move by the same expressions. + for c in &mut g.colors { + c.position = if c.vertical { m0 * c.position + tx } else { m3 * c.position + ty }; + } } else if m0.abs() < EPS && m3.abs() < EPS { // A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa. let v: Vec = g.horizontal.iter().map(|y| m2 * y + tx).collect(); let h: Vec = g.vertical.iter().map(|x| m1 * x + ty).collect(); g.horizontal = h; g.vertical = v; + for c in &mut g.colors { + c.position = if c.vertical { m1 * c.position + ty } else { m2 * c.position + tx }; + c.vertical = !c.vertical; + } } else { g.horizontal.clear(); g.vertical.clear(); + g.colors.clear(); } } diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index f27d9756fd..0dbf03d2ef 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -263,6 +263,28 @@ fn canvas_size_and_crop_move_vector_geometry() { assert!(mean < 1.0 / 255.0, "{mean}"); } +#[test] +fn guide_colours_follow_their_guides() { + const MAGENTA: [u8; 3] = [255, 0, 255]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "#ff00ff"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + // Crop shifts both guides by 10: the colour goes with the guide at 20, not to the one now there. + s.execute("image.crop", json!({"x": 10, "y": 0, "width": W - 10, "height": H})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.vertical, vec![10.0, 20.0]); + assert_eq!((g.color(true, 10.0), g.color(true, 20.0)), (Some(MAGENTA), None)); + // A quarter turn makes it a horizontal guide at y = 10, still magenta. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.horizontal, vec![10.0, 20.0]); + assert_eq!((g.color(false, 10.0), g.color(true, 10.0)), (Some(MAGENTA), None)); + // An arbitrary angle drops the guides, so their colours go too. + s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap(); + assert!(doc(&s).guides.colors.is_empty()); +} + #[test] fn image_size_scales_vector_geometry() { let mut s = mixed(8, "rgb"); From fe64e5a2bde315793b36fc41879c8317c6d9142e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:31:49 +0200 Subject: [PATCH 0878/1452] Keep arbitrary canvas rotation interpolation in ATN export (#7630) Fixes #7504 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 21 +++++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ffa29aec14..621b5bb4a8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2135,7 +2135,11 @@ fn rotate_from(d: &Descriptor) -> Mapped { } else if targets_layer(d) { return Mapped::Unsupported { harmless: false }; } else { - return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + let mut p = json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }}); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + p["interpolation"] = i.into(); + } + return step("image.rotation.arbitrary", p); }; none(if targets_layer(d) { layer } else { doc }) } @@ -2155,7 +2159,20 @@ fn rotate_to(id: &str, p: &Value) -> Option { if layer { d = d.with("null", target_ref("Lyr ")); } - Some(d.with("Angl", ang(angle))) + d = d.with("Angl", ang(angle)); + if id == "image.rotation.arbitrary" + && let Some(i) = p.get("interpolation") + { + // The command resamples with nearest, bilinear or bicubic; any other value can't be + // carried faithfully, so refuse rather than replay with a different interpolation. + let i = match i.as_str()? { + "nearestNeighbor" => "nearest", + i @ ("bicubic" | "bilinear" | "nearest") => i, + _ => return None, + }; + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) } fn flip_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 1b91235428..b19dc25d8b 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1199,6 +1199,25 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } +/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it +/// silently replays the rotation with the default bicubic resampling. +#[test] +fn arbitrary_rotation_keeps_interpolation_through_atn() { + for params in [ + json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}), + json!({"angle": 17, "direction": "ccw"}), + ] { + let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]); + } +} + /// A filter recorded against a layer mask must not export as a plain filter that edits the /// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. #[test] From c78ec47f4bcc2d1a49086bcbc8a6d0d8bdce8ee3 Mon Sep 17 00:00:00 2001 From: zrsix1 <139825666+zrsix1@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:31:56 -0300 Subject: [PATCH 0879/1452] Type: a click inside the paragraph box off the glyphs moves the caret (#7606) While editing paragraph text, a click inside the box that missed the glyphs (left of a line's start, or below the last line) committed the edit. Like Photoshop, it now places the caret at the nearest position; a click outside the box still commits. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 13 ++++++++++++- crates/ui-egui/src/type_tool_tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d07b8bb161..ef021438f3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -237,6 +237,15 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session})); } +/// True when document point (`x`, `y`) lies inside the paragraph box of type layer `id` (false +/// for point text). +fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { + let Some((bx, by, w, h)) = box_shape(app, id) else { return false }; + let Some((_, aff, _)) = layout(app, id) else { return false }; + let (tx, ty) = to_text(&aff, x, y); + tx >= bx && tx <= bx + w && ty >= by && ty <= by + h +} + /// Pointer down with the Type tool. Returns true when the press was consumed (no box drag). pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) -> bool { if let Some(ed) = app.ui.text_edit.clone() { @@ -247,7 +256,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - if hit_layer(app, x, y) == Some(id) { + // Photoshop: anywhere inside the paragraph box places the caret, even off the glyphs + // (left of a line's start, below the last line); only a click outside it commits. + if hit_layer(app, x, y) == Some(id) || inside_box(app, id, x, y) { let off = hit_offset(app, id, x, y); if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 20e59a01d6..9b840236f0 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -964,6 +964,33 @@ fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { } } +/// Editing paragraph text, a click inside the box but off the glyphs moves the caret to the +/// nearest offset, as in Photoshop; a click outside the box still commits. +#[test] +fn click_inside_the_paragraph_box_off_the_glyphs_moves_the_caret() { + use crate::canvas::{ToolEvent, tool_event}; + // The box is 240 × 180; the two 32 pt lines only fill its top. + let (mut app, id) = transform_app(true, false, Affine::translate(400.0, 350.0)); + let (bx, by, _, h) = super::box_shape(&app, id).unwrap(); + let (l, aff, _) = layout(&mut app, id).unwrap(); + let click = |app: &mut PhotocraftApp, tx: f32, ty: f32| { + let p = aff.apply(Point::new(f64::from(tx), f64::from(ty))); + tool_event(app, ToolEvent::Down { x: p.x, y: p.y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: p.x, y: p.y }, Modifiers::NONE); + }; + let layers = app.session.active().unwrap().doc.layers.len(); + // Near the bottom of the box, just right of its left edge. + assert!(!photocraft_text::text_point_inside(&l, bx + 8.0, by + h - 8.0, 6.0), "the point is off the glyphs"); + click(&mut app, bx + 8.0, by + h - 8.0); + let ed = app.ui.text_edit.clone().expect("still editing"); + // "Hello é世界\n" is 10 characters: the start of the second line. + assert_eq!((ed.layer, ed.caret, ed.anchor), (id.0, 10, 10)); + // Outside the box the edit commits, without starting new text. + click(&mut app, bx - 40.0, by + h - 8.0); + assert!(app.ui.text_edit.is_none()); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); +} + #[test] fn temporary_type_transform_noop_cancel_and_stale_capture() { use crate::canvas::{ToolEvent, tool_event}; From 4252fb7743cabbb2e41db9b5c65af7ef59fedde5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:32:07 +0200 Subject: [PATCH 0880/1452] Presets: Migrate Presets reads tool presets like the launch loader (#7375) (#7376) Migrate Presets deserialized the incoming tool presets all or nothing, so one damaged entry dropped every good one, and it skipped the checks the launch loader applies: blank or overlong names were imported and then lost on restart, and a large file pushed the library past MAX_PRESETS. Read the list with `presets::tools::load` and stop at MAX_PRESETS. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/migrate_cmds.rs | 52 ++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs index 18d2be8017..68fbffd25b 100644 --- a/crates/engine/src/migrate_cmds.rs +++ b/crates/engine/src/migrate_cmds.rs @@ -40,12 +40,14 @@ fn migrate(s: &mut Session, p: &Value) -> Result { let st = merge_groups(&mut ps.styles, groups(&v, "styles")); let sh = merge_groups(&mut ps.shapes, groups(&v, "shapes")); let pt = merge_groups(&mut ps.pattern_groups, groups(&v, "pattern_groups")); - // Tool presets are a flat list, matched by name. + // Tool presets are a flat list, matched by name. Read them like the launch loader does (entry by + // entry, checked names), and keep the library within its cap, so what we import survives a restart. let mut tp = 0usize; - if let Some(arr) = v.get("tool_presets").cloned() - && let Ok(incoming) = serde_json::from_value::>(arr) - { - for t in incoming { + if let Some(arr) = v.get("tool_presets").cloned() { + for t in crate::presets::tools::load(arr) { + if ps.tool_presets.len() >= crate::presets::tools::MAX_PRESETS { + break; + } if !ps.tool_presets.iter().any(|e| e.name == t.name) { ps.tool_presets.push(t); tp += 1; @@ -151,6 +153,46 @@ mod tests { let _ = std::fs::remove_file(file); } + #[test] + fn tool_presets_are_read_entry_by_entry_like_the_launch_loader() { + use crate::presets::tools::MAX_PRESETS; + let file = std::env::temp_dir().join(format!("pc-migrate-tools-{}.json", std::process::id())); + let migrate = |s: &mut Session, data: Value| { + std::fs::write(&file, serde_json::to_string(&data).unwrap()).unwrap(); + s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap() + }; + // A damaged entry no longer discards the good ones; blank and overlong names are skipped. + let mut s = Session::new(); + let before = s.presets.tool_presets.len(); + let r = migrate( + &mut s, + json!({"tool_presets": [ + {"name": "4x6 print", "tool": "crop", "options": {}}, + {"name": "old", "tool": null}, + {"name": " ", "tool": "crop"}, + {"name": "x".repeat(300), "tool": "crop"}, + {"name": " Padded ", "tool": "brush", "options": 5}, + ]}), + ); + assert_eq!(r["added"]["toolPresets"], 2); + assert_eq!(s.presets.tool_presets.len(), before + 2); + assert!(s.presets.tool_presets.iter().any(|t| t.name == "4x6 print")); + let padded = s.presets.tool_presets.iter().find(|t| t.name == "Padded").expect("trimmed name"); + assert_eq!(padded.options, json!({})); + // What was imported survives the next launch. + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.presets.tool_presets, s.presets.tool_presets); + // The library stays within MAX_PRESETS. + let mut s = Session::new(); + let many: Vec = (0..MAX_PRESETS + 500).map(|i| json!({"name": format!("p{i}"), "tool": "brush"})).collect(); + let before = s.presets.tool_presets.len(); + let r = migrate(&mut s, json!({"tool_presets": many})); + assert_eq!(s.presets.tool_presets.len(), MAX_PRESETS); + assert_eq!(r["added"]["toolPresets"], MAX_PRESETS - before); + let _ = std::fs::remove_file(file); + } + #[test] fn bad_path_is_an_error() { let mut s = Session::new(); From 8c2cfdba6ae43a850b76fa56e44501058aeed8c6 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:32:14 +0300 Subject: [PATCH 0881/1452] fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) (#6941) * fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) * test(linux): cover the XDG state recovery folder (#6612) --- apps/photocraft/src/app_dirs.rs | 71 +++++++++++++++++++++++++++------ apps/photocraft/src/services.rs | 7 ++-- docs/control-protocol.md | 4 +- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 39d90f07bb..a75520f3c4 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -10,8 +10,8 @@ //! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows //! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`. //! -//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the -//! roaming profile ([`recovery_dir`]). +//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced +//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option { }) } -/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every -/// few minutes, so on Windows the platform convention puts them in the machine-local -/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and -/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before -/// a crashed document is recovered. The override and portable folders keep everything in one -/// place, and other platforms already keep these folders local. +/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few +/// minutes, so with the platform convention they stay out of what is synced with the settings: +/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming +/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy. +/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default +/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that +/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy +/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored. +/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put +/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the +/// system. +/// +/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed +/// document is recovered. The override and portable folders keep everything in one place. pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { - if cfg!(windows) - && data.mode == Mode::Platform - && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) - { - return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + if data.mode == Mode::Platform { + if cfg!(windows) { + if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + } else if !cfg!(target_os = "macos") { + let state = env("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|s| s.is_absolute()) + .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state"))); + if let Some(state) = state { + return Some(state.join("photocraft").join(RECOVERY)); + } + } } data.dir.as_ref().map(|d| d.join(RECOVERY)) } @@ -623,6 +640,34 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default + /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows + /// and macOS don't read it, and an override or portable folder keeps them inside itself. + #[test] + fn autosaves_go_to_the_xdg_state_folder() { + let root = temp("recovery-xdg"); + let settings = root.join("home").join(".config").join("photocraft"); + let home = root.join("home"); + let state = root.join("state"); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let xdg = !cfg!(windows) && !cfg!(target_os = "macos"); + let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") }); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery"))); + let default = home.join(".local").join("state").join("photocraft").join("Recovery"); + assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone())); + let relative = PathBuf::from("relative/state"); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone())); + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default)); + // No home at all: beside the settings, as before. + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + /// An autosave bundle and its sidecar, as `format::Autosaver` writes them. fn autosave(dir: &Path, key: &str) { std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap(); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 20de0d6be8..80f0b2d3a1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -293,7 +293,7 @@ fn block_on(future: impl Future) -> T { /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in /// portable mode, else the platform convention. Everything the app persists lives under it -/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. +/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -308,8 +308,9 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } -/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay -/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]). +/// Crash-recovery autosaves: under the settings directory, except where that is synced with the +/// settings: the Windows roaming profile and the Linux config folder (see +/// [`crate::app_dirs::resolve_recovery`]). fn recovery_dir() -> Option { crate::app_dirs::recovery_dir() } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6d5c9591d..78f44b07d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -248,7 +248,9 @@ The desktop app stores them in `preferences.json` in the platform config directo `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its `Recovery` folder (on Windows to `%LOCALAPPDATA%\Photocraft\Recovery`, out of the roaming -profile; autosaves left by earlier versions move there at startup), and the window size and +profile, and on Linux to `$XDG_STATE_HOME/photocraft/Recovery`, by default +`~/.local/state/photocraft/Recovery`, out of the config folder; autosaves left by earlier +versions move there at startup), and the window size and position, the dock width and floating-panel geometry to `ui.ron` beside it (a file holding values that would crash startup is discarded). In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the From e1c209eadb58ad5a84588513bd00089fbfe3ed50 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 04:32:21 +1100 Subject: [PATCH 0882/1452] Hide the Hyphenate checkbox until layout hyphenates (#6710) (#6869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Hide the Hyphenate checkbox until layout actually hyphenates (#6710). The flag still round-trips to PSD and ⌥⇧⌘H still toggles it, but the Paragraph and Properties panels no longer advertise a no-op. * Assert Hyphenate stays hidden until layout hyphenates (#6710). The Paragraph properties and paragraph style options editor now have tests that fail if the no-op control is shown again. * Drop the duplicate #[test] on hyphenate_is_not_shown (#6710). Clippy -D warnings failed on duplicate-macro-attributes and unused mut on the harness that only queries the label. --- crates/ui-egui/src/type_panels_ui.rs | 23 ++++++++++++++++++++++- crates/ui-egui/src/type_tool.rs | 5 +---- crates/ui-egui/src/type_tool_tests.rs | 11 ++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index d97a649fba..86ef663209 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -443,7 +443,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s (tl!("Indent Right"), "end_indent_pt", "pt"), (tl!("Space Before"), "space_before_pt", "pt"), (tl!("Space After"), "space_after_pt", "pt"), - (tl!("Hyphenate"), "hyphenate", "bool"), + // Hyphenate is stored and round-trips to PSD, but layout never hyphenates (#6710). ]; for &(label, key, kind) in rows { let mut on = defined(¶_attrs, key); @@ -969,4 +969,25 @@ mod tests { assert!(h.state().ui.type_panels.spell.is_none(), "the × closed Check Spelling"); assert_eq!(h.state().session.active().unwrap().revision, revision, "and changed nothing"); } + + #[test] + fn hyphenate_is_not_shown_in_paragraph_style_options() { + // #6710: layout never hyphenates; the style editor must not advertise it. + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Hi"); + app.ui.type_panels.styles = true; + app.ui.type_panels.styles_tab = 1; + app.ui.type_panels.options = Some((true, 0)); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + windows(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + assert!(h.query_by_label("Hyphenate").is_none()); + } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index ef021438f3..ee29a9e1d3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2398,10 +2398,7 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); - let mut hy = para.hyphenate; - if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() { - apply(app, ui.ctx(), json!({"hyphenate": hy})); - } + // Hyphenate is stored (⌥⇧⌘H / PSD AutoHyphenate) but layout never breaks words (#6710). ui.add_space(ROW_GAP); } } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 9b840236f0..5cbca3e2b4 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1578,6 +1578,14 @@ mod multi_layer_formatting { h.run(); } + #[test] + fn hyphenate_is_not_shown_while_layout_does_not_hyphenate() { + // #6710: layout never hyphenates; the control must stay hidden until it does. + let (app, _) = fixture(); + let h = controls(app, true); + assert!(h.query_by_label("Hyphenate").is_none()); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); @@ -1595,8 +1603,6 @@ mod multi_layer_formatting { h.run(); h.get_by_label("Center text").click(); h.run(); - h.get_by_label("Hyphenate").click(); - h.run(); number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); @@ -1605,7 +1611,6 @@ mod multi_layer_formatting { assert!(c.faux_bold); let p = text(h.state(), id).paragraph_runs()[0].style.clone(); assert_eq!(p.align, TextAlign::Center); - assert!(p.hyphenate); assert_eq!(p.start_indent_pt, 9.0); } assert_eq!(style(h.state(), ids[2], 0), untouched); From daa9abfe43803cf055e08b6d24a9919d8e10fbd0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:32:29 +0200 Subject: [PATCH 0883/1452] Disable Save for Web while a Free Transform is pending (#6737) (#6832) --- crates/ui-egui/src/menus.rs | 6 ++++++ crates/ui-egui/src/transform_tool.rs | 26 ++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5928d844b7..ea94379685 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -155,6 +155,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::prefs_ui::invoke(app, ctx, id, ¶ms) { return r; } + // Save for Web's dialog would export the committed document, not the open Free Transform (#6737). + if id == crate::transform_tool::SAVE_FOR_WEB + && let Some(r) = crate::transform_tool::intercept(app, id) + { + return r; + } // Save for Web, Print and the other File-menu dialogs added with slices. if let Some(r) = crate::file_ui::invoke(app, ctx, id, ¶ms) { return r; diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index bf0c4d8e87..7f4238ae82 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1250,6 +1250,9 @@ pub(crate) fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) { } } +/// Save for Web (Legacy): refused under an open box, as it would export without the preview (#6737). +pub(crate) const SAVE_FOR_WEB: &str = "file.export.saveForWebLegacy"; + /// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { @@ -1266,6 +1269,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())), // No Toggle Last State or history jump under the box, and one box at a time. "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), + // Save for Web exports the committed document and would ignore the pending box (#6737). + SAVE_FOR_WEB => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). @@ -1305,7 +1310,7 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// `None` for other commands or when not transforming. /// The commands the open box answers itself ([`is_enabled`], [`intercept`]). fn box_command(id: &str) -> bool { - matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB) || box_preset(id).is_some() } pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { @@ -1316,7 +1321,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), - "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => { + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB => { Some(Err("Commit or cancel the transform first".into())) } _ => { @@ -3975,6 +3980,23 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); } + /// #6737: Save for Web exports the committed document, so it is off while a box is pending. + #[test] + fn save_for_web_is_off_under_a_pending_transform() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "enabled with no box"); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap(); + assert!(!crate::menus::is_enabled(&app, SAVE_FOR_WEB), "off under the box"); + assert!(crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({})).is_err(), "the dialog doesn't open"); + let path = std::env::temp_dir().join(format!("pc-web6737-{}.png", std::process::id())).to_string_lossy().into_owned(); + let r = crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({"path": path, "format": "png24"})); + assert!(r.is_err() && !std::path::Path::new(&path).exists(), "nor does the export run"); + assert!(app.ui.transform.is_some(), "the box stays open"); + cancel(&mut app); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "back after the box closes"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. From a0128945056a58cf094a95b685d402f1c8f30d18 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:32:35 +0200 Subject: [PATCH 0884/1452] Layer Mask Properties: the Feather slider has fine steps near 0 px, as in Photoshop (#6545) (#6727) --- crates/ui-egui/src/adjust_editors.rs | 4 +- crates/ui-egui/src/filter_dialog.rs | 2 +- crates/ui-egui/src/mask_props_ui.rs | 8 +++- crates/ui-egui/src/mask_props_ui_tests.rs | 28 +++++++++++- crates/ui-egui/src/widgets.rs | 54 ++++++++++++++++++++++- 5 files changed, 87 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index fa32a3b392..0c4d5ae268 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -216,7 +216,7 @@ fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). - let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { v[key] = json!(row_value(x, min, max, decimals)); @@ -1095,7 +1095,7 @@ fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ]; // Photoshop: a double-click on a slider zeroes it (this tone only); the dialog's sliders step // with the wheel; Up/Down step the fields; Shift makes those steps 10. - let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0) }; + let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0), ..Default::default() }; let mut changed = false; for (i, (text, a, b)) in rows.iter().enumerate() { let r = Edit::of(&widgets::slider_row_with(ui, text, &mut vals[i], -100.0..=100.0, "", Some(&[*a, *b]), gestures)); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 14f24bc52e..effb52694d 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -811,7 +811,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.add_space(4.0); } else { // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); } }); diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 295310bf97..26c3791d5a 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -66,8 +66,12 @@ fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: u64, document: u64 } g.0 }); - for (field, label, mut value, max, unit) in [("density", tl!("Density"), density * 100.0, 100.0, "%"), ("feather", tl!("Feather"), feather, 1000.0, "px")] { - let response = widgets::slider_row(ui, label, &mut value, 0.0..=max, unit, None); + // Feather's track is curved like Photoshop's, so a few px can be dragged to (#6545). + for (field, label, mut value, max, unit, curve) in + [("density", tl!("Density"), density * 100.0, 100.0, "%", None), ("feather", tl!("Feather"), feather, 1000.0, "px", Some(3.0))] + { + let gestures = widgets::RowGestures { curve, ..Default::default() }; + let response = widgets::slider_row_with(ui, label, &mut value, 0.0..=max, unit, None, gestures); response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(value), label)); if response.changed() { let mut params = json!({"document": document, "layer": layer, diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1cde904682..660b94a155 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -94,8 +94,32 @@ fn mask_slider_drags_preview_live_and_undo_as_separate_gestures() { h.state_mut().run("edit.redo", json!({})).unwrap(); assert!((mask(&h).density - 0.5).abs() < 0.01); h.run_steps(3); - drag(&mut h, 1, &[0.0, 0.01, 0.02]); - assert!((mask(&h).feather - 20.0).abs() < 0.1); + // #6545: Feather's track is curved like Photoshop's, so a fifth of the way is 8 px, not 200. + drag(&mut h, 1, &[0.0, 0.1, 0.2]); + assert!((mask(&h).feather - 8.0).abs() < 0.3, "feather {}", mask(&h).feather); +} + +/// #6545: the curve is only the track's. A typed Feather and the arrow keys stay exact px, and +/// drawing the knob for them never writes the value back. +#[test] +fn typed_and_stepped_feather_stays_exact() { + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(mask(&h).feather, 5.0); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(3); + assert_eq!(mask(&h).feather, 6.0); + let before = h.state().session.active().unwrap().history.past_len(); + h.run_steps(5); + assert_eq!(mask(&h).feather, 6.0); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before, "idle frames write nothing"); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b393c0787d..19f47830d9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -926,6 +926,34 @@ pub struct RowGestures { /// Each wheel notch over the slider moves the value by this step (up = higher; ×10 with /// Shift). Photoshop does this in dialogs, not in the Properties panel. pub wheel_step: Option, + /// A curved track: the knob at fraction f of the track is `lo + (hi - lo) * f^k`, so small + /// values get fine steps (Photoshop's mask Feather, #6545). Typed, scrubbed and stepped + /// values stay linear; only the track maps through the curve. + pub curve: Option, +} + +/// The value at fraction `f` of a track over `lo..=hi`, through `curve` (see [`RowGestures`]). +pub(crate) fn curve_value(f: f32, lo: f32, hi: f32, curve: Option) -> f32 { + match curve { + Some(k) if k.is_finite() && k > 0.0 => lo + f.clamp(0.0, 1.0).powf(k) * (hi - lo), + _ => lo + f * (hi - lo), + } +} + +/// Where `v` sits on a track over `lo..=hi`, as a fraction: the inverse of [`curve_value`]. +pub(crate) fn curve_fraction(v: f32, lo: f32, hi: f32, curve: Option) -> f32 { + let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0); + match curve { + // An unset (NaN) value draws the knob at the start rather than nowhere. + Some(k) if k.is_finite() && k > 0.0 => { + if f.is_finite() { + f.powf(1.0 / k) + } else { + 0.0 + } + } + _ => f, + } } /// Wheel notches this frame while the pointer is over `resp` (Shift ×10). Wheel lines add up in @@ -1010,7 +1038,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive f32::EPSILON { *value = nv; resp.mark_changed(); @@ -1033,7 +1061,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive {back}"); + let v = i as f32 * 10.0; + let again = curve_value(curve_fraction(v, 0.0, 1000.0, Some(3.0)), 0.0, 1000.0, Some(3.0)); + assert!((again - v).abs() < 1e-3, "{v} -> {again}"); + } + assert_eq!(curve_fraction(-5.0, 0.0, 1000.0, Some(3.0)), 0.0); + assert_eq!(curve_fraction(f32::NAN, 0.0, 1000.0, Some(3.0)), 0.0); + } + #[test] fn arrow_keys_work_out_a_typed_sum_then_step_it() { let mut h = focused(100.0, 0.0..=1000.0); From 80cd36846e51e1cc44ebb04440490139141436af Mon Sep 17 00:00:00 2001 From: Joseph <56056764+00xJS@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:32:42 -0700 Subject: [PATCH 0885/1452] GPU canvas: redraw mip levels of 128 px or less whole; fixes AMD Vulkan corruption after partial updates (#6418) (#6438) * GPU canvas: redraw mip levels of 128 px or less whole; AMD Vulkan corrupted them after partial updates (#6418) On an RX 6900 XT with Vulkan (driver 26.8.1), a regenerate_mips pass that drew only the damaged part of a small mip level corrupted texels outside its scissor, at the level's top-left; DX12 on the same card was fine. A sweep of 15 canvas sizes x 8-bit/float x 6 dabs found 66 of 180 cases corrupt on Vulkan, every corrupted level at most 64 px; sampling the levels in a shader shows the same pixels, so it is the texture contents, not the readback. mip_damage (regenerate_mips's damage arithmetic) now redraws levels of at most WHOLE_MIP_SIDE (128 px) whole on every adapter: no new passes or textures, only a larger scissor on the smallest levels. With it the sweep is clean on both backends. Tests: small_mip_levels_are_redrawn_whole (the rule, no GPU) and partial_updates_match_a_full_rebuild_at_every_mip_level (fails on the Radeon over Vulkan without the change, passes with it, like canvas_mips_preserve_opaque_pixels_and_partial_updates). * contributors: add 00xJS (GitHub profile name) * Mip sweep test: hold the GPU lock like the other GPU tests (#6438 review) --------- Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com> --- contributors/people.toml | 3 ++ crates/ui-egui/src/gpu_canvas.rs | 33 ++++++++++++++-- crates/ui-egui/src/gpu_canvas_mip_tests.rs | 44 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 102c69e66c..0860ea17db 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -172,3 +172,6 @@ sync_github_name = false [people.Byxis] sync_github_name = true + +[people.00xJS] +sync_github_name = true diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 65388d5049..7ec4c192b2 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -36,6 +36,10 @@ pub use photocraft_gpu::{DeviceHealth, Fault}; /// Default tile side; documents up to this size (and the device limit) use a single texture. pub const DEFAULT_TILE: u32 = 8192; +/// Mip levels no larger than this on either side are redrawn whole on every edit rather than +/// just their damage (see [`mip_damage`], #6418). Every corrupted level measured was at most +/// 64 px; this leaves a factor of two. +const WHOLE_MIP_SIDE: u32 = 128; const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Canvas texture format of high-bit documents (see [`GpuCanvas::format_for`]). const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -1847,6 +1851,20 @@ fn mip_count(w: u32, h: u32) -> u32 { 32 - w.max(h).max(1).leading_zeros() } +/// The rect (x0, y0, x1, y1) of a `lw`×`lh` mip level to redraw when `prev` changed in the level +/// above: that damage halved and padded by one texel for odd-size rounding, or the whole level +/// when it is at most [`WHOLE_MIP_SIDE`] on either side. On AMD's Vulkan driver a pass that draws +/// only part of a level of 64 px or less corrupts texels outside its scissor, at the level's +/// top-left (#6418: RX 6900 XT, driver 26.8.1; DX12 on the same card is fine). Such a level costs +/// next to nothing to redraw, so this holds for every adapter; the levels after it are smaller +/// still, so the rest of the chain is redrawn whole too. +fn mip_damage([x0, y0, x1, y1]: [u32; 4], lw: u32, lh: u32) -> [u32; 4] { + if lw.max(lh) <= WHOLE_MIP_SIDE { + return [0, 0, lw, lh]; + } + [(x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1), (x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)] +} + /// A buffer holding `contents`, like `DeviceExt::create_buffer_init` but without its panic when /// the device was lost (an invalid buffer has no mapped range): the buffer is then just invalid. fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { @@ -1944,9 +1962,7 @@ impl DocTextures { (x0, y0, x1, y1) = (x0 - tx, y0 - ty, x1 - tx, y1 - ty); for level in 1..t.levels.len() { let (lw, lh) = ((tw >> level).max(1), (th >> level).max(1)); - // Damage at this level, padded by one texel for odd-size rounding. - (x0, y0) = ((x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1)); - (x1, y1) = ((x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)); + [x0, y0, x1, y1] = mip_damage([x0, y0, x1, y1], lw, lh); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_mip"), layout: &res.mip_bgl, @@ -2567,6 +2583,17 @@ mod tests { assert!(separate_mip_targets(Dx12, intel, Some("yes"))); } + /// #6418: levels of at most WHOLE_MIP_SIDE are redrawn whole; larger ones keep the damage. + #[test] + fn small_mip_levels_are_redrawn_whole() { + // A 3 × 3 dab at (800, 500) of a 1791 × 1083 tile, at mip 1 (895 × 541): halved and padded. + assert_eq!(mip_damage([800, 500, 803, 503], 895, 541), [399, 249, 403, 253]); + // At most 128 px on either side: the whole level, wherever the damage is. + assert_eq!(mip_damage([100, 60, 102, 63], 111, 67), [0, 0, 111, 67]); + assert_eq!(mip_damage([0, 0, 1, 1], 128, 2), [0, 0, 128, 2]); + assert_eq!(mip_damage([0, 0, 1, 1], 129, 2), [0, 0, 2, 2]); + } + #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index 82e911bae9..b9eac3e0b9 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -102,3 +102,47 @@ fn check_mips(gpu: &GpuCanvas) { } } } + +/// #6418: on AMD's Vulkan driver, a pass that drew only part of a small mip level corrupted texels +/// outside its scissor, at the level's top-left (RX 6900 XT, driver 26.8.1). Whether a texture is +/// hit depends on where the driver placed it, so this paints dabs on canvases of several sizes at +/// both depths and compares every level with a full rebuild of the same pixels. +#[test] +fn partial_updates_match_a_full_rebuild_at_every_mip_level() { + let _gpu = photocraft_testkit::gpu_lock(); + let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default()))); + let Ok(rs) = rs else { + eprintln!("skipping mip rebuild comparison: no GPU adapter"); + return; + }; + let gpu = GpuCanvas::new(&rs); + for (depth, w, h) in [(SampleType::U8, 255, 255), (SampleType::U8, 1024, 768), (SampleType::F32, 512, 512), (SampleType::F32, 1791, 1083)] { + let mut full = Buffer::filled(Rect::from_xywh(0, 0, w, h), [0.0; 4]); + for (i, px) in full.px.iter_mut().enumerate() { + let (x, y) = (i as u32 % w, i as u32 / w); + *px = [x as f32 / w as f32, y as f32 / h as f32, ((x + y) % 7) as f32 / 7.0, 1.0]; + } + gpu.upload_buffer_full(1, &full, depth); + let dabs = [Rect::from_xywh((w / 2) as i32, (h / 2) as i32, w / 3, h / 3), Rect::from_xywh(w as i32 - 1, h as i32 - 1, 1, 1)]; + for (dab, color) in dabs.into_iter().zip([[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]) { + assert!(gpu.upload_buffer_rect(1, &Buffer::filled(dab, color))); + for y in dab.y0..dab.y1 { + for x in dab.x0..dab.x1 { + full.px[y as usize * w as usize + x as usize] = color; + } + } + gpu.upload_buffer_full(2, &full, depth); + for (level, (partial, rebuilt)) in read_mips(&gpu, 1).iter().zip(&read_mips(&gpu, 2)).enumerate() { + let lw = (w >> level).max(1) as usize; + for (i, (a, b)) in partial.iter().zip(rebuilt).enumerate() { + assert!( + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.005), + "{depth:?} {w}x{h} dab {dab:?} mip {level} at ({}, {}): {a:?} != {b:?}", + i % lw, + i / lw + ); + } + } + } + } +} From 3cafc636564f799b17e3d9eb9d627cb8ea1fcfa0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:32:51 +0200 Subject: [PATCH 0886/1452] Free Transform: W and H take a typed length in percent view (#6284) (#6293) W and H showing percent rejected a typed length ("40px", "2 in", "5cm"), since the shared unit parser has no length to convert a percentage against. The fields now carry the box's original side as the length 100 % stands for, so a typed length becomes that share of it at the document's resolution, and "50%" typed into a length view is half the original side. The field keeps its chosen unit; the aspect link and the scale limits apply as before. Other percent fields (opacity, fill) still reject lengths. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/numeric_expression.rs | 59 +++++++++++++-- crates/ui-egui/src/transform_tool.rs | 94 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 27 ++++++- 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/numeric_expression.rs b/crates/ui-egui/src/numeric_expression.rs index a3232dd96f..6c7f96393c 100644 --- a/crates/ui-egui/src/numeric_expression.rs +++ b/crates/ui-egui/src/numeric_expression.rs @@ -19,6 +19,10 @@ pub struct Units<'s> { pub dpi: f64, /// Points per inch (72 PostScript, 72.27 traditional). pub ppi: f64, + /// The length in pixels that 100 % stands for, in a field that is both (Free Transform's W + /// and H, #6284): a length then converts into its percent and a percentage into its length. + /// `None` (most fields) keeps lengths and percentages apart. + pub extent: Option, } /// Unit names a number may carry, longest first so `pica` wins over `pc`. @@ -34,8 +38,9 @@ const UNIT_NAMES: [(&str, Unit); 8] = [ ]; /// [`parse`] where a number may carry a unit (`2in`, `5 mm`, `50%`), converted into the field's -/// unit. A unit that can't convert (a length in a percent field, any unit in a unitless one) makes -/// the whole text invalid; without `units`, unit names are invalid as before. +/// unit. A unit that can't convert (a length in a percent field without an [`Units::extent`], any +/// unit in a unitless one) makes the whole text invalid; without `units`, unit names are invalid +/// as before. pub(crate) fn parse_with_units(text: &str, units: Option) -> Option { if text.len() > 1024 { return None; @@ -168,7 +173,13 @@ impl Parser<'_> { Some(field) if field == typed => Some(value), // Lengths convert through pixels; a percentage has no length to convert against. Some(field) if field != Unit::Percent && typed != Unit::Percent => Some(field.from_px(typed.to_px(value, dpi, 1.0, ppi), dpi, 1.0, ppi)), - _ => None, + // A field whose 100 % is a length: convert through that length's pixels. + Some(field) => { + let extent = units.extent.map(f64::abs).filter(|e| e.is_finite() && *e > 1e-9)?; + let v = field.from_px(typed.to_px(value, dpi, extent, ppi), dpi, extent, ppi); + v.is_finite().then_some(v) + } + None => None, } } } @@ -206,10 +217,10 @@ mod tests { #[test] fn units_convert_into_the_field_unit_and_bad_input_is_rejected() { use super::{Unit, Units, parse_with_units}; - let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0 }; - let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0 }; - let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0 }; - let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0 }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0, extent: None }; + let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0, extent: None }; + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0, extent: None }; + let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0, extent: None }; for (text, units, want) in [ ("1in", px, 300.0), ("2.54cm", px, 300.0), @@ -255,6 +266,40 @@ mod tests { } assert_eq!(parse("1in"), None, "without a field unit, unit names stay invalid"); } + #[test] + fn a_field_with_an_extent_converts_between_lengths_and_percent() { + use super::{Unit, Units, parse_with_units}; + // Free Transform's W at 300 ppi for a 600 px side (#6284): 100 % is 600 px, 2 in. + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 300.0, ppi: 72.0, extent: Some(600.0) }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", ..pct }; + let inches = Units { unit: Some(Unit::Inches), suffix: "in", ..pct }; + for (text, units, want) in [ + ("300px", pct, 50.0), + ("1in", pct, 50.0), + ("2in*2", pct, 200.0), + ("2.54cm", pct, 50.0), + ("-1in", pct, -50.0), + ("50%", pct, 50.0), + ("50", pct, 50.0), + ("50%", px, 300.0), + ("50%+10", px, 310.0), + ("150%", inches, 3.0), + ("10%3", px, 1.0), + ] { + let got = parse_with_units(text, Some(units)); + assert!(got.is_some_and(|v| (v - want).abs() < 1e-9), "{text}: {got:?}, want {want}"); + } + // A degenerate or hostile extent can't convert, and never divides by zero. + for extent in [Some(0.0), Some(f64::NAN), Some(f64::INFINITY), None] { + assert_eq!(parse_with_units("1in", Some(Units { extent, ..pct })), None, "{extent:?}"); + assert_eq!(parse_with_units("50%", Some(Units { extent, ..px })), None, "{extent:?}"); + } + assert_eq!(parse_with_units("1e308in", Some(Units { extent: Some(1e-6), ..pct })), None, "an overflow is invalid"); + // Other percent fields (opacity, fill …) still reject a length. + let opacity = Units { extent: None, ..pct }; + assert_eq!(parse_with_units("40px", Some(opacity)), None); + assert_eq!(parse_with_units("2in", Some(opacity)), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 7f4238ae82..7ae223029d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -2880,14 +2880,17 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (suffix, places) = (u.unit.suffix(), u.unit.decimals() as u32); // Lengths need more room than a percentage ("2116.67 mm"). let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; + // Whatever their unit, W and H take any other typed in (`500 px` or `2 in` in percent, `50%` + // in a length), converted at the document's resolution against the box's original side (#6284). + let units = crate::widgets::FieldUnits { unit: u.unit, dpi: u.dpi, ppi: u.ppi }; lbl(ui, "W:"); - let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); + let rw = crate::widgets::value_field_extent(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places, units, side_w); let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { app.ui.transform_unlinked = link; } lbl(ui, "H:"); - let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); + let rh = crate::widgets::value_field_extent(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places, units, side_h); for r in [&rw, &rh] { r.context_menu(|ui| { if let Some(name) = crate::rulers::unit_menu(ui, u.unit) { @@ -3514,6 +3517,93 @@ mod tests { assert_eq!(shown_size(&bar_harness(|_| {})), ["100", "100"]); } + /// Opens a document at `dpi` so the W/H fields convert typed lengths at its resolution. + fn at_dpi(app: &mut PhotocraftApp, dpi: f32) { + app.session.execute("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.session + .edit("resolution", |doc, _| { + doc.resolution_dpi = dpi; + Ok(()) + }) + .unwrap(); + } + + /// The box's width and height in px after a W/H edit. + fn box_size(t: &TransformSession) -> (f64, f64) { + (t.quad[1][0] - t.quad[0][0], t.quad[3][1] - t.quad[0][1]) + } + + #[test] + fn w_and_h_in_percent_take_a_typed_length() { + use photocraft_engine::prefs::Unit; + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // The 100 × 50 px box with W/H showing percent (#6284): a typed length is the box's new side. + for (text, px) in + [("40px", 40.0), ("40 px", 40.0), ("2in", 600.0), ("2 in", 600.0), ("5cm", 5.0 / 2.54 * 300.0), ("25.4mm", 300.0), ("72pt", 300.0), ("1pica", 50.0)] + { + // Linked: the height follows the width. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, px / 2.0), "{text} linked: {w} × {h}"); + // Unlinked: only the width changes. + let t = type_into_bar_field_with( + |app| { + at_dpi(app, 300.0); + app.ui.transform_unlinked = true; + }, + 2, + text, + ); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, 50.0), "{text} unlinked: {w} × {h}"); + } + // H takes a length too; linked, the width follows. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 3, "1in"); + let (w, h) = box_size(&t); + assert!(near(w, 600.0) && near(h, 300.0), "H 1in: {w} × {h}"); + // A plain number is still the percentage. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "40")); + assert!(near(w, 40.0), "40 is 40 % of 100 px: {w}"); + // A percentage typed into a length field is that share of the box's original side. + let px = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Pixels.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(px, 2, "50%")); + assert!(near(w, 50.0) && near(h, 25.0), "50% in px: {w} × {h}"); + let inches = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Inches.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(inches, 3, "300%")); + assert!(near(w, 300.0) && near(h, 150.0), "300% in inches: {w} × {h}"); + } + + #[test] + fn w_and_h_reject_or_clamp_hostile_typed_lengths() { + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // Text that is no number leaves the box alone. + for text in ["px", "in", "NaN", "%"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(near(w, 100.0) && near(h, 50.0), "{text}: {w} × {h}"); + } + // Invalid text after a number keeps only what applied while typing (a plain number applies + // per keystroke), never a NaN or a box past the largest document. + for text in ["1in/0", "1e400px", "2 inches", "1e308in"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(w.is_finite() && h.is_finite() && w.abs() <= f64::from(POSITION_LIMIT_PX) + 0.01, "{text}: {w} × {h}"); + } + // A huge length stops at the field's limit: the box stays within the largest document. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "1e9in")); + assert!(w.is_finite() && w <= f64::from(POSITION_LIMIT_PX) + 0.01 && h <= f64::from(POSITION_LIMIT_PX), "{w} × {h}"); + // A negative length flips, as a negative percentage does. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "-40px")); + assert!(near(w, -40.0), "{w}"); + // Zero collapses the side without dividing by zero. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "0px")); + assert!(w.is_finite() && h.is_finite(), "{w} × {h}"); + } + #[test] fn right_clicking_w_or_h_picks_their_unit_and_remembers_it() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 19f47830d9..0a91058387 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -350,6 +350,25 @@ pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInc value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units), ..Default::default() }).0 } +/// A [`value_field_prec`] in `units` whose 100 % is a length of `extent` px (Free Transform's W +/// and H): a length typed while it shows percent becomes that share of `extent` (`40px`, `2in`), +/// and a percentage typed while it shows a length becomes that share of `extent` (`50%`), at +/// `units`' resolution (#6284). The field keeps its own unit. +#[allow(clippy::too_many_arguments)] +pub fn value_field_extent( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + decimals: u32, + units: FieldUnits, + extent: f64, +) -> Response { + let opts = FieldOpts { decimals: Some(decimals), units: Some(units), extent: Some(extent), ..Default::default() }; + value_field_in(ui, value, range, suffix, width, 0.0, opts).0 +} + fn field_resolution_id() -> egui::Id { egui::Id::new("pc-field-resolution") } @@ -499,6 +518,8 @@ struct FieldOpts { decimals: Option, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, + /// The length (px) 100 % stands for, so lengths and percentages convert into each other. + extent: Option, wrap_drag: bool, } @@ -526,7 +547,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units, wrap_drag } = opts; + let FieldOpts { decimals, units, extent, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect).map(|s| { @@ -544,8 +565,8 @@ fn value_field_box( // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); let units = match units { - Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi }, - None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi }, + Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi, extent }, + None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi, extent }, }; surface(ui, rect, t.field, false); if !t.bevel { From e795411547dcdf95e9e579dbbffcec0ecf56c204 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:32:54 -0600 Subject: [PATCH 0887/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From 1c74da7baa4f5c9b0438889195300cbd0b1fded2 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 13:32:59 -0400 Subject: [PATCH 0888/1452] keep layer-based slices when duplicating a document (#6133) --- crates/engine/src/image_cmds.rs | 5 ++++ crates/engine/tests/duplicate_slices.rs | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 crates/engine/tests/duplicate_slices.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 92bbdd7463..805fbde55b 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -115,6 +115,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { def.layer = new_id; } } + for slice in &mut doc.slices.list { + if let Some(&new_id) = slice.layer.and_then(|layer| ids.get(&layer)) { + slice.layer = Some(new_id); + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/duplicate_slices.rs new file mode 100644 index 0000000000..5f5d21c621 --- /dev/null +++ b/crates/engine/tests/duplicate_slices.rs @@ -0,0 +1,35 @@ +use photocraft_doc::SliceOrigin; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn sliced() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "logo"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({"url": "https://example.com", "alt": "logo"})).unwrap(); + s +} + +fn layer_slice(s: &mut Session) -> Option { + let list = s.execute("slice.list", json!({})).unwrap(); + list["slices"].as_array().unwrap().iter().find(|sl| sl["origin"] == "layer").cloned() +} + +#[test] +fn duplicating_a_document_keeps_its_layer_based_slices() { + for cmd in ["image.duplicate", "history.newDocument"] { + let mut s = sliced(); + s.execute(cmd, json!({})).unwrap(); + let st = s.active().unwrap(); + let logo = st.doc.walk().into_iter().find(|(_, _, l)| l.name == "logo").unwrap().2.id; + let slices: Vec<_> = st.doc.slices.list.iter().filter(|sl| sl.origin == SliceOrigin::Layer).collect(); + assert_eq!(slices.len(), 1, "{cmd}"); + assert_eq!(slices[0].layer, Some(logo), "{cmd}"); + assert_eq!((slices[0].url.as_str(), slices[0].alt.as_str()), ("https://example.com", "logo"), "{cmd}"); + let before = layer_slice(&mut s).unwrap()["rect"].clone(); + s.execute("layer.translate", json!({"dx": 5, "dy": 3})).unwrap(); + let after = layer_slice(&mut s).unwrap_or_else(|| panic!("{cmd} lost the slice after a move"))["rect"].clone(); + assert_ne!(before, after, "{cmd}: the slice no longer follows its layer"); + } +} From 8c3c3237f412af5bd1ba9b340ce9192c407b6230 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 19 Oct 2026 18:33:06 +0100 Subject: [PATCH 0889/1452] refactor(engine): drop the second damage pass after single-edit duplicates and pastes (#5880) Session::edit has reported a new layer's bounds as damage since #3847, so Duplicate Layer, Duplicate Layers and Paste no longer need to keep the document from before the edit and work the damage out again with note_insert. Paste Into still does: it is three edits. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 -- crates/engine/src/edit_cmds.rs | 4 +--- crates/engine/src/layer_multi_cmds.rs | 18 +++++++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..9b4d4a7472 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -754,7 +754,6 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id)?; if let Some(name) = &name { @@ -767,7 +766,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 7cdb595212..aa2ce627f7 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -394,7 +394,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -403,8 +402,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..418b0051d0 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -885,9 +885,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -1386,7 +1386,6 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); @@ -1408,7 +1407,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1823,8 +1821,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1847,6 +1845,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } From caa1539030a95f5320053d5f0acaece8facfb864 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 23:03:14 +0530 Subject: [PATCH 0890/1452] Tools: Sample All Layers is each tool's own, as in Photoshop (#5524) (#5570) One ToolOptions::sample_all_layers was shared by twelve tools, so ticking it for the Magic Wand or Paint Bucket also turned it on for Blur, Sharpen and Smudge. A dab of those on an annotation layer then pasted an opaque copy of the composite there, which sat above the picture and hid every later blur of it (the sharp round patch in #5524). ToolOptions::sample_all is now a SampleAllLayers with one flag per tool, all off by default: Magic Wand, Quick and Object Selection (and Object Finder), Paint Bucket, Magic Eraser, Spot Healing, Patch, Content-Aware Move, Remove, Blur, Sharpen and Smudge. Each options bar and command reads its own tool's flag, and Reset Tool resets only that tool's. The new key means older preferences drop the shared flag, so every tool starts with it off. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/eraser_ui.rs | 4 +- crates/ui-egui/src/object_finder.rs | 2 +- crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/preset_panels.rs | 34 ++++++++- crates/ui-egui/src/retouch_ui.rs | 72 +++++++++++++----- crates/ui-egui/src/state.rs | 76 ++++++++++++++++++- crates/ui-egui/src/tool_reset.rs | 72 ++++++++++++++---- .../ui-egui/tests/paint_bucket_all_layers.rs | 4 +- 9 files changed, 225 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 474864746b..053716a572 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5547,7 +5547,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::MagicWand => { let o = app.ui.tool_options.clone(); let mode = selection_mode(app, mods); - let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "mode": mode})); + let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.magic_wand, "mode": mode})); return; } Tool::MagicEraser => { @@ -5557,7 +5557,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::PaintBucket => { let o = app.ui.tool_options.clone(); let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" }; - let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); + let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.paint_bucket, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); return; } Tool::RedEye => { diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index 59fc4d4926..01a2e74a00 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -27,7 +27,7 @@ pub fn click(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { "tolerance": o.tolerance, "antiAlias": o.anti_alias, "contiguous": o.contiguous, - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.magic_eraser, "opacity": o.magic_eraser_opacity, }); let r = app.run("paint.magicEraser", p); @@ -92,7 +92,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo widgets::value_field(ui, &mut o.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut o.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut o.sample_all.magic_eraser, tl!("Sample All Layers")); widgets::vline(ui, 22.0); opt(ui, tl!("Opacity:")); widgets::value_field(ui, &mut o.magic_eraser_opacity, 1.0..=100.0, "%", 62.0); diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index e219b0ddff..01fe50e502 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -78,7 +78,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo return Update::default(); }; let Some(st) = app.session.active() else { return Update::default() }; - let all_layers = app.ui.tool_options.sample_all_layers; + let all_layers = app.ui.tool_options.sample_all.object_selection; let target = sampled_target(&st.doc, st.active_layer, all_layers); let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); let px = [at[0].floor() as i32, at[1].floor() as i32]; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 75ade83b8d..9e0a701cee 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1241,7 +1241,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.magic_wand, tl!("Sample All Layers")); widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { let r = app.run("select.subject", json!({})); @@ -1263,7 +1263,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.paint_bucket, tl!("All Layers")); } Tool::Gradient => { // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels). diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 2741f9170c..b52f1400d7 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1017,7 +1017,14 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { let mut cur = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); if let Some(c) = cur.as_object_mut() { for (k, v) in o { - c.insert(k.clone(), v.clone()); + // An object option (`sample_all`, one per tool) takes only the entries the + // preset stored, so the other tools' keep theirs (#5524). + match (c.get_mut(k), v) { + (Some(Value::Object(have)), Value::Object(set)) => have.extend(set.clone()), + _ => { + c.insert(k.clone(), v.clone()); + } + } } } if let Ok(opts) = serde_json::from_value(cur) { @@ -1034,8 +1041,14 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { /// preset later leaves the other tools' settings alone. fn new_tool_preset_params(app: &PhotocraftApp, tool: Tool) -> Value { let all = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); - let own: serde_json::Map = + let mut own: serde_json::Map = crate::tool_reset::option_keys(tool).iter().filter_map(|k| Some(((*k).to_string(), all.get(*k)?.clone()))).collect(); + // Sample All Layers is each tool's own (#5524): store `tool`'s alone. + if let Some(k) = crate::tool_reset::sample_all_key(tool) + && let Some(v) = all.get("sample_all").and_then(|m| m.get(&k)) + { + own.insert("sample_all".into(), json!({ k: v })); + } json!({"name": tool.label(), "tool": tool_id(tool), "options": {"toolOptions": own}}) } @@ -1360,6 +1373,23 @@ mod tests { assert_eq!(app.ui.tool_options.move_auto_select, !ToolOptions::default().move_auto_select); } + /// #5524 with #7356: a Blur preset keeps Blur's Sample All Layers and leaves the Magic Wand's + /// alone, both when it is made and when it is chosen. + #[test] + fn new_tool_preset_stores_only_its_tools_sample_all_layers() { + let (mut app, _) = app(); + app.ui.tool = Tool::Blur; + app.ui.tool_options.sample_all.blur = true; + let p = new_tool_preset_params(&app, Tool::Blur); + assert_eq!(p["options"]["toolOptions"]["sample_all"], json!({"blur": true})); + let name = app.run("tool.presets.new", p).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.tool_options.sample_all.blur = false; + app.ui.tool_options.sample_all.magic_wand = true; + select_tool_preset(&mut app, &name); + assert!(app.ui.tool_options.sample_all.blur); + assert!(app.ui.tool_options.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + } + #[test] fn custom_shape_tool_places_the_picked_shape() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index d79a5385e3..20efb9bc0b 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -50,9 +50,9 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; Some(match tool { - Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), - Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), + Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), + Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), + Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), Tool::Dodge | Tool::Burn => ( if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), @@ -84,7 +84,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let o = app.ui.tool_options.clone(); let pts = json!(points); let (cmd, mut p): (&str, Value) = match tool { - Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), + Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all.spot_healing})), Tool::Remove => { if !o.remove_after_each_stroke { remove_pending_add(app, points); @@ -134,7 +134,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; let xy: Vec<[f64; 2]> = points.iter().map(|q| [q[0], q[1]]).collect(); - let (sample_all, enhance) = (o.sample_all_layers, o.enhance_edge); + let (sample_all, enhance) = (o.sample_all.quick_selection, o.enhance_edge); let r = app.run("select.quick", json!({"points": xy, "size": size, "mode": mode, "enhanceEdge": enhance, "sampleAllLayers": sample_all})); report_smart_result(app, r, tl!("Quick Selection found nothing there")); return true; @@ -163,7 +163,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m /// `paint.remove` params from the options bar (without the strokes). fn remove_params(o: &crate::state::ToolOptions) -> Value { - json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) + json!({"sampleAllLayers": o.sample_all.remove, "newLayer": o.remove_new_layer}) } /// The selection a queued Remove stroke was drawn against. A later selection must not @@ -307,7 +307,7 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { "contentAware": true, "structure": o.patch_structure.round().clamp(1.0, 7.0), "color": o.patch_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.patch, "target": crate::canvas::paint_target(app), }); if let Err(e) = app.run("paint.patch", p) { @@ -353,7 +353,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value "mode": o.cam_mode, "structure": o.cam_structure.round().clamp(1.0, 7.0), "color": o.cam_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.content_aware_move, "target": crate::canvas::paint_target(app), }) } @@ -367,7 +367,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); - let sample_all = app.ui.tool_options.sample_all_layers; + let sample_all = app.ui.tool_options.sample_all.object_selection; if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { // Object Finder (#4087): the object under the click, as hovering highlighted it. let mode = match (mods.shift, mods.alt) { @@ -472,12 +472,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } crate::widgets::vline(ui, 22.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.spot_healing, tl!("Sample All Layers")); } Tool::Remove => { // Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each // stroke; with strokes waiting, ✓ applies them and ⊘ drops them. - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.remove, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer")); crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke")); if !app.remove_pending.is_empty() { @@ -506,7 +506,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.patch_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.patch_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.patch, tl!("Sample All Layers")); } else { for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] { let mut on = o.patch_mode == k; @@ -526,7 +526,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cam_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.cam_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.content_aware_move, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.cam_transform_on_drop, tl!("Transform On Drop")); crate::widgets::vline(ui, 22.0); if crate::cam_transform::active(app) { @@ -578,7 +578,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::Blur | Tool::Sharpen | Tool::Smudge => { pct(ui, tl!("Strength:"), &mut o.strength); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + if let Some(all) = o.sample_all.slot(tool) { + crate::widgets::checkbox(ui, all, tl!("Sample All Layers")); + } if tool == Tool::Sharpen { crate::widgets::checkbox(ui, &mut o.protect_detail, tl!("Protect Detail")); } @@ -622,7 +624,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), Tool::QuickSelection => { - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.quick_selection, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.enhance_edge, tl!("Enhance Edge")); opt(ui, &crate::i18n::fmt(tl!("{key} to subtract"), &[("key", &crate::shortcuts::pretty("Alt"))])); crate::widgets::vline(ui, 22.0); @@ -633,7 +635,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::ObjectSelection => { crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.object_selection, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); if crate::widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { @@ -820,7 +822,7 @@ mod tests { let mut app = app(); stripes(&mut app, 4, "pixels"); app.run("layer.new.layer", json!({})).unwrap(); - app.ui.tool_options.sample_all_layers = all; + *app.ui.tool_options.sample_all.slot(tool).unwrap() = all; drag(&mut app, tool); let a = active(&app).surface().unwrap().rgba(30, 30)[3]; assert_eq!(a > 0.0, all, "{tool:?} sampleAllLayers={all}: alpha {a}"); @@ -828,6 +830,42 @@ mod tests { } } + #[test] + fn sample_all_layers_is_each_tools_own() { + // #5524: Sample All Layers turned on for another tool (the Magic Wand, say) left Blur and + // Smudge copying the composite onto an annotation layer, freezing a sharp patch above the + // picture. Photoshop keeps the option per tool, so those strokes leave the empty layer empty. + for tool in [Tool::Blur, Tool::Sharpen, Tool::Smudge] { + let mut app = app(); + stripes(&mut app, 4, "pixels"); + app.run("layer.new.layer", json!({})).unwrap(); + let mut others = crate::state::SampleAllLayers::default(); + for t in [ + Tool::MagicWand, + Tool::QuickSelection, + Tool::ObjectSelection, + Tool::PaintBucket, + Tool::MagicEraser, + Tool::SpotHealing, + Tool::Patch, + Tool::ContentAwareMove, + Tool::Remove, + Tool::Blur, + Tool::Sharpen, + Tool::Smudge, + ] { + if let Some(v) = others.slot(t).filter(|_| t != tool) { + *v = true; + } + } + app.ui.tool_options.sample_all = others; + assert!(!app.ui.tool_options.sample_all.get(tool)); + drag(&mut app, tool); + let a = active(&app).surface().unwrap().rgba(30, 30)[3]; + assert_eq!(a, 0.0, "{tool:?} sampled all layers through another tool's option"); + } + } + #[test] fn object_selection_click_falls_back_to_quick_selection() { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..974d3fde35 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -511,7 +511,9 @@ pub struct ToolOptions { pub tolerance: f32, pub contiguous: bool, pub anti_alias: bool, - pub sample_all_layers: bool, + /// Sample All Layers, kept per tool as in Photoshop (#5524). Stored under a new key, so the + /// single shared `sample_all_layers` of older preferences is ignored on load. + pub sample_all: SampleAllLayers, /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). @@ -653,8 +655,8 @@ pub struct ToolOptions { /// Crop gear menu (#1919): Show Cropped Area and the crop shield. See `crop_shield`. #[serde(default)] pub crop_shield: crate::crop_shield::CropShield, - /// Magic Eraser opacity % (tolerance, anti-alias, contiguous and sample-all are shared with the - /// Magic Wand and Paint Bucket). + /// Magic Eraser opacity % (tolerance, anti-alias and contiguous are shared with the Magic Wand + /// and Paint Bucket). pub magic_eraser_opacity: f32, /// Background Eraser: sampling (continuous | once | backgroundSwatch), limits (discontiguous | /// contiguous | findEdges), tolerance % and Protect Foreground Color. @@ -761,13 +763,60 @@ fn default_eyedropper_sample() -> String { "all".into() } +/// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per +/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge +/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the +/// brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SampleAllLayers { + pub magic_wand: bool, + pub quick_selection: bool, + pub object_selection: bool, + pub paint_bucket: bool, + pub magic_eraser: bool, + pub spot_healing: bool, + pub patch: bool, + pub content_aware_move: bool, + pub remove: bool, + pub blur: bool, + pub sharpen: bool, + pub smudge: bool, +} + +impl SampleAllLayers { + /// `tool`'s setting; false for a tool without the option. + pub fn get(mut self, tool: Tool) -> bool { + self.slot(tool).is_some_and(|v| *v) + } + + /// `tool`'s setting, or `None` for a tool without the option. + pub fn slot(&mut self, tool: Tool) -> Option<&mut bool> { + Some(match tool { + Tool::MagicWand => &mut self.magic_wand, + Tool::QuickSelection => &mut self.quick_selection, + Tool::ObjectSelection => &mut self.object_selection, + Tool::PaintBucket => &mut self.paint_bucket, + Tool::MagicEraser => &mut self.magic_eraser, + Tool::SpotHealing => &mut self.spot_healing, + Tool::Patch => &mut self.patch, + Tool::ContentAwareMove => &mut self.content_aware_move, + Tool::Remove => &mut self.remove, + Tool::Blur => &mut self.blur, + Tool::Sharpen => &mut self.sharpen, + Tool::Smudge => &mut self.smudge, + _ => return None, + }) + } +} + impl Default for ToolOptions { fn default() -> Self { Self { tolerance: 32.0, contiguous: true, anti_alias: true, - sample_all_layers: false, + sample_all: SampleAllLayers::default(), object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, @@ -1444,6 +1493,25 @@ impl UiState { mod tests { use super::*; + #[test] + fn sample_all_layers_is_per_tool_and_old_prefs_load() { + // The single shared flag of older preferences is dropped: every tool starts off (#5524). + let o: ToolOptions = serde_json::from_str(r#"{"sample_all_layers": true, "feather": 3.0}"#).unwrap(); + assert_eq!((o.sample_all, o.feather), (SampleAllLayers::default(), 3.0)); + let mut o = ToolOptions::default(); + o.sample_all.magic_wand = true; + let back: ToolOptions = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap(); + assert!(back.sample_all.magic_wand && !back.sample_all.get(Tool::Blur)); + assert!(!back.sample_all.get(Tool::Brush), "a tool without the option reads false"); + let mut s = SampleAllLayers::default(); + assert!(s.slot(Tool::Brush).is_none()); + for t in [Tool::MagicWand, Tool::PaintBucket, Tool::Blur, Tool::Sharpen, Tool::Smudge, Tool::Remove] { + *s.slot(t).unwrap() = true; + assert!(s.get(t), "{t:?}"); + } + assert!(!s.get(Tool::QuickSelection)); + } + #[test] fn tool_names_parse() { assert_eq!(Tool::from_name("brush"), Some(Tool::Brush)); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 9fa9169f87..3c154d3a45 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -8,9 +8,10 @@ //! `ui.dialog.cancel` answer it); `{"confirmed": true}` skips it, for agents that already asked. //! //! What a tool's options are: [`ToolOptions`] is one struct, not one per tool, and a few fields -//! are shared by the tools that show them (Tolerance, Anti-alias, Contiguous and Sample All Layers -//! by the Magic Wand, Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength -//! by Blur, Sharpen and Smudge). Resetting one of those tools resets the shared field too. +//! are shared by the tools that show them (Tolerance, Anti-alias and Contiguous by the Magic Wand, +//! Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength by Blur, Sharpen and +//! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per +//! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New //! Selection, and the painting tools (the ones with a brush in the options bar) get the default //! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have @@ -128,6 +129,20 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { if let Ok(n) = serde_json::from_value(Value::Object(cur)) { *o = n; } + // Sample All Layers is each tool's own (#5524): only `tool`'s goes back. + let d = ToolOptions::default(); + if let Some(all) = o.sample_all.slot(tool) { + *all = d.sample_all.get(tool); + } +} + +/// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a +/// tool preset stores that one alone, not the other tools' (#5524, #7356). +pub fn sample_all_key(tool: Tool) -> Option { + let mut probe = crate::state::SampleAllLayers::default(); + *probe.slot(tool)? = true; + let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; + m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) } /// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and @@ -147,9 +162,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => reset!(feather), Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), - Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), - Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), + Tool::MagicWand => reset!(tolerance, anti_alias, contiguous), + Tool::QuickSelection => reset!(enhance_edge), + Tool::ObjectSelection => reset!(enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, @@ -173,20 +188,20 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), - Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), + Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), - Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), + Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), Tool::Zoom => reset!(zoom_scrubby), - Tool::SpotHealing => reset!(spot_type, sample_all_layers), + Tool::SpotHealing => reset!(spot_type), Tool::Healing => reset!(clone_aligned, clone_sample), - Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color, sample_all_layers), - Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop, sample_all_layers), + Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color), + Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop), Tool::RedEye => reset!(red_eye_pupil_size, red_eye_darken), Tool::CloneStamp => reset!(clone_aligned, clone_sample), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), - Tool::Blur | Tool::Sharpen => reset!(strength, sample_all_layers, protect_detail), - Tool::Smudge => reset!(strength, sample_all_layers, finger_painting), + Tool::Blur | Tool::Sharpen => reset!(strength, protect_detail), + Tool::Smudge => reset!(strength, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), Tool::Pen => reset!(vector_mode, pen_auto_add_delete), @@ -226,13 +241,14 @@ mod tests { } /// Every option belongs to a tool, so Reset Tool and tool presets (#7356) miss none, and every - /// listed name is a real serialized field. + /// listed name is a real serialized field. `sample_all` holds one Sample All Layers per tool, + /// which `reset_options` and `sample_all_key` handle tool by tool (#5524). #[test] fn every_option_belongs_to_a_tool() { let all = serde_json::to_value(ToolOptions::default()).unwrap(); let all = all.as_object().unwrap(); let listed: std::collections::BTreeSet<&str> = Tool::ALL.iter().flat_map(|t| option_keys(*t).iter().copied()).collect(); - for k in all.keys() { + for k in all.keys().filter(|k| *k != "sample_all") { assert!(listed.contains(k.as_str()), "{k} is no tool's option"); } for k in listed { @@ -264,4 +280,30 @@ mod tests { assert_eq!(app.ui.tool_options, ToolOptions::default()); assert!(app.ui.dialogs.is_empty()); } + + /// Sample All Layers is per tool (#5524): resetting Blur turns off only Blur's. + #[test] + fn reset_tool_resets_only_its_own_sample_all_layers() { + let mut o = ToolOptions::default(); + o.sample_all.blur = true; + o.sample_all.magic_wand = true; + reset_options(&mut o, Tool::Blur); + assert!(!o.sample_all.blur); + assert!(o.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + reset_options(&mut o, Tool::Remove); + assert!(o.sample_all.magic_wand); + reset_options(&mut o, Tool::MagicWand); + assert_eq!(o.sample_all, Default::default()); + } + + /// Every tool with a Sample All Layers names its own key, and a tool without one names none. + #[test] + fn sample_all_key_names_the_tools_own() { + assert_eq!(sample_all_key(Tool::MagicWand).as_deref(), Some("magic_wand")); + assert_eq!(sample_all_key(Tool::Blur).as_deref(), Some("blur")); + assert_eq!(sample_all_key(Tool::Brush), None); + let keys: Vec = Tool::ALL.iter().filter_map(|t| sample_all_key(*t)).collect(); + let distinct: std::collections::BTreeSet<&String> = keys.iter().collect(); + assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); + } } diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/paint_bucket_all_layers.rs index 27e1428942..9e71d918d0 100644 --- a/crates/ui-egui/tests/paint_bucket_all_layers.rs +++ b/crates/ui-egui/tests/paint_bucket_all_layers.rs @@ -52,7 +52,7 @@ fn setup(h: &mut Harness) -> LayerId { o.tolerance = 0.0; o.anti_alias = false; o.contiguous = true; - o.sample_all_layers = false; + o.sample_all.paint_bucket = false; o.bucket_fill_pattern = false; o.fill_opacity = 100.0; background @@ -98,7 +98,7 @@ fn all_layers_checkbox_limits_bucket_fill_to_the_visible_color() { h.get_by_label("All Layers").click(); h.run_steps(3); } - assert_eq!(h.state().ui.tool_options.sample_all_layers, all_layers, "checkbox changed the option"); + assert_eq!(h.state().ui.tool_options.sample_all.paint_bucket, all_layers, "checkbox changed the option"); let pos = screen(&h, 300.0, 150.0); h.event(egui::Event::PointerMoved(pos)); From a84aa1be3413ad1272abd0d1bc9906fd53a481a3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:26 -0600 Subject: [PATCH 0891/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From e154d3dafd41e69fba69be0d0e38c94a00a104d1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:29 -0600 Subject: [PATCH 0892/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From 907923b014573c0898178e9bf3cbc477f582489a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:38 -0600 Subject: [PATCH 0893/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From b99db40906b063e93f01a4c9f63ca900eaba79d1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:42 -0600 Subject: [PATCH 0894/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From a9a757543d49a6b05f3b5ef9d3e3337defd6043e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:46 -0600 Subject: [PATCH 0895/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From f4764f1f99d685f4b2ed10e4cea7872eae9186d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:50 -0600 Subject: [PATCH 0896/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From dc6cb9725b4f1e4cc10d2a561e5731ff83564b14 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:53 -0600 Subject: [PATCH 0897/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From 4b9fde8787b2a56b6a308903d4ba72f2ebe0e1af Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:33:59 -0600 Subject: [PATCH 0898/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From e499fef328b6ff329e0602d484a4f04e33306ba0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:34:03 -0600 Subject: [PATCH 0899/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From f11e51b8993daebc02f81574dd0f9c3cef105713 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:34:06 -0600 Subject: [PATCH 0900/1452] Re-run CI Co-Authored-By: Claude Opus 5.5 From 990483a95e803cabfb33e3c854d0effae9798046 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:51 +0300 Subject: [PATCH 0901/1452] Actions: Apply Layer Comp is recorded by the comp's name, so it plays in any document (#7181) (#7790) * fix(engine): record Apply Layer Comp by the comp's name, not its document-local id (#7181) * test(engine): a recorded Apply Layer Comp applies the comp of that name in another document (#7181) --------- Co-authored-by: Brandon Thomas --- crates/engine/src/comps_cmds.rs | 17 +++++++++ crates/engine/src/comps_cmds/tests.rs | 53 +++++++++++++++++++++++++++ crates/engine/src/jobs.rs | 8 +++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index fe5708387c..43a8ec12f0 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -208,6 +208,23 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { apply(s, id, "Apply Layer Comp") } +/// How an action records Apply Layer Comp: by the comp's name, as Photoshop does. Comp ids are +/// local to a document, so a recorded id would apply whichever comp of another document has it +/// (#7181); by name the step finds that document's comp of the name, or stops with an error. +/// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. +pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + let Some(d) = s.active() else { return p }; + let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; + // `null` params run as `{}` (the last applied comp), so they are recorded by name too. + if p.is_null() { + p = json!({}); + } + if let Value::Object(m) = &mut p { + m.insert("comp".into(), json!(comp.name)); + } + p +} + fn step(s: &mut Session, delta: i64) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let n = d.doc.layer_comps.len() as i64; diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 5458523bcf..d4857ad025 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -347,3 +347,56 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { assert_eq!(doc(&s).layer_comps, before); assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); } + +/// Apply Layer Comp is recorded by the comp's name, not its document-local id: played in another +/// document it applies that document's comp of the name, or stops when there is none (#7181). +#[test] +fn a_recorded_apply_layer_comp_finds_the_comp_by_name() { + let (mut s, _, _) = session(8); + s.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + let wanted = doc(&s).layer_comps[0].id; + s.execute("actions.record", json!({"name": "ApplyWanted"})).unwrap(); + s.execute("layerComp.apply", json!({"comp": wanted})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + // `{}` and `null` apply the last applied comp; they are recorded by name too. + s.execute("actions.record", json!({"name": "ApplyLast"})).unwrap(); + s.execute("layerComp.apply", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.record", json!({"name": "ApplyNull"})).unwrap(); + s.execute("layerComp.apply", Value::Null).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + for action in ["ApplyWanted", "ApplyLast", "ApplyNull"] { + let steps = s.execute("actions.get", json!({"action": action})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["layerComp.apply", {"comp": "Wanted"}]]), "{action}"); + } + + // A second document whose comp with the recorded id is another one. + let (mut other, _, ob) = session(8); + other.actions = s.actions.clone(); + let show = |s: &mut Session, visible: bool| { + s.edit("t", |d, _| { + d.layer_mut(ob).unwrap().visible = visible; + Ok(()) + }) + .unwrap(); + }; + show(&mut other, false); + other.execute("layerComp.new", json!({"name": "Different"})).unwrap(); + show(&mut other, true); + other.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + assert_eq!(doc(&other).layer_comps[0].id, wanted, "the recorded id names `Different` here"); + show(&mut other, false); + other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + let applied = doc(&other).last_applied_comp.and_then(|id| doc(&other).comp(id)).map(|c| c.name.clone()); + assert_eq!(applied.as_deref(), Some("Wanted")); + assert!(doc(&other).layer(ob).unwrap().visible, "Wanted shows the layer"); + + // A document without a comp of that name stops the action instead of applying another one. + other.execute("layerComp.delete", json!({"comp": "Wanted"})).unwrap(); + show(&mut other, false); + let before = (doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible); + let played = other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("no layer comp named \"Wanted\""), "{played}"); + assert_eq!((doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible), before, "the document is unchanged"); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5098b958b4..583465b46e 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -816,8 +816,12 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = match id { + _ if self.actions.recording.is_none() => params, + "layer.select" => crate::layer_multi_cmds::recorded_selection(self, params), + "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), + _ => params, + }; self.journal.push((id.to_string(), params)); } } From 2ef8a9d3f5aa74ddc7612cb58720a84dbcafc2ea Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:45:07 +0200 Subject: [PATCH 0902/1452] Reject exporting a partial Play Action call to .atn (#7602) Fixes #7186 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 4 ++++ crates/engine/src/atn_cmds/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 621b5bb4a8..3e4c34b81a 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1984,6 +1984,10 @@ fn play_from(d: &Descriptor) -> Mapped { fn play_to(_: &str, p: &Value) -> Option { let name = jstr(p, "action")?; + // The Play Action descriptor written here carries no starting step, so a call from a later step is not a whole call. + if p.get("from").is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)) { + return None; + } let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; if let Some(set) = jstr(p, "set") { items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b19dc25d8b..37744831cc 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1239,6 +1239,23 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { } } +/// A Play Action that starts the called action at a later step must not export as a call of the +/// whole action: that would replay the steps the recording deliberately skipped. +#[test] +fn partial_play_action_is_not_exported_as_whole_action_call() { + let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() }; + assert!(export("Set", &[&partial], &[], false).is_err()); + let (_, skipped) = export("Set", &[&partial], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]); + for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] { + let whole = Action { name: "Parent".into(), steps: vec![("actions.play".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]); + } +} + /// #7646: an action's function key keeps Photoshop's Shift and Command options through /// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. #[test] From a58a6b100f81910c2e7adb845dc8c1844cfb1bd6 Mon Sep 17 00:00:00 2001 From: Peppe Date: Mon, 19 Oct 2026 19:53:29 +0200 Subject: [PATCH 0903/1452] feat(ui): search font input field sticky on scroll (#7792) --- crates/ui-egui/src/type_tool.rs | 71 ++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d07b8bb161..928ac808a7 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1518,43 +1518,48 @@ pub(crate) fn family_picker_in( let preview = crate::font_preview::sample(preview); let row_h = preview.map_or(28.0, |(_, [_, h])| (h + 4.0).max(28.0)); + // Wrap the font rows in a ScrollArea so only the list scrolls: the search field above + // stays fixed at the top of the popup. let mut hovered: Option = None; - for (f, label) in rows { - // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays - // out its frame's stroke only once hovered or selected, so with the theme's stroke the - // row under the pointer (and the selected one) grew and pushed the rows below down: - // the list jumped as the pointer moved (#7406). - let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); - if let Some((size, [_, h])) = preview { - button = - button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); + egui::ScrollArea::vertical().show(ui, |ui| { + for (f, label) in rows { + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). + let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); + if let Some((size, [_, h])) = preview { + button = button + .right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); + } + let response = ui.add_sized([330.0, row_h], button); + if (down || up) && f == current { + response.scroll_to_me(Some(egui::Align::Center)); + } + if let Some((size, _)) = preview + && ui.is_rect_visible(response.rect) + { + crate::font_preview::paint(ui, f, response.rect, size); + } + if response.hovered() { + hovered = Some(f.clone()); + } + if press.chosen(ui, &response) { + *current = f.clone(); + // A served family not fetched yet: start now, before any text needs it. + photocraft_text::served::request(f); + changed = true; + picked = true; + ui.data_mut(|d| d.remove::(search_id)); + hovered = None; + ui.close(); + } } - let response = ui.add_sized([330.0, row_h], button); - if (down || up) && f == current { - response.scroll_to_me(Some(egui::Align::Center)); - } - if let Some((size, _)) = preview - && ui.is_rect_visible(response.rect) - { - crate::font_preview::paint(ui, f, response.rect, size); - } - if response.hovered() { - hovered = Some(f.clone()); - } - if press.chosen(ui, &response) { - *current = f.clone(); - // A served family not fetched yet: start now, before any text needs it. - photocraft_text::served::request(f); - changed = true; - picked = true; - ui.data_mut(|d| d.remove::(search_id)); - hovered = None; - ui.close(); - } - } + }); // egui measures a popup only when it opens and never lets it grow back, so a filtered // list would leave the menu short after the query is cleared (#1369). Keep the unfiltered - // height while searching. + // height while searching. Measured after the rows: before them `min_rect` only holds the + // search field, and the cache would shrink the menu to it. if ql.is_empty() { let h = ui.min_rect().height(); ui.data_mut(|d| d.insert_temp(height_id, h)); From 3f509f06ed585815310ba18ed8dd4efb0cfd7fc7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 18:01:48 +0000 Subject: [PATCH 0904/1452] Integration: regenerate docs/scorecard.md --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index b320028936..c4b53a20f8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 25 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 169** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 806e7ba37a96c6a126f6620d9be60ded4ba66ed8 Mon Sep 17 00:00:00 2001 From: Peppe Date: Mon, 19 Oct 2026 20:05:37 +0200 Subject: [PATCH 0905/1452] feat(ui): search font input field sticky on scroll (#7792) From b960c3a4e2fad3c5161b6cb48d980facbceb56aa Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 12:10:03 -0600 Subject: [PATCH 0906/1452] Type move zone: leave the paragraph box to the caret (#7606) Inside a paragraph box, off the glyphs, a press places the caret, as in Photoshop: the move zone now excludes it. The reach drops from 40 to 38 pt so a press 40 pt outside a paragraph box still commits the text (#7606's test). Photoshop measured 35 pt moving and 48 pt committing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 2 +- crates/ui-egui/src/type_transform.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index ee29a9e1d3..7d9fc3039c 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -239,7 +239,7 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { /// True when document point (`x`, `y`) lies inside the paragraph box of type layer `id` (false /// for point text). -fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { +pub(crate) fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { let Some((bx, by, w, h)) = box_shape(app, id) else { return false }; let Some((_, aff, _)) = layout(app, id) else { return false }; let (tx, ty) = to_text(&aff, x, y); diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 51ae9a608a..0a48d84495 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -19,8 +19,9 @@ const PREVIEW_BASE: u64 = 1 << 37; const ROTATE_PX: f64 = 28.0; /// How far outside the text being typed a plain press moves it, as the Move tool (#5956), in /// screen points. Photoshop 2025, measured at 100 %: a drag from 35 pt off the glyphs moved the -/// text without ending typing, one from 48 pt committed it instead. -const MOVE_REACH_PT: f64 = 40.0; +/// text without ending typing, one from 48 pt committed it instead. Kept under 40 pt, so a press +/// 40 pt outside a paragraph box still commits the text (#7606). +const MOVE_REACH_PT: f64 = 38.0; /// Cached rendered preview and bounded damage history. Unchanged pointer positions render nothing. pub(crate) struct Preview { @@ -180,6 +181,10 @@ fn move_zone(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { if crate::transform_tool::hit(&t, p, 0.0) != Hit::Outside || border_points(app, &t, p) > MOVE_REACH_PT { return None; } + // Inside a paragraph box, off the glyphs, a press places the caret, as in Photoshop (#7606). + if crate::type_tool::inside_box(app, LayerId(t.layer), p[0], p[1]) { + return None; + } let other = crate::type_tool::hit_layer(app, p[0], p[1]).is_some_and(|id| id.0 != t.layer); (!other).then_some(t) } From 7b5f66be11c5f3af3daa1ce5da9aa87dbd36cf58 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:13:40 +0200 Subject: [PATCH 0907/1452] Don't paint from the sampled point when Alt is released mid-press Fixes #7802 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 57 +++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 053716a572..c91d7e0230 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3714,7 +3714,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.press_stroke = app.drag.is_some(); } let press_stroke = app.press_stroke; - if press_stroke { + // A live tool's press that went to the ⌥ eyedropper keeps sampling for the whole press: the + // drag recognised later must not decide again with ⌥ already let go and paint (#7802). + if press_stroke || (strokes_live(tool) && app.alt_sampling) { buttons.started = false; } if buttons.started @@ -8062,6 +8064,59 @@ mod tests { assert_ne!(dragged.center, center0, "a secondary window's Hand pans while transforming"); } + #[test] + fn letting_go_of_alt_during_an_alt_sample_press_does_not_paint() { + // #7802: Alt-pressing with the Brush samples a colour on the press. If Alt was let go + // before egui recognised the drag, that drag started a stroke from the sampled point. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("view.actualPixels", json!({})).unwrap(); + app.ui.tool = Tool::Brush; + app.sync_views(); + let mut view = app.ui.views[0].clone(); + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let frame = |app: &mut PhotocraftApp, events: Vec| { + let input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + canvas_view(app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + }; + let alt = egui::Modifiers::ALT; + let none = egui::Modifiers::NONE; + let start = Pos2::new(200.0, 150.0); + let press = + |pos: Pos2, pressed: bool, modifiers: egui::Modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // Alt released mid-press, and Alt held throughout: both only sample. + for release_alt in [true, false] { + app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let rev = app.session.active().unwrap().revision; + frame(&mut app, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(start)]); + frame(&mut app, vec![press(start, true, alt)]); + assert_eq!(app.session.tools.foreground, [1.0, 1.0, 1.0, 1.0], "the press sampled the white canvas"); + let later = if release_alt { none } else { alt }; + let end = start + vec2(20.0, 0.0); + frame(&mut app, vec![egui::Event::ModifiersChanged(later), egui::Event::PointerMoved(end)]); + frame(&mut app, vec![press(end, false, later)]); + frame(&mut app, vec![egui::Event::ModifiersChanged(none)]); + assert_eq!(app.session.active().unwrap().revision, rev, "release_alt={release_alt}: an Alt-sample press must not paint"); + assert!(app.drag.is_none() && !app.alt_sampling, "release_alt={release_alt}"); + } + // Without Alt the same gesture paints. + let rev = app.session.active().unwrap().revision; + frame(&mut app, vec![egui::Event::PointerMoved(start)]); + frame(&mut app, vec![press(start, true, none)]); + frame(&mut app, vec![egui::Event::PointerMoved(start + vec2(20.0, 0.0))]); + frame(&mut app, vec![press(start + vec2(20.0, 0.0), false, none)]); + assert!(app.session.active().unwrap().revision > rev, "a plain drag paints"); + } + #[test] fn guides_draw_over_the_transform_preview_and_under_its_box() { // #5086: a guide crossing a layer under Free Transform was hidden behind the preview of From 0b6480cdc5d67582c4d78edc320a6bed9e037438 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 14:13:55 -0400 Subject: [PATCH 0908/1452] Address review: adapter override process, wasm build, saved-name warning - PHOTOCRAFT_GPU_ADAPTER now counts as a per-process launch option, so a second launch with it set doesn't forward to the running window and drop it. - Gate RenderState::available_adapters to native targets (it doesn't exist on wasm, which broke the web build). - Share the adapter name matcher (engine prefs) between startup and Preferences, so a saved fragment like "nvidia" isn't flagged as missing when it matches a listed adapter. Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 4 +--- apps/photocraft/src/main.rs | 6 +++++- crates/engine/src/prefs.rs | 9 +++++++++ crates/engine/src/prefs/tests.rs | 10 ++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/prefs_ui.rs | 7 +++++-- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index a709e99676..411708c8f8 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -279,9 +279,7 @@ pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option bool { - let name = name.to_lowercase(); - let mut words = wanted.split_whitespace().peekable(); - words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) + photocraft_engine::prefs::gpu_adapter_matches(name, wanted) } /// The user asked for an adapter and none of `adapters` that can present is it. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index befff83026..1c06e23898 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -496,7 +496,7 @@ fn main() -> eframe::Result { unreadable_paths, safe_gpu, new_instance, - launch_options, + mut launch_options, present_mode_arg, frame_latency_arg, stroke_trace_arg, @@ -516,6 +516,10 @@ fn main() -> eframe::Result { } } + // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. + if std::env::var("PHOTOCRAFT_GPU_ADAPTER").is_ok_and(|v| !v.trim().is_empty()) { + launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); + } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs // become tabs in its window. Only with the per-user settings folder (a portable copy's may be // shared between users), and not for automation, `--new-instance` or per-process options. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index eed66a3247..375fb9ceac 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1067,6 +1067,15 @@ pub fn is_hidden(path: &str) -> bool { /// still load, save and round-trip on every platform. pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; +/// Whether a graphics adapter called `name` is the one `performance.gpuAdapter` (`wanted`) asks +/// for: every whitespace-separated word of `wanted` appears in the name, ignoring case ("nvidia", +/// "arc a370m"). An empty `wanted` matches nothing. Startup and Preferences share this. +pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool { + let name = name.to_lowercase(); + let mut words = wanted.split_whitespace().peekable(); + words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) +} + /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { Some(match path { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index bbb9df4ff2..c5970adb19 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -509,6 +509,16 @@ fn gpu_adapter_round_trips_and_defaults_to_automatic() { assert_eq!(u.prefs().performance.gpu_adapter, ""); } +#[test] +fn gpu_adapter_names_match_by_word_ignoring_case() { + let arc = "Intel(R) Arc(tm) A370M Graphics (DG2)"; + assert!(gpu_adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(gpu_adapter_matches(arc, " ARC A370M ")); + assert!(!gpu_adapter_matches(arc, "arc a380")); + assert!(!gpu_adapter_matches(arc, "")); + assert!(!gpu_adapter_matches(arc, " ")); +} + #[test] fn gpu_backend_round_trips_and_validates() { let mut s = session(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5634dfa72b..52c6408154 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1013,6 +1013,8 @@ impl PhotocraftApp { let tile = self.session.prefs().performance.cache_tile_size; // Escaped driver/setup panics must leave the session and CPU canvas alive. self.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + // The browser has no adapter list (egui-wgpu only has it natively). + #[cfg(not(target_arch = "wasm32"))] self.perf.gpu_info.set_available(&rs.available_adapters); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 1b59083261..9a57a41277 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1344,8 +1344,11 @@ fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, adapters: Option<&Va fn graphics_adapter_row(ui: &mut egui::Ui, adapters: Option<&Value>, obj: &mut Map) { let mut current = obj.get("gpuAdapter").and_then(Value::as_str).unwrap_or("").to_string(); let mut names: Vec = adapters.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); - let missing = !current.is_empty() && !names.contains(¤t); - if missing { + // A saved name fragment ("nvidia") isn't an adapter's full name but still selects one; only a + // value no listed adapter matches is marked. + let kept = !current.is_empty() && !names.contains(¤t); + let missing = kept && !names.iter().any(|n| prefs::gpu_adapter_matches(n, ¤t)); + if kept { names.push(current.clone()); } let automatic = tl!("Automatic"); From cec0e843e66dbd53e362f2c977b07064465bbd9b Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 14:17:32 -0400 Subject: [PATCH 0909/1452] refactor(algo): route the splitmix64 copies through one function (#7603) * route the splitmix64 copies through one function * document the splitmix64 generators --------- Co-authored-by: Brandon Thomas --- crates/algo/src/features.rs | 7 ++----- crates/algo/src/inpaint.rs | 8 +++---- crates/algo/src/photo_util.rs | 39 ++++++++++++++++++++++++++++++----- crates/algo/src/render2.rs | 8 +++---- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index ec74891074..80de7b993e 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15; struct Rng(u64); impl Rng { + /// The next value of the SplitMix64 sequence. fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index ee472b2296..2dd40198d8 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20; #[derive(Clone)] pub(crate) struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed`. pub(crate) fn new(seed: u64) -> Self { Rng(seed ^ 0x9E37_79B9_7F4A_7C15) } + /// The next value of the SplitMix64 sequence. pub(crate) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]). pub(crate) fn skip(&mut self, n: u64) { diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index b29612769b..7316088079 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -159,15 +159,21 @@ pub(crate) fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) - (0..n).map(f).collect() } +/// Advances `state` one SplitMix64 step and returns the mixed output. +pub(crate) fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + /// Deterministic 64-bit generator (SplitMix64). pub(crate) struct Rng(pub u64); impl Rng { + /// The next value of the SplitMix64 sequence. pub fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + splitmix64(&mut self.0) } pub fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize @@ -209,6 +215,29 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit. + #[test] + fn splitmix64_output_is_unchanged() { + /// The SplitMix64 step as each generator inlined it before. + fn inlined(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + let mut zero = 0; + assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]); + for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] { + let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed)); + for _ in 0..10_000 { + let want = inlined(&mut copy); + assert_eq!(splitmix64(&mut shared), want); + assert_eq!(rng.next(), want); + } + } + } + /// The shared sRGB functions match the removed copies bit for bit. #[test] fn srgb_transfer_output_is_unchanged() { diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index ed6567ca5c..19a4b8218d 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O pub struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed` and `salt`. pub fn new(seed: u32, salt: u64) -> Rng { Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D) } + /// The next value of the SplitMix64 sequence. pub fn next_u64(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Uniform in [0, 1). pub fn f(&mut self) -> f32 { From 7200f64bf1a795bd0aaf70afe2302f4df4183646 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 05:17:49 +1100 Subject: [PATCH 0910/1452] Explain the Memory Usage budget and GPU caps (#6546) (#6892) * Explain the per-document memory limit and automatic GPU budget caps. * Add catalog entries for the Memory Usage explanation (#6546). The new preference strings need a row in every complete-menu language for the i18n tests. --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/cs.tsv | 6 ++++ crates/ui-egui/src/i18n/de.tsv | 6 ++++ crates/ui-egui/src/i18n/el.tsv | 6 ++++ crates/ui-egui/src/i18n/es.tsv | 6 ++++ crates/ui-egui/src/i18n/fr.tsv | 6 ++++ crates/ui-egui/src/i18n/hu.tsv | 6 ++++ crates/ui-egui/src/i18n/id.tsv | 6 ++++ crates/ui-egui/src/i18n/it.tsv | 6 ++++ crates/ui-egui/src/i18n/ja.tsv | 6 ++++ crates/ui-egui/src/i18n/ko.tsv | 6 ++++ crates/ui-egui/src/i18n/nl.tsv | 6 ++++ crates/ui-egui/src/i18n/pl.tsv | 6 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++++ crates/ui-egui/src/i18n/ru.tsv | 6 ++++ crates/ui-egui/src/i18n/sr.tsv | 6 ++++ crates/ui-egui/src/i18n/tr.tsv | 6 ++++ crates/ui-egui/src/i18n/uk.tsv | 6 ++++ crates/ui-egui/src/i18n/vi.tsv | 6 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++++ crates/ui-egui/src/prefs_ui.rs | 45 ++++++++++++++++++++++++++++- 21 files changed, 164 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9cf6344d24..7894c00941 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3839,6 +3839,12 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Memory Usage Využití paměti + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Omezuje pixely plus Historii každého otevřeného dokumentu. Nejstarší stavy zpět se při překročení limitu zahodí. + Applies immediately when saved. Memory is not reserved in advance. Platí hned po uložení. Paměť se předem nerezervuje. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Při GPU paměti Automaticky omezuje i GPU rozpočet. Strop je čtvrtina RAM a, je-li známo, tři osminy paměti karty, minimum 512 MB. Vyšší limit GPU rozpočet nezvýší. Use Použít In Front Vpředu Behind Vzadu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 627c9eced5..6468ef3bf7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3231,6 +3231,12 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Memory Usage Speichernutzung + Per-document RAM limit (MB) RAM-Limit pro Dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installierter RAM: {ram} MB; dieses Limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrenzt Pixel plus Verlauf jedes offenen Dokuments. Die ältesten Widerrufsschritte werden verworfen, wenn das Limit überschritten wird. + Applies immediately when saved. Memory is not reserved in advance. Gilt sofort nach dem Speichern. Speicher wird nicht im Voraus reserviert. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bei GPU-Speicher = Automatisch begrenzt dies auch das GPU-Ebenenbudget. Das Budget ist auf ein Viertel des RAM und, wenn bekannt, drei Achtel des Grafikspeichers begrenzt, mindestens 512 MB. Ein höheres Limit erhöht das GPU-Budget nicht. Use Verwenden In Front Davor Behind Dahinter diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4cd75535d8..0a0f1fda29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3931,6 +3931,12 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Memory Usage Χρήση μνήμης + Per-document RAM limit (MB) Όριο RAM ανά έγγραφο (MB) + Installed RAM: {ram} MB; this limit: {percent}% Εγκατεστημένη RAM: {ram} MB· αυτό το όριο: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Περιορίζει τα pixel συν το Ιστορικό κάθε ανοιχτού εγγράφου. Οι παλαιότερες καταστάσεις αναίρεσης απορρίπτονται όταν ξεπεραστεί το όριο. + Applies immediately when saved. Memory is not reserved in advance. Ισχύει αμέσως με την αποθήκευση. Η μνήμη δεν δεσμεύεται εκ των προτέρων. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Με Μνήμη GPU σε Αυτόματο, περιορίζει και τον προϋπολογισμό GPU. Ανώτατο όριο το 1/4 της RAM και, αν είναι γνωστό, τα 3/8 της μνήμης κάρτας, ελάχιστο 512 MB. Μεγαλύτερο όριο δεν αυξάνει τον προϋπολογισμό GPU. Use Χρήση In Front Μπροστά Behind Πίσω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a48e224832..ba1183354c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4193,6 +4193,12 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Memory Usage Uso de memoria + Per-document RAM limit (MB) Límite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este límite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita los píxeles más el Historial de cada documento abierto. Se descartan los estados de deshacer más antiguos al superar el límite. + Applies immediately when saved. Memory is not reserved in advance. Se aplica al guardar. La memoria no se reserva de antemano. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU en Automático, también limita el presupuesto GPU. Está topeado a un cuarto de la RAM y, si se conoce, tres octavos de la memoria de la tarjeta, mínimo 512 MB. Subir el límite no aumenta el presupuesto GPU. Use Usar In Front Delante Behind Detrás diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index caff033e38..12bafb51d3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3239,6 +3239,12 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Memory Usage Utilisation de la mémoire + Per-document RAM limit (MB) Limite RAM par document (Mo) + Installed RAM: {ram} MB; this limit: {percent}% RAM installée : {ram} Mo ; cette limite : {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limite les pixels plus l’Historique de chaque document ouvert. Les plus anciens états d’annulation sont supprimés au-delà de la limite. + Applies immediately when saved. Memory is not reserved in advance. S’applique dès l’enregistrement. La mémoire n’est pas réservée à l’avance. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Si Mémoire GPU est Automatique, cela limite aussi le budget GPU. Il est plafonné à un quart de la RAM et, si connu, aux trois huitièmes de la mémoire de la carte, minimum 512 Mo. Dépasser ces plafonds n’augmente pas le budget GPU. Use Utiliser In Front Devant Behind Derrière diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2b1e3e293..c670182e6b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4150,6 +4150,12 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Memory Usage Memóriahasználat + Per-document RAM limit (MB) Dokumentumonkénti RAM-korlát (MB) + Installed RAM: {ram} MB; this limit: {percent}% Telepített RAM: {ram} MB; ez a korlát: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Minden nyitott dokumentum pixeljeit és előzményeit korlátozza. A legrégebbi visszavonási állapotok törlődnek a korlát túllépésekor. + Applies immediately when saved. Memory is not reserved in advance. Mentéskor azonnal érvényes. A memória nincs előre lefoglalva. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Automatikus GPU-memóriánál a GPU-rétegkeretet is korlátozza. Felső határ a RAM negyede és, ha ismert, a kártyamemória háromnyolcada, minimum 512 MB. Magasabb korlát a GPU-keretet nem növeli. Use Használat In Front Elöl Behind Mögött diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9836f2262a..5142781b01 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3226,6 +3226,12 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Memory Usage Penggunaan memori + Per-document RAM limit (MB) Batas RAM per dokumen (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM terpasang: {ram} MB; batas ini: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Membatasi piksel plus Riwayat setiap dokumen terbuka. Status urungkan tertua dibuang jika batas terlampaui. + Applies immediately when saved. Memory is not reserved in advance. Berlaku segera setelah disimpan. Memori tidak dipesan di muka. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Jika Memori GPU Otomatis, ini juga membatasi anggaran GPU. Plafon seperempat RAM dan, jika diketahui, tiga per delapan memori kartu, minimum 512 MB. Menaikkan batas tidak menambah anggaran GPU. Use Gunakan In Front Di Depan Behind Di Belakang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 09e20955ca..9ccb67dd8c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4348,6 +4348,12 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Memory Usage Utilizzo memoria + Per-document RAM limit (MB) Limite RAM per documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM installata: {ram} MB; questo limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita i pixel più la Cronologia di ogni documento aperto. Gli stati Annulla più vecchi vengono scartati oltre il limite. + Applies immediately when saved. Memory is not reserved in advance. Si applica al salvataggio. La memoria non è riservata in anticipo. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU Automatica limita anche il budget GPU. È limitato a un quarto della RAM e, se nota, a tre ottavi della memoria della scheda, minimo 512 MB. Alzare il limite non aumenta il budget GPU. Use Usa In Front Davanti Behind Dietro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f43978bdfe..372cd06906 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3908,6 +3908,12 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Memory Usage メモリ使用量 + Per-document RAM limit (MB) ドキュメントあたりの RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 搭載 RAM: {ram} MB、この上限: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 開いている各ドキュメントのピクセルと履歴を制限します。上限を超えると最も古い取り消し状態が破棄されます。 + Applies immediately when saved. Memory is not reserved in advance. 保存するとすぐに適用されます。メモリは事前に予約されません。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU メモリが自動のとき、GPU レイヤー予算も制限します。上限は搭載 RAM の 1/4、判明していればグラフィックメモリの 3/8、最低 512 MB です。これ以上に上げても GPU 予算は増えません。 Use 使用 In Front 前面 Behind 背面 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 43dcbd7184..60f50975ab 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3412,6 +3412,12 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Memory Usage 메모리 사용량 + Per-document RAM limit (MB) 문서당 RAM 한도(MB) + Installed RAM: {ram} MB; this limit: {percent}% 설치된 RAM: {ram} MB; 이 한도: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 열린 각 문서의 픽셀과 기록을 제한합니다. 한도를 넘으면 가장 오래된 실행 취소 상태가 버려집니다. + Applies immediately when saved. Memory is not reserved in advance. 저장하면 바로 적용됩니다. 메모리를 미리 예약하지 않습니다. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU 메모리가 자동이면 GPU 레이어 예산도 제한합니다. 상한은 설치 RAM의 1/4, 알려진 경우 그래픽 메모리의 3/8, 최소 512MB입니다. 이보다 올려도 GPU 예산은 늘지 않습니다. Use 사용 In Front 앞 Behind 뒤 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2bbe405810..18c37120e4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3991,6 +3991,12 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Memory Usage Geheugengebruik + Per-document RAM limit (MB) RAM-limiet per document (MB) + Installed RAM: {ram} MB; this limit: {percent}% Geïnstalleerd RAM: {ram} MB; deze limiet: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Beperkt pixels plus Geschiedenis van elk geopend document. Oudste herstelstappen verdwijnen als de limiet wordt overschreden. + Applies immediately when saved. Memory is not reserved in advance. Geldt meteen na opslaan. Geheugen wordt niet vooraf gereserveerd. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bij GPU-geheugen Automatisch beperkt dit ook het GPU-laagbudget. Maximum is een kwart RAM en, indien bekend, drie achtste van het videogeheugen, minstens 512 MB. Hoger zetten vergroot het GPU-budget niet. Use Gebruiken In Front Voor Behind Achter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e086a07d93..ca1f466d8f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3830,6 +3830,12 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Memory Usage Użycie pamięci + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Zainstalowany RAM: {ram} MB; ten limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ogranicza piksele plus Historię każdego otwartego dokumentu. Najstarsze stany cofania są usuwane po przekroczeniu limitu. + Applies immediately when saved. Memory is not reserved in advance. Obowiązuje zaraz po zapisaniu. Pamięć nie jest rezerwowana z góry. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Przy pamięci GPU = Automatycznie ogranicza też budżet GPU. Limit to 1/4 RAM i, jeśli znane, 3/8 pamięci karty, minimum 512 MB. Wyższy limit nie zwiększa budżetu GPU. Use Użyj In Front Z przodu Behind Z tyłu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cb16fa0326..6f98a43622 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4021,6 +4021,12 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Memory Usage Uso de memória + Per-document RAM limit (MB) Limite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita os pixels mais o Histórico de cada documento aberto. Os estados de desfazer mais antigos são descartados ao exceder o limite. + Applies immediately when saved. Memory is not reserved in advance. Aplica-se ao salvar. A memória não é reservada antecipadamente. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Com Memória GPU Automática, também limita o orçamento GPU. O teto é um quarto da RAM e, se conhecido, três oitavos da memória da placa, mínimo 512 MB. Aumentar o limite não aumenta o orçamento GPU. Use Usar In Front Na frente Behind Atrás diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50b0081146..1fd816bf90 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,6 +3204,12 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Memory Usage Использование памяти + Per-document RAM limit (MB) Лимит ОЗУ на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Установлено ОЗУ: {ram} МБ; этот лимит: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ограничивает пиксели и историю каждого открытого документа. Старые состояния отмены удаляются при превышении лимита. + Applies immediately when saved. Memory is not reserved in advance. Применяется сразу после сохранения. Память заранее не резервируется. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. При GPU-памяти «Авто» это также ограничивает бюджет GPU. Потолок — четверть ОЗУ и, если известно, три восьмых памяти карты, минимум 512 МБ. Больший лимит GPU-бюджет не увеличивает. Command Command Use Использовать In Front Спереди diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 389469a2fe..a9ff4435bf 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4304,6 +4304,12 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Memory Usage Upotreba memorije + Per-document RAM limit (MB) RAM limit po dokumentu (MB) + Installed RAM: {ram} MB; this limit: {percent}% Instalisan RAM: {ram} MB; ovaj limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ograničava piksele plus Istoriju svakog otvorenog dokumenta. Najstarija stanja opoziva se odbacuju kad se prekorači limit. + Applies immediately when saved. Memory is not reserved in advance. Važi odmah po čuvanju. Memorija se unapred ne rezerviše. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Kad je GPU memorija Automatski, ograničava i GPU budžet. Plafon je četvrtina RAM-a i, ako je poznato, tri osmine memorije kartice, najmanje 512 MB. Veći limit ne povećava GPU budžet. Use Koristi In Front Ispred Behind Iza diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c27af05e43..fb4b426c2d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3961,6 +3961,12 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Memory Usage Bellek kullanımı + Per-document RAM limit (MB) Belge başına RAM sınırı (MB) + Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Her açık belgenin piksellerini ve Geçmişini sınırlar. Sınır aşılınca en eski geri alma durumları atılır. + Applies immediately when saved. Memory is not reserved in advance. Kaydedilince hemen uygulanır. Bellek önceden ayrılmaz. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU Belleği Otomatikken GPU katman bütçesini de sınırlar. Tavan, RAM’in dörtte biri ve biliniyorsa kart belleğinin sekizde üçü, en az 512 MB. Sınırı yükseltmek GPU bütçesini artırmaz. Use Kullan In Front Önde Behind Arkada diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9f68595f57..d8510183de 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3939,6 +3939,12 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Memory Usage Використання пам’яті + Per-document RAM limit (MB) Ліміт RAM на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Встановлено RAM: {ram} МБ; цей ліміт: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Обмежує пікселі плюс Історію кожного відкритого документа. Найстаріші стани скасування відкидаються після перевищення ліміту. + Applies immediately when saved. Memory is not reserved in advance. Застосовується одразу після збереження. Пам’ять заздалегідь не резервується. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Якщо пам’ять GPU — Авто, це також обмежує бюджет GPU. Стеля — чверть RAM і, якщо відомо, три восьмих пам’яті карти, мінімум 512 МБ. Більший ліміт бюджет GPU не збільшує. Use Використати In Front Спереду Behind Ззаду diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e6f6312760..a1106bbbc2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3718,6 +3718,12 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Memory Usage Sử dụng bộ nhớ + Per-document RAM limit (MB) Giới hạn RAM mỗi tài liệu (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM đã cài: {ram} MB; giới hạn này: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Giới hạn pixel cộng Lịch sử của mỗi tài liệu đang mở. Các trạng thái hoàn tác cũ nhất bị bỏ khi vượt giới hạn. + Applies immediately when saved. Memory is not reserved in advance. Áp dụng ngay khi lưu. Bộ nhớ không được dành trước. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Khi Bộ nhớ GPU là Tự động, cũng giới hạn ngân sách GPU. Trần là 1/4 RAM và, nếu biết, 3/8 bộ nhớ card, tối thiểu 512 MB. Tăng giới hạn không làm tăng ngân sách GPU. Use Dùng In Front Phía trước Behind Phía sau diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 418d631a0f..c10a287def 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2854,6 +2854,12 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Memory Usage 内存使用 + Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每个打开文档的像素加历史记录。超出上限时会丢弃最旧的撤销状态。 + Applies immediately when saved. Memory is not reserved in advance. 保存后立即生效。不会预先占用内存。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 当 GPU 内存为自动时,这也会限制 GPU 图层预算。上限为安装 RAM 的四分之一,以及(若已知)显存的八分之三,最低 512 MB。超过这些上限不会增加 GPU 预算。 Use 使用 In Front 前面 Behind 后面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cffc27969b..82015e5754 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2839,6 +2839,12 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Memory Usage 記憶體使用量 + Per-document RAM limit (MB) 每個文件的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安裝 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每個開啟文件的像素加歷史記錄。超出上限時會捨棄最舊的復原狀態。 + Applies immediately when saved. Memory is not reserved in advance. 儲存後立即套用。不會預先保留記憶體。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 當 GPU 記憶體為自動時,這也會限制 GPU 圖層預算。上限為安裝 RAM 的四分之一,以及(若已知)顯示卡記憶體的八分之三,最低 512 MB。超過這些上限不會增加 GPU 預算。 Use 使用 In Front 前面 Behind 後面 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 7857b0f8f7..a3f9f26c7d 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1566,7 +1566,7 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" - || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode" | "memoryUsageMb")) // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) @@ -1598,10 +1598,37 @@ fn section_fields( if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } + if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) { + memory_usage_row(ui, obj); + } keys.retain(|k| field_visible(section, obj, k, &query)); egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); } +/// Explain the per-document RAM limit separately from the graphics-memory preference. +fn memory_usage_row(ui: &mut egui::Ui, obj: &mut Map) { + let t = Tokens::get(ui.ctx()); + ui.group(|ui| { + ui.label(RichText::new(tl!("Memory Usage")).strong()); + let mut mb = obj.get("memoryUsageMb").and_then(Value::as_u64).unwrap_or(8192); + ui.horizontal(|ui| { + ui.label(tl!("Per-document RAM limit (MB)")); + if ui.add(egui::DragValue::new(&mut mb).range(256..=1_048_576).suffix(" MB")).changed() { + obj.insert("memoryUsageMb".into(), json!(mb)); + } + }); + if let Some(ram) = crate::gpu_canvas::physical_memory().filter(|n| *n > 0) { + let installed = ram / (1 << 20); + let percent = mb as f64 * 100.0 / (ram as f64 / (1 << 20) as f64); + ui.label(crate::i18n::fmt(tl!("Installed RAM: {ram} MB; this limit: {percent}%"), &[("ram", &installed.to_string()), ("percent", &format!("{percent:.1}"))])); + } + ui.label(RichText::new(tl!("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded.")).color(t.text_dim)); + ui.label(RichText::new(tl!("Applies immediately when saved. Memory is not reserved in advance.")).color(t.text_dim)); + ui.label(RichText::new(tl!("With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget.")).color(t.text_dim)); + }); + ui.add_space(8.0); +} + /// One grid row per setting in `keys` (already filtered by `field_visible`). fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, keys: Vec, lang: crate::i18n::Lang) { let t = Tokens::get(ui.ctx()); @@ -2486,6 +2513,22 @@ mod shortcut_set_tests; #[cfg(test)] mod tests { + #[test] + fn memory_usage_has_a_dedicated_explanation_and_keeps_its_value() { + use egui_kittest::{Harness, kittest::Queryable}; + let values = json!({"memoryUsageMb":50000}).as_object().unwrap().clone(); + assert!(!field_visible("performance", &values, "memoryUsageMb", "")); + let mut h = Harness::builder() + .with_size(vec2(680.0, 350.0)) + .build_ui_state(|ui, values| section_fields(ui, "performance", values, &["memoryUsageMb".into()], crate::i18n::Lang::EN, None, ""), values); + h.run_steps(2); + h.get_by_label("Memory Usage"); + h.get_by_label("Per-document RAM limit (MB)"); + h.get_by_label("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded."); + h.get_by_label("Applies immediately when saved. Memory is not reserved in advance."); + assert_eq!(h.state()["memoryUsageMb"], 50000); + } + #[test] fn colour_preferences_take_a_typed_hex_value() { // #668: the canvas colour (and every colour preference) showed its hex but couldn't take one. From dfe94d6ab96bc03e2b57e6b57b7bb348bf44cba8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:18:18 +0200 Subject: [PATCH 0911/1452] Actions: export Box Blur, Surface Blur, Flatten Image and Black & White under ids Photoshop runs (#6330) (#6331) Save Actions wrote these four steps under the four-char codes BoxB, SrfB, Fltn and BlnW, which Photoshop does not run: executeAction fails with "the command is not currently available" (measured in Photoshop on a document with a filled pixel layer; Fltn resolves to the flatness property, not the flatten event). The names boxblur, surfaceBlur and blackAndWhite and the code FltI run. - Box Blur, Surface Blur and Black & White now export by name (boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI. The descriptors are unchanged. - Import accepts the names, FltI and flattenImage, and still accepts the old codes (a LEGACY_CODES table consulted only for four-char codes), so actions PhotoCraft saved before keep loading. - Tests: each step exports the new id and imports back to the same command and params under the new id, the name and the legacy code; a set mixing Photoshop-shaped and legacy steps loads and re-saves with the new ids; near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken descriptors don't panic. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 21 +++++++-- crates/engine/src/atn_cmds/tests.rs | 73 ++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 3e4c34b81a..0cfbc31156 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2691,8 +2691,8 @@ const EVENTS: &[Event] = &[ ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), - ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), - ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!( "SmrB", "smartBlur", @@ -2768,7 +2768,7 @@ const EVENTS: &[Event] = &[ adjustment_event!("Exps", "Exps", "exposure", "Exposure"), adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), Event { - code: "BlnW", + code: "", name: "blackAndWhite", label: "Black & White", ids: &["image.adjustments.blackWhite"], @@ -2960,7 +2960,7 @@ const EVENTS: &[Event] = &[ from: move_from, to: move_to, }, - ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + ev!("FltI", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), Event { code: "MrgV", name: "mergeVisible", @@ -3046,9 +3046,20 @@ const EVENTS: &[Event] = &[ Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; +/// Four-char codes PhotoCraft once exported for events Photoshop only runs by name (or by +/// another code), with the name they stand for. Measured in Photoshop: `executeAction` with +/// these codes fails ("the command is not currently available"), and `Fltn` is the +/// `flatness` property, not the flatten event. Import keeps accepting them so actions saved +/// before still load; export never writes them. +const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; + fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + find(&id).or_else(|| match item { + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), + AtnEvent::Name(_) => None, + }) } /// The event `id` exports as: the first event listing it whose converter accepts the params diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 37744831cc..f22a153232 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -144,7 +144,7 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(file.name.to_string_lossy(), "Test Set"); assert_eq!(file.actions[0].function_key, 3); let events: Vec = file.actions[0].items.iter().map(|i| i.event.id()).collect(); - assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]); assert!(file.actions[0].items[0].with_dialog); assert!(!file.actions[0].items[8].enabled); // The kept Photoshop step is written back byte for byte. @@ -1037,6 +1037,77 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and +/// `BlnW` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +#[test] +fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { + let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let cases = [ + ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), + ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), + ("layer.flattenImage", json!({}), "FltI", "Fltn"), + ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ]; + for (id, params, written, legacy) in &cases { + let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs"); + assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name"); + let d = exported.descriptor.clone(); + // The written id, the name for Flatten Image, and the old code all import to the command. + let name = if *id == "layer.flattenImage" { "flattenImage" } else { written }; + for event in [*written, name, *legacy] { + let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import")); + assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids); + match (e.from)(d.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{event}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{event}"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + assert_eq!(back, *params, "{event}"); + } + Mapped::Unsupported { .. } => panic!("{event} does not import back"), + } + } + } + + // A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones + // (legacy codes) loads to the same commands. + let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0)))); + let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0)))); + let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)]; + let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)]; + let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]); + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]); + assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]); + assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15})); + // Saving them writes the ids Photoshop runs, never the old codes. + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]); + + // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, + // never a panic or a wrong command. + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + let e = event_for(&item(event, "", None).event); + assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); + } + // A legacy code with a broken descriptor is unsupported, not a crash. + let e = event_for(&item("SrfB", "", None).event).unwrap(); + assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_))); + // A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults. + match (e.from)(&desc("null").with("Rds ", text("x"))) { + Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"), + Mapped::Unsupported { .. } => {} + } +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test] From 209a384ef0ebdc18fd45ae14989cc78afff949e2 Mon Sep 17 00:00:00 2001 From: Taylor Burke Date: Mon, 19 Oct 2026 13:18:33 -0500 Subject: [PATCH 0912/1452] Home screen: fit the card and stack the buttons in a narrow window (#5774) * Home screen: fit the card and stack the buttons in a narrow window The Home screen's card was a fixed 460 points wide, centred on the canvas, with two 190-point buttons side by side. A tiling window manager can give PhotoCraft a canvas narrower than that (Hyprland on Omarchy gave a 728-point window, below the 760-point minimum), so the New document and Open buttons were clipped at the edges. The card is now as wide as the canvas when the canvas is narrower than 460 points. The buttons are sized from their labels (so longer translations still fit) and are stacked when two of them don't fit side by side; the card grows by one row when they stack. Adds start_screen_buttons_stay_inside_a_narrow_canvas, which fails before this change (New document starts at x = -1 in a 390-point canvas). Co-Authored-By: Claude Sonnet 5.5 * contributors: add TaylorBurke Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 ++ crates/ui-egui/src/canvas.rs | 74 ++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 0860ea17db..34c50d4d9f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -163,6 +163,9 @@ sync_github_name = true real_name = "Sidney Markowitz" sync_github_name = true +[people.TaylorBurke] +sync_github_name = true + [people.1Git2Clone] sync_github_name = true diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 053716a572..02fb1b33c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2824,6 +2824,13 @@ fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { } } +/// The Home screen's card is this wide when the canvas has room, and as wide as the canvas when it doesn't. +const HOME_CARD_WIDTH: f32 = 460.0; +/// Least width of each of the Home screen's New document and Open buttons. +const HOME_BUTTON_WIDTH: f32 = 190.0; +/// Space between the Home screen's two buttons. +const HOME_BUTTON_GAP: f32 = 12.0; + fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); @@ -2834,9 +2841,16 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let recent: Vec = if cfg!(target_arch = "wasm32") || !show_recent { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; let recent_h = if recent.is_empty() { 0.0 } else { 34.0 + recent.len() as f32 * HOME_RECENT_ROW }; - let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0 + recent_h)); + // A tiling window manager can give the canvas less room than the card wants: shrink the card + // to fit, and stack the buttons when they don't fit side by side. let new_label = crate::shortcuts::command_label(app, "New document…", "file.new"); let open_label = crate::shortcuts::command_label(app, "Open…", "file.open"); + let card_width = HOME_CARD_WIDTH.min(area.width()); + let button_width = HOME_BUTTON_WIDTH.max(crate::widgets::button_width(ui, &[&new_label, &open_label])); + let side_by_side = 2.0 * button_width + HOME_BUTTON_GAP <= card_width; + // Stacked buttons take one more row. + let stacked_h = if side_by_side { 0.0 } else { 44.0 }; + let card = Rect::from_center_size(area.center(), egui::vec2(card_width, 330.0 + recent_h + stacked_h)); ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { ui.vertical_centered(|ui| { ui.horizontal(|ui| { @@ -2852,17 +2866,26 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Create a new document or open an existing file.")).color(t.text_dim).size(14.0)); ui.add_space(22.0); - ui.horizontal(|ui| { - ui.add_space(((card.width() - 2.0 * 190.0 - 12.0) / 2.0).max(0.0)); - ui.spacing_mut().item_spacing.x = 12.0; - if crate::widgets::primary_button(ui, &new_label, 190.0).clicked() { - let fields = app.new_document_fields(); - app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); - } - if crate::widgets::secondary_button(ui, &open_label, 190.0).clicked() { - let _ = app.open_dialog_file(); - } - }); + let (mut new_clicked, mut open_clicked) = (false, false); + if side_by_side { + ui.horizontal(|ui| { + ui.add_space(((card.width() - 2.0 * button_width - HOME_BUTTON_GAP) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = HOME_BUTTON_GAP; + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + }); + } else { + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + ui.add_space(HOME_BUTTON_GAP); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + } + if new_clicked { + let fields = app.new_document_fields(); + app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); + } + if open_clicked { + let _ = app.open_dialog_file(); + } ui.add_space(22.0); ui.horizontal(|ui| { let msg = start_screen_drop_hint(app); @@ -7273,6 +7296,33 @@ mod tests { assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); } + /// A tiling window manager can hand PhotoCraft a canvas narrower than the Home card (460 + /// points): the two buttons must stay inside the window instead of being clipped at its edges. + #[test] + fn start_screen_buttons_stay_inside_a_narrow_canvas() { + use egui_kittest::kittest::Queryable; + for width in [390.0, 420.0, 520.0] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + for label in ["New document", "Join us on Discord"] { + for node in h.query_all_by_label_contains(label) { + let r = node.rect(); + assert!(r.left() >= 0.0 && r.right() <= width, "`{label}` is clipped in a {width}-point-wide canvas: {r:?}"); + } + } + } + } + /// #4382: Interface › Show home recent files off hides the Home screen's list, not the recents. #[test] fn home_recent_list_hides_when_the_preference_is_off() { From 98d3ebc1f5d88010962ae1e5df62585cbe08ad8d Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:05 +0200 Subject: [PATCH 0913/1452] perf(codecs): Avoid redundant PNG filter passes (#7690) * perf(codecs): Avoid redundant PNG filter passes Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression. * docs(perf): measure PNG filtering on real images * perf(codecs): stop PNG candidates at the winning cost * Drop the local benchmark report Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 138 +++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 19 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index ede70d296d..f894b3a30d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -541,8 +541,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -551,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -779,6 +776,109 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); From 2cc107d05f67f3f263cbeef880ec1eb8eddbda52 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:15 +0200 Subject: [PATCH 0914/1452] Crop: a ui.pointer click on the frame leaves it alone (#7740) (#7741) `to_press` and `control_point` read a point in the press-time view through `remap`, which rounds it through f32 and the view even when the view hasn't moved. A `ui.pointer` down + up at the same point then came back slightly apart: the frame moved by a hair and a box Undo step was pushed that undid nothing visible. Skip the remap while the view is still the press view. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/crop_mode.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 676c97dbec..a1264126bf 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -212,10 +212,11 @@ fn remap(p: [f64; 2], from: PressView, to: PressView, flip: bool) -> [f64; 2] { } /// A pointer point `p` the canvas mapped through the view as it is now, read in the press-time -/// view `press` (unchanged without one). +/// view `press` (unchanged without one, or while the view hasn't moved: the `f32` round trip +/// would nudge a click). pub fn to_press(app: &PhotocraftApp, press: Option, p: [f64; 2]) -> [f64; 2] { match (press, press_view(app)) { - (Some(press), Some(now)) => remap(p, now, press, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if press != now => remap(p, now, press, app.ui.view.flip_horizontal), _ => p, } } @@ -239,7 +240,7 @@ pub fn control_point(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { _ => None, }; match (press, press_view(app)) { - (Some(press), Some(now)) if app.ui.tool == Tool::Crop => remap(p, press, now, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if app.ui.tool == Tool::Crop && press != now => remap(p, press, now, app.ui.view.flip_horizontal), _ => p, } } @@ -452,6 +453,30 @@ mod tests { assert!(((c1[0] - c0[0]).hypot(c1[1] - c0[1]) - 30.0).abs() < 1e-3); } + /// A `ui.pointer` click (down and up at the same point, no move) changes nothing, upright or + /// turned: reading the point in the press view used to round it through `f32` and the view, + /// nudging the frame by a hair and pushing a box Undo step that undid nothing visible. + #[test] + fn a_ui_pointer_click_changes_nothing() { + let ctx = egui::Context::default(); + for deg in [0.0, -30.0, 41.5] { + let mut app = app(); + with_frame(&mut app); + app.ui.crop_angle = deg; + sync_frame(&mut app); + let (rect, angle) = (app.ui.crop_rect, app.ui.crop_angle); + // Inside the frame (moves the image) and outside it (turns the image). + for p in [[70.3, 50.7], [55.17, 41.9], [83.01, 59.33], [150.3, 50.7], [10.9, 90.1]] { + let ev = json!([{"kind": "down", "x": p[0], "y": p[1]}, {"kind": "up", "x": p[0], "y": p[1]}]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"events": ev})); + let crate::control::Outcome::Done(v) = crate::control::handle(&mut app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), (rect, angle), "click at {p:?}, frame at {deg}°"); + assert_eq!(crate::crop_ui::is_enabled(&app, "edit.undo"), None, "click at {p:?}, frame at {deg}°: a box Undo step"); + } + } + } + #[test] fn the_readout_is_the_images_turn_clockwise_positive() { let mut app = app(); From 5a649f8e4ea4638df4e590138cd032834d718da1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:22 +0200 Subject: [PATCH 0915/1452] Apply background jobs to the layers they started on (#7620) Fixes #7366 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/jobs.rs | 55 +++++++++- crates/ui-egui/tests/remove_channel_target.rs | 100 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/remove_channel_target.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 583465b46e..0ee0573170 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -219,12 +219,36 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + /// The document's layer selection when the job started: applying targets it, not whatever + /// got selected meanwhile (a Type click does that without editing the document, #7366). + layers: Option, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] handle: Option>, } +/// A document's active layer, selected layers and selection anchor. +#[derive(Clone, PartialEq)] +struct LayerSelection { + active: Option, + selected: Vec, + anchor: Option, +} + +impl LayerSelection { + fn of(st: &crate::DocState) -> Self { + Self { active: st.active_layer, selected: st.selected_layers.clone(), anchor: st.layer_anchor } + } + + fn set(self, st: &mut crate::DocState) { + st.active_layer = self.active; + st.selected_layers = self.selected; + st.layer_anchor = self.anchor; + crate::channel_cmds::fix_view(st); + } +} + impl Running { fn info(&self) -> JobInfo { JobInfo { @@ -333,10 +357,10 @@ pub fn run( if s.jobs.pending.is_some() { return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); } - let (doc, snapshot) = match (lock_document, s.active()) { - (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc))), + let (doc, snapshot, layers) = match (lock_document, s.active()) { + (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), (true, None) => return Err(EngineError::NoDocument), - (false, _) => (None, None), + (false, _) => (None, None, None), }; let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); @@ -382,6 +406,7 @@ pub fn run( started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + layers, rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -843,7 +868,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -858,6 +883,19 @@ impl Session { } None => None, }; + // Selecting a layer doesn't change the document, so the snapshot check above lets it + // through. Apply against the layers the job started on: the colour restriction and any + // `active`-based edit land on them (#7366). The user's selection is put back below. + let user_layers = match (target, layers) { + (Some(i), Some(started)) => self.docs.get_mut(i).and_then(|st| { + let now = LayerSelection::of(st); + (now != *started).then(|| { + started.clone().set(st); + now + }) + }), + _ => None, + }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; // Apply-time hooks can dispatch commands too. Keep the same single-step recording @@ -880,6 +918,15 @@ impl Session { if r.is_ok() { self.after_command(command, params.clone(), *journal, Some(nested_from)); } + // Give the user back the layers they selected while the job ran, unless the job chose a + // selection itself (a new layer, say) or one of their layers is gone. + if let (Some(started), Some(user), Some(st)) = (layers, user_layers, target.and_then(|i| self.docs.get_mut(i))) { + let kept = LayerSelection::of(st) == *started; + let exist = user.active.iter().chain(&user.selected).all(|id| st.doc.layer(*id).is_some()); + if (r.is_err() || kept) && exist { + user.set(st); + } + } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. if target.is_some() && self.active == target { diff --git a/crates/ui-egui/tests/remove_channel_target.rs b/crates/ui-egui/tests/remove_channel_target.rs new file mode 100644 index 0000000000..5d18009baa --- /dev/null +++ b/crates/ui-egui/tests/remove_channel_target.rs @@ -0,0 +1,100 @@ +//! #7366: a background Remove on a Red-only target keeps G/B/alpha intact when a Type click +//! selects another layer while the job is pending. + +use photocraft_doc::LayerId; +use photocraft_engine::Session; +use photocraft_engine::jobs::Started; +use photocraft_ui_egui::{PhotocraftApp, Services, control, type_tool}; + +const REMOVE: &str = r#"{"points":[[12,10],[16,12]],"size":6}"#; + +fn run(s: &mut Session, id: &str, p: &str) { + s.execute(id, p.parse().unwrap()).unwrap(); +} + +fn pixels(s: &Session, doc: usize, id: LayerId) -> Vec { + let d = &s.documents()[doc].doc; + d.layer(id).unwrap().surface().unwrap().read_region(d.bounds()) +} + +#[derive(Clone, Copy)] +enum Input { + None, + TypeTool, + ControlPointer, +} + +fn probe(input: Input) { + let mut a = PhotocraftApp::new(Session::new(), Services::default()); + run(&mut a.session, "file.new", r#"{"name":"A","width":32,"height":24,"background":"transparent"}"#); + run(&mut a.session, "type.create", r#"{"text":"X","x":2,"y":18,"size":14}"#); + let text = a.session.active().unwrap().active_layer.unwrap(); + run(&mut a.session, "layer.new.layer", r#"{"name":"Target"}"#); + let target = a.session.active().unwrap().active_layer.unwrap(); + a.session + .edit("fixture", |d, l| { + let q = d.layer_mut(l.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..24 { + for x in 0..32 { + q.write_pixel(x, y, &[((x * 7 + y * 13) % 31) as f32 / 30., (x % 5) as f32 / 4., (y % 7) as f32 / 6., 1.]); + } + } + Ok(()) + }) + .unwrap(); + run(&mut a.session, "file.new", r##"{"name":"B","width":32,"height":24,"background":"#6a219c"}"##); + a.session.set_active(0); + a.sync_views(); + run(&mut a.session, "channel.target", r#"{"channel":"red"}"#); + let b = a.session.documents()[1].doc.clone(); + let text_before = a.session.documents()[0].doc.layer(text).unwrap().clone(); + + // The inline Red-only Remove is the oracle. + let mut inline = Session::new(); + inline.add_document(a.session.documents()[0].doc.as_ref().clone(), None); + inline.select_layer(target).unwrap(); + run(&mut inline, "channel.target", r#"{"channel":"red"}"#); + run(&mut inline, "paint.remove", REMOVE); + let expected = pixels(&inline, 0, target); + + let job = match a.session.start("paint.remove", REMOVE.parse().unwrap()).unwrap() { + Started::Job(id) => id, + v => panic!("must start a background job: {v:?}"), + }; + match input { + Input::None => {} + Input::TypeTool => assert!(type_tool::pointer_down(&mut a, 4., 14., Default::default()), "the click must hit the text"), + Input::ControlPointer => { + let (req, _rx) = control::ControlRequest::new("ui.pointer", r#"{"tool":"type","events":[{"kind":"down","x":4,"y":14}]}"#.parse().unwrap()); + let _ = control::handle(&mut a, &Default::default(), &req); + } + } + let selected = if matches!(input, Input::None) { target } else { text }; + assert_eq!(a.session.active().unwrap().active_layer, Some(selected), "the input selects the text layer"); + + a.session.set_active(1); + a.session.wait_job(job).unwrap(); + assert_eq!(a.session.documents()[1].doc, b, "B is unchanged"); + let st = &a.session.documents()[0]; + assert_eq!(st.doc.layer(text).unwrap(), &text_before, "the text layer is unchanged"); + assert_eq!(st.active_layer, Some(selected), "the user's selection survives the job"); + let actual = pixels(&a.session, 0, target); + let wrong = expected.as_chunks::<4>().0.iter().zip(actual.as_chunks::<4>().0).filter(|(e, r)| e[1..] != r[1..]).count(); + assert_eq!(wrong, 0, "pixels with G/B/alpha changed outside the Red target"); + assert_eq!(actual, expected); +} + +#[test] +fn no_input_keeps_red_restriction() { + probe(Input::None); +} + +#[test] +fn type_tool_click_keeps_red_restriction() { + probe(Input::TypeTool); +} + +#[test] +fn control_pointer_click_keeps_red_restriction() { + probe(Input::ControlPointer); +} From 73b79953e6026998da1522bffcda3cf749453ce4 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:19:28 -0400 Subject: [PATCH 0916/1452] =?UTF-8?q?Print:=20Save=20as=20PDF=20gets=20a?= =?UTF-8?q?=20Browse=E2=80=A6=20button=20(#5328)=20(#7586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save as PDF was only a free text field, so saving a PDF meant typing a full path by hand. Browse… opens the standard Save dialog (pick_save), suggesting the document's name as a PDF, and puts the chosen path in the field. file_ui::body now gets the dialog id so the answer can be written back into the Print dialog on a later frame. Refs storytold/photocraft#5328 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/file_ui.rs | 61 +++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e951e2ece..e27411b7e0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -336,7 +336,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::tool_reset::owns(&fields) => crate::tool_reset::body(ui), DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields), DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields), - DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields), + DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields, d.id), DialogKind::Command if crate::color_picker_ui::owns(&fields) => { outcome = crate::color_picker_ui::body(ui, &mut fields); crate::color_picker_ui::take_add_swatch(app, &mut fields); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index a84bfe7779..14a512c288 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -168,11 +168,12 @@ pub fn ok_label(f: &Map) -> Option<&'static str> { } } -pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// The body of Save for Web or Print (dialog `id`). +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { if f.contains_key("__web") { web_body(app, ui, f); } else { - print_body(app, ui, f); + print_body(app, ui, f, id); } } @@ -865,7 +866,22 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { } } -fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// Save as PDF › Browse…: where to write the PDF, into Print dialog `id` once the file dialog +/// answers. Without it the only way to save a PDF was to type a full path (#5328). +fn browse_pdf(app: &mut PhotocraftApp, id: u64, suggested: &str) { + let r = app.pick_save(suggested, move |app, path| { + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + d.fields.insert("output".into(), json!(path)); + Ok(Value::Null) + }); + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status = e; + } +} + +fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; // PDF exports and previews retain editable paper on Windows too. Only a real native @@ -885,6 +901,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { @@ -1470,6 +1495,28 @@ mod tests { assert_eq!(f["copies"], 1, "what wasn't remembered keeps its default"); } + /// #5328: Save as PDF has Browse…, which fills the field from a Save dialog (suggesting the + /// document's name as a PDF) rather than needing a typed path. + #[test] + fn save_as_pdf_browse_fills_the_output_from_a_save_dialog() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + use egui_kittest::{Harness, kittest::Queryable}; + let out = std::env::temp_dir().join(format!("pc-printui-browse-{}", std::process::id())).to_string_lossy().into_owned(); + let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(out.clone()))]); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }) + }); + h.state_mut().run("file.new", json!({"width": 120, "height": 80, "name": "poster"})).unwrap(); + let id = open_print(h.state_mut()); + h.run_steps(3); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Save { suggested }] if suggested.ends_with("poster.pdf")), "{:?}", asked.borrow()); + // A name chosen without an extension gets the suggested one. + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["output"], json!(format!("{out}.pdf"))); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { From b67502e20fd02a21f5a0bf9002459c63a15b89a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:35 +0200 Subject: [PATCH 0917/1452] Reveal All: one History step named "Reveal All" (#7145) (#7147) image.revealAll ran image.crop, whose step is labelled "Crop". It now runs it inside analysis_cmds::compound with the label "Reveal All". Co-authored-by: Claude Opus 5.5 --- crates/engine/src/extra_cmds.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 08ed6cfa0f..072a3d4417 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -872,7 +872,9 @@ pub fn specs() -> Vec { if all == d.doc.bounds() { return Ok(json!({"changed": false})); } - s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?; + // One History step named as in Photoshop, not the crop's "Crop". + let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); + crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; Ok(json!({"changed": true})) }), spec!( @@ -1760,8 +1762,12 @@ mod tests { s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok(); paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30)); + let steps = s.active().unwrap().history.past_len(); s.execute("image.revealAll", json!({})).unwrap(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30)); + let history = &s.active().unwrap().history; + assert_eq!(history.past_len(), steps + 1, "one step"); + assert_eq!(history.entries().last().map(String::as_str), Some("Reveal All"), "named as in Photoshop, not Crop"); assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false); } From bf638a7a228ccc9527ed346c969cad0dfd5c2754 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:42 +0200 Subject: [PATCH 0918/1452] Crop: Enter during a drag ends the drag (#7055) (#7056) Pressing Enter while the frame is still being dragged commits the crop; commit_crop now ends the drag, so the release no longer restores a frame in the old document's coordinates as a pending crop. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 ++ crates/ui-egui/src/crop_ui.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 02fb1b33c4..b20f827987 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6148,6 +6148,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { crate::crop_ui::cancel_stale(app); // Committing ends the box's own Undo/Redo: from here Edit › Undo reverts the document. crate::crop_ui::clear_history(app); + // ↵ during a drag ends it: the release must not bring back the old document's frame. + app.crop.drag = None; let Some(r) = app.ui.crop_rect.take() else { return }; // The crop's default mode leaves the view upright (`crop_mode`). crate::crop_mode::upright(app); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..00700ae5c0 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1724,6 +1724,22 @@ mod tests { } } + #[test] + fn enter_during_a_drag_ends_the_drag() { + // ↵ while the frame is still being dragged commits it; the release after must not bring + // back a frame in the old document's coordinates as a pending crop. + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, NONE); + crate::canvas::commit_crop(&mut app); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); + ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert!(!pending(&app)); + } + #[test] fn degenerate_and_bad_input_never_panics() { let mut app = app(SampleType::U8); From e3224000cac1a30a19e239d67dcad110ccb7c8b2 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 19 Oct 2026 19:19:48 +0100 Subject: [PATCH 0919/1452] fix(psd): make packbits::decode_into crate-private and overflow-safe (#6950) decode_into computed out.len() + expected unchecked. With a non-empty buffer and an expected length near usize::MAX it panicked in debug builds and, in release, wrapped and returned Ok having decoded nothing. Compute the target with checked_add and report LimitExceeded, and test each packet as len > target - out.len() so the bound checks cannot overflow either. The only callers are decode and decode_rle in this module, and the crate README documents packbits::{encode, decode} as the public API, so decode_into becomes pub(crate). Co-authored-by: Claude Opus 5.5 --- crates/psd/src/compression.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index aab785dff4..68782464ad 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -111,9 +111,10 @@ pub mod packbits { /// /// Errors if the input is exhausted early or a packet would overflow the /// row. Trailing input after the row is complete is ignored except for - /// `-128` no-op bytes (which are always permitted). - pub fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { - let target = out.len() + expected; + /// `-128` no-op bytes (which are always permitted). Errors, rather than + /// overflowing, when `out.len() + expected` does not fit in `usize`. + pub(crate) fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { + let target = out.len().checked_add(expected).ok_or(PsdError::LimitExceeded("PackBits output length overflows usize"))?; let mut i = 0; while out.len() < target { let Some(&h) = src.get(i) else { @@ -126,7 +127,7 @@ pub mod packbits { let Some(lit) = src.get(i..i + len) else { return Err(PsdError::Decompress("PackBits literal truncated".into())); }; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits literal overflows row".into())); } out.extend_from_slice(lit); @@ -138,7 +139,7 @@ pub mod packbits { return Err(PsdError::Decompress("PackBits run truncated".into())); }; i += 1; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits run overflows row".into())); } out.extend(std::iter::repeat_n(b, len)); @@ -687,6 +688,20 @@ mod tests { assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows } + /// `out.len() + expected` past `usize::MAX` used to panic in debug builds and, in release, + /// wrap and return `Ok` having decoded nothing. + #[test] + fn packbits_decode_into_output_length_overflow_is_an_error() { + let mut out = vec![7u8]; + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX, &mut out); + assert!(matches!(r, Err(PsdError::LimitExceeded(_))), "{r:?}"); + assert_eq!(out, [7]); + // The largest `expected` that fits still decodes up to the input, then reports it short. + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX - 1, &mut out); + assert!(matches!(r, Err(PsdError::Decompress(_))), "{r:?}"); + assert_eq!(out, [7, 0xaa]); + } + #[test] fn packbits_zero_expected_ignores_input() { assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); From 2cdc8cb73df08e82866fb4d28727675940a151ae Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:55 +0200 Subject: [PATCH 0920/1452] Content-Aware Fill: preview at full resolution so it matches the result (#6530) (#6838) --- crates/ui-egui/src/filter_dialog.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index effb52694d..a908df5eba 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -203,6 +203,9 @@ fn is_pixel_param_of(command: &str, key: &str) -> bool { /// Sharpen, Sharpen More, Sharpen Edges, Find Edges, Emboss and Despeckle use fixed one-pixel /// kernels with no size parameter, so a proxy changes their look: edges average away, making /// the sharpening and edge detection invisible. These must preview at full resolution (#3929). +/// +/// Content-Aware Fill searches for matching patches, and the matches found on a reduced copy +/// differ from those found at full size, so its preview would not show what OK writes (#6530). const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.distort.ripple", "filter.stylize.diffuse", @@ -225,6 +228,7 @@ const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.stylize.findEdges", "filter.stylize.emboss", "filter.noise.despeckle", + "edit.contentAwareFill", ]; /// Whether `command` with `params` previews at full resolution: [`FULL_RESOLUTION_PREVIEW`], every @@ -2094,6 +2098,15 @@ mod tests { assert_eq!(preview_factor("image.mode.bitmap", &json!({"method": "threshold"}), 4), 4); } + /// #6530: the Content-Aware Fill preview runs at full resolution, for every Sampling choice, + /// because its patch search finds different matches on a reduced copy. + #[test] + fn content_aware_fill_previews_at_full_resolution() { + for params in [json!({}), json!({"sampling": "auto"}), json!({"sampling": "rectangular"})] { + assert_eq!(preview_factor("edit.contentAwareFill", ¶ms, 4), 1, "{params}"); + } + } + /// #2063: proxy previews scaled what isn't a length (Tree's canvas fraction, Lens Correction's /// perspective), clamped negative lengths to +0.1 (Offset left/up), left Shadows/Highlights' /// radii in document pixels and rounded Mosaic's 10 px cells to 3 proxy px (12 px). From 77b845e0986b7baabbfc8d3f714a9ffa3cdc0bb3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:02 +0200 Subject: [PATCH 0921/1452] Actions: loading a set renames each Play target once (#6806) (#6807) Load Actions applied each rename as its own pass over every action in the set, so chained renames ("A" -> "A 2", "A 2" -> "A 2 2") retargeted a step twice, and with replace:false it also rewrote steps of actions already in the set. Now one original->new map is applied once to the newly loaded actions' Play and Conditional (then/else) steps and to function keys. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/atn_cmds.rs | 30 ++++++++++++++++---------- crates/engine/src/atn_cmds/tests.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 0cfbc31156..9e8eb6cb7c 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3290,31 +3290,39 @@ fn load(s: &mut Session, p: &Value) -> Result { } let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); let mut names = Vec::with_capacity(imp.actions.len()); - let mut renamed = Vec::new(); + // The file's name of each action that had to be renamed, and its new name. + let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); let mut steps = 0usize; + let mut added = Vec::with_capacity(imp.actions.len()); for mut a in imp.actions { let name = unique(&a.name, &taken); if name != a.name { - renamed.push((a.name.clone(), name.clone())); + renamed.entry(a.name.clone()).or_insert_with(|| name.clone()); } a.name = name.clone(); taken.push(name.clone()); steps = steps.saturating_add(a.steps.len()); names.push(name); - s.actions.list.push(a); + added.push(a); } - // A Play step calls its action by name: follow renames within the set. - for (from, to) in &renamed { - for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { - for (id, params) in &mut a.steps { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { - params["action"] = json!(to); + // Play and Conditional steps call actions by name: follow the renames in the actions just + // loaded, each target looked up once by the name the file gave it. + for a in &mut added { + for (id, params) in &mut a.steps { + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if let Some(to) = params.get(*key).and_then(Value::as_str).and_then(|n| renamed.get(n)) { + params[*key] = json!(to); } } } } - let keys: Vec<(String, String)> = - imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + s.actions.list.extend(added); + let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { for (name, key) in &keys { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index f22a153232..26865d4060 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -130,6 +130,39 @@ fn load_maps_events_keeps_flags_and_plays() { assert_eq!(s.actions.list[2].set.as_deref(), Some("Other")); } +/// Renames that chain ("A" becomes "A 2" while the file's own "A 2" becomes "A 2 2") rewrite +/// each Play and Conditional target once, to the renamed copy of the action it named. +#[test] +fn load_renames_each_play_target_once() { + let mut s = Session::new(); + let mut mine = Action::new("A", vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new("A", vec![("actions.conditional".into(), json!({"if": "always", "then": "A", "else": "A 2"}))]); + let a2 = Action::new("A 2", vec![("actions.play".into(), json!({"action": "A"}))]); + let keys = [("A".to_string(), "F4".to_string())]; + let (bytes, _) = export("File", &[&a, &a2], &keys, false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["actions"], json!(["A 2", "A 2 2"])); + let get = |n: &str| s.actions.list.iter().find(|a| a.name == n && a.set.as_deref() == Some("File")).unwrap(); + assert_eq!(get("A 2 2").steps[0].1["action"], "A 2", "plays the file's A, not its A 2"); + let cond = &get("A 2").steps[0].1; + assert_eq!((&cond["then"], &cond["else"]), (&json!("A 2"), &json!("A 2 2")), "{cond}"); + assert_eq!(r["functionKeys"], json!([{"action": "A 2", "key": "F4"}])); + + // Adding to a set of the same name leaves the Play steps already in it alone. + let mut s = Session::new(); + for (name, steps) in [("A", vec![]), ("B", vec![("actions.play".to_string(), json!({"action": "A"}))])] { + let mut a = Action::new(name, steps); + a.set = Some("File".into()); + s.actions.list.push(a); + } + let (bytes, _) = export("File", &[&Action::new("A", vec![])], &[], false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes), "replace": false})).unwrap(); + assert_eq!(r["actions"], json!(["A 2"])); + assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); +} + #[test] fn save_round_trips_through_photoshop_events() { let mut s = Session::new(); From 5e1d90d86e62dd5d969f7f454cbc8d25628692a2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:09 +0200 Subject: [PATCH 0922/1452] Preferences: a wrongly typed value resets only that field (#6611) (#6641) One value of the wrong type in prefs.json (say "recentFileCount": "ten") made the whole file fail to deserialize, so every preference fell back to its default for the session, and since the file was never rewritten it happened again on every launch. load_prefs_json now reads leniently when the file doesn't deserialize as a whole: it starts from the defaults and lays the file's values over them one at a time, descending into section objects and map entries, keeping every value that still reads and leaving out only the ones that don't. Colour settings get the same treatment instead of resetting as a whole. It returns the left-out values (keys and original value); text that isn't a JSON object still fails as before. On launch the app shows a notice naming the reset paths and saves once: the three-way merge base gets the unreadable values, so the merge replaces (or removes) exactly those in the stored file and keeps everything else, including keys this version doesn't know. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/prefs.rs | 109 +++++++++++++++++++++++++++++-- crates/engine/src/prefs/tests.rs | 47 +++++++++++++ crates/ui-egui/src/prefs_ui.rs | 78 ++++++++++++++++++++-- 3 files changed, 225 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b551032e9d..77acd2e346 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1609,6 +1609,100 @@ impl PrefsStore { } } +/// A saved preference [`Session::load_prefs_json`] couldn't read (a wrong type, an unknown +/// choice) and left at its default. +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidPref { + /// JSON keys from the top of the preferences file (`["fileHandling", "recentFileCount"]`), + /// kept apart because keys such as command ids contain dots. + pub keys: Vec, + /// The value the file held there. + pub value: Value, +} + +impl InvalidPref { + /// The dotted path, as `prefs.get` and `prefs.set` take it. + pub fn path(&self) -> String { + self.keys.join(".") + } +} + +/// How deep [`read_lenient`] looks for the readable parts of an unreadable value. +const LENIENT_DEPTH: usize = 32; + +/// Read `v` as a `T`. When that fails, start from `T::default()` and lay the file's values over +/// it one at a time, keeping each one the whole still reads with and descending into objects +/// that don't, so one bad value resets only itself. The values left out are added to +/// `invalid`, their keys after `prefix`. +fn read_lenient(v: Value, prefix: &[&str], invalid: &mut Vec) -> T +where + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + if let Ok(t) = T::deserialize(&v) { + return t; + } + let prefix: Vec = prefix.iter().map(|k| (*k).to_string()).collect(); + let mut acc = serde_json::to_value(T::default()).unwrap_or(Value::Null); + let fits = |root: &Value| T::deserialize(root).is_ok(); + match v { + Value::Object(file) if acc.is_object() && fits(&acc) => { + lay_over(&mut acc, &mut Vec::new(), file, &fits, &prefix, invalid); + T::deserialize(&acc).unwrap_or_default() + } + value => { + invalid.push(InvalidPref { keys: prefix, value }); + T::default() + } + } +} + +/// [`read_lenient`]'s walk: lay `file` over the object at `at` inside `root`, key by key, so that +/// `root` always reads (`fits`). +fn lay_over( + root: &mut Value, + at: &mut Vec, + file: Map, + fits: &dyn Fn(&Value) -> bool, + prefix: &[String], + invalid: &mut Vec, +) { + for (k, fv) in file { + let Some(obj) = object_at(root, at) else { return }; + let old = obj.insert(k.clone(), fv.clone()); + if fits(root) { + continue; + } + // Back to the default. An object keeps its parts that read, and so does a new map entry + // (a shortcut set, a workspace) when it reads from an empty object. + let Some(obj) = object_at(root, at) else { return }; + let mut descend = fv.is_object() && at.len() < LENIENT_DEPTH && matches!(old, None | Some(Value::Object(_))); + match old { + Some(o) => obj.insert(k.clone(), o), + None if descend => obj.insert(k.clone(), Value::Object(Map::new())), + None => obj.remove(&k), + }; + at.push(k); + if descend && !fits(root) { + descend = false; + if let Some((key, parent)) = at.split_last() + && let Some(obj) = object_at(root, parent) + { + obj.remove(key); + } + } + match fv { + Value::Object(m) if descend => lay_over(root, at, m, fits, prefix, invalid), + value => invalid.push(InvalidPref { keys: prefix.iter().chain(at.iter()).cloned().collect(), value }), + } + at.pop(); + } +} + +/// The JSON object at `path` inside `root`. +fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map> { + path.iter().try_fold(root, |cur, k| cur.get_mut(k.as_str()))?.as_object_mut() +} + impl Session { /// The current preferences. pub fn prefs(&self) -> &Preferences { @@ -1657,8 +1751,14 @@ impl Session { /// Restore preferences saved by [`Session::prefs_to_json`]. Missing keys keep their /// defaults and unknown keys are ignored, so files from older and newer versions load. - pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result<(), String> { + /// A value of the wrong type (a hand-edited `"recentFileCount": "ten"`) keeps its default + /// and everything else still loads (#6611); the returned list names those values. Only + /// text that isn't a JSON object fails as a whole. + pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result, String> { let mut v: Value = serde_json::from_str(s).map_err(|e| format!("preferences: {e}"))?; + if !v.is_object() { + return Err("preferences: expected a JSON object".into()); + } // Saved preferences before appearance modes had one concrete theme. Preserve its // appearance instead of silently switching established users to Auto. if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) @@ -1679,9 +1779,10 @@ impl Session { } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); - let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?; + let mut invalid = Vec::new(); + let prefs: Preferences = read_lenient(v, &[], &mut invalid); if let Some(c) = color { - self.color.settings = serde_json::from_value(c).unwrap_or_default(); + self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); } self.prefs.edit(|p| *p = prefs); @@ -1689,7 +1790,7 @@ impl Session { self.load_presets_json(v); } self.apply_prefs(); - Ok(()) + Ok(invalid) } /// Full JSON view used by `prefs.get`: preferences plus `colorSettings`. diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 5314d1f197..55e41949b9 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -915,3 +915,50 @@ fn a_stranded_backup_is_named_in_the_error() { assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_wrongly_typed_value_resets_only_that_field() { + // #6611: one bad value used to reset every preference. + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": {"beepWhenDone": true}, "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 20}, "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}}"#, + ) + .unwrap(); + assert!(s.prefs().general.beep_when_done); + assert_eq!(s.prefs().file_handling.autosave_minutes, 5, "a sibling in the same section survives"); + assert_eq!(s.prefs().file_handling.recent_file_count, 20, "the bad value takes the default"); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.prefs().shortcuts.get("edit.fill").map(String::as_str), Some("Cmd+Shift+F")); + assert!(!s.prefs().shortcuts.contains_key("file.open")); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["fileHandling.recentFileCount", "shortcuts.file.open"]); + assert_eq!(invalid[0].value, json!("ten")); + assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]); +} + +#[test] +fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": "loud", "performance": {"historyStates": 20}, + "colorSettings": {"workingRgb": "display-p3", "askOnMismatch": "yes"}}"#, + ) + .unwrap(); + assert_eq!(s.prefs().general, General::default()); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.color.settings.working_rgb, "display-p3", "the rest of the colour settings load"); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["general", "colorSettings.askOnMismatch"]); + // A valid file reports nothing; one that isn't an object still fails as a whole. + assert!(s.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + for bad in ["[1, 2]", "42", "\"prefs\"", "null", "{", ""] { + assert!(s.load_prefs_json(bad).is_err(), "{bad}"); + } + // Deep nesting and odd values don't panic. + let deep = format!("{{\"general\": {}1{}}}", "{\"a\":".repeat(100), "}".repeat(100)); + assert!(s.load_prefs_json(&deep).is_ok()); + assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index a3f9f26c7d..d808ae32f4 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -233,11 +233,15 @@ pub fn load(app: &mut PhotocraftApp) { return; } app.prefs_rt.loaded = true; - if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) - && let Err(e) = app.session.load_prefs_json(&text) - { - let template = "Preferences were reset: {error}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + let mut invalid = Vec::new(); + if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) { + match app.session.load_prefs_json(&text) { + Ok(found) => invalid = found, + Err(e) => { + let template = "Preferences were reset: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + } + } } crate::dock::restore(app); crate::brush_picker::restore(app); @@ -248,6 +252,37 @@ pub fn load(app: &mut PhotocraftApp) { app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); app.prefs_rt.saved_value = Some(app.session.prefs_value()); + if !invalid.is_empty() { + repair_invalid(app, &invalid); + } +} + +/// Some saved values couldn't be read and took their defaults (#6611): say which, and write +/// the defaults over them on the next save so the next launch reads a clean file. The save +/// base holds the unreadable values, so the three-way merge sees them as changed here and +/// replaces (or removes) exactly those, keeping everything else in the stored file. +fn repair_invalid(app: &mut PhotocraftApp, invalid: &[prefs::InvalidPref]) { + if let Some(base) = app.prefs_rt.saved_value.as_mut() { + for bad in invalid { + let Some((key, parent)) = bad.keys.split_last() else { + continue; + }; + if let Some(obj) = parent.iter().try_fold(&mut *base, |cur, k| cur.get_mut(k.as_str())).and_then(Value::as_object_mut) { + obj.insert(key.clone(), bad.value.clone()); + } + } + } + app.session.prefs.edit(|_| ()); + let paths = invalid.iter().map(prefs::InvalidPref::path).collect::>().join(", "); + let (title, line) = ( + "Some preferences were reset", + "The preferences file held values PhotoCraft can't read for {paths}. Those settings are back to their defaults; the rest of your preferences loaded.", + ); + // Notices keep their English source and are translated when drawn. + let id = crate::notices::post(app, title, vec![line.into()], false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.args.push(("paths".into(), paths)); + } } /// Discover recovery entries after construction, then decode one at a time without blocking @@ -3096,6 +3131,39 @@ mod tests { serde_json::from_str(store.lock().unwrap().as_deref().unwrap_or("null")).unwrap() } + #[test] + fn a_wrongly_typed_value_resets_only_itself_and_is_repaired_on_disk() { + // #6611: one bad value used to reset every preference, on every launch. + let saved = json!({ + "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 12}, + "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}, + "futureSection": {"kept": true}, + }); + let (mut app, store) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let p = app.session.prefs(); + assert_eq!((p.file_handling.recent_file_count, p.file_handling.autosave_minutes, p.performance.history_states), (20, 5, 12)); + assert!(!app.ui.status.contains("reset"), "{}", app.ui.status); + let notice = app.ui.notices.iter().find(|n| n.title == "Some preferences were reset").expect("a notice names the bad values"); + let text = notice.text(¬ice.lines[0]); + assert!(text.contains("fileHandling.recentFileCount, shortcuts.file.open"), "{text}"); + // The file now holds the default in its place and keeps everything else. + let v = stored(&store); + assert_eq!(v["fileHandling"]["recentFileCount"], 20); + assert_eq!(v["fileHandling"]["autosaveMinutes"], 5); + assert_eq!(v["performance"]["historyStates"], 12); + assert_eq!(v["shortcuts"]["edit.fill"], "Cmd+Shift+F"); + assert!(v["shortcuts"].get("file.open").is_none()); + assert_eq!(v["futureSection"]["kept"], true, "keys this version doesn't know survive"); + // So the next launch loads cleanly. + let (mut next, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut next, &ctx); + assert!(next.ui.notices.iter().all(|n| n.title != "Some preferences were reset")); + assert_eq!(next.session.prefs().file_handling.autosave_minutes, 5); + } + #[test] fn merge_keeps_changes_made_elsewhere() { let base = json!({"a": {"x": 1, "y": 1}, "list": [1], "gone": 1, "kept": 1}); From c136d2dd3db255f194098530be284c186561c979 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:16 +0200 Subject: [PATCH 0923/1452] Guides: Ctrl/Cmd drags a guide with every tool that moves guides, and the cursor shows it (#6288) (#6294) With Ctrl (Cmd on the Mac) held over a guide, Photoshop's tools that turn Ctrl into the Move tool show the guide cursor and drag the guide. Three gaps remained: - The Frame and Artboard tools showed the guide cursor under Ctrl but took the press themselves (a new frame or board), so the guide never moved. Their pointer handlers checked the selected tool instead of the effective one. - The Gradient tool's live mode took a Ctrl press sent with the event (automation, `ui.pointer` with `command: true`) instead of handing it to the Move tool. - With Window > Modifier Keys' latched Ctrl, the press took the guide but the pointer kept the tool's own cursor: `command_guide_at`, the decision the cursor and the guide context menu use, knew only the selection tools. `command_guide_at` now covers every `hold_keys::cmd_moves` tool, and not during a stroke or type editing. Frame, Artboard and live Gradient step aside for a Ctrl press when they have no drag of their own under way. Tools that keep Ctrl for themselves (Pen, Hand, Zoom, Crop, type, shapes, slices) and Lock Guides are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/artboard_tool.rs | 4 +- crates/ui-egui/src/canvas.rs | 15 +- crates/ui-egui/src/canvas_tool_menu.rs | 3 +- crates/ui-egui/src/frame_tool.rs | 4 +- crates/ui-egui/src/gradient_ui.rs | 5 + crates/ui-egui/src/guide_cursor_tests.rs | 204 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/rulers.rs | 12 +- 8 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/guide_cursor_tests.rs diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 0b12c36127..7f8ee838c6 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -182,7 +182,9 @@ fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) { /// Pointer input for the Artboard tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Artboard { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Artboard || (app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard) { return false; } let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b20f827987..3ebecc75ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5363,7 +5363,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::gradient_ui::pointer(app, ev, mods) { return; } - // ⌘ with a selection tool over a guide takes the guide (#2690); the guide drag below moves it. + // ⌘ over a guide takes the guide (#2690, #6288); the guide drag below moves it. if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { @@ -5758,13 +5758,16 @@ pub(crate) fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], selection_tool && mods.command && !mods.shift && !floating && app.ui.polygon.is_empty() && !inside_selection(app, p) } -/// The guide a ⌘ press with selection tool `tool` at `p` drags (vertical?, index), as the Move -/// tool's press would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the -/// layer or the selected pixels. Not while a polygon or lasso outline is being drawn. +/// The guide a ⌘ press with `tool` at `p` drags (vertical?, index), as the Move tool's press +/// would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the layer or the +/// selected pixels. The selection tools, and every tool on which ⌘ is the Move tool +/// (`hold_keys::cmd_moves`, #6288); the canvas shows the guide cursor where this finds a guide. +/// Not while a polygon or lasso outline, a stroke, Free Transform or type editing is under way. pub(crate) fn command_guide_at(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<(bool, usize)> { let selection_tool = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand); - let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none(); - (selection_tool && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() + let moves = selection_tool || crate::hold_keys::cmd_moves(tool); + let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none() && app.ui.text_edit.is_none() && app.drag.is_none(); + (moves && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() } /// Photoshop's cursor over a selection: what a press (or the drag under way) would do. diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index f7f9892d2f..43418d5ff9 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -566,7 +566,8 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: } /// Right-click at document point `doc` on a guide that `tool` could drag (the Move tool, or ⌘ with -/// a selection tool): select the guide and open the guide menu at screen point `pos`. +/// a tool on which ⌘ moves guides, `canvas::command_guide_at`): select the guide and open the guide +/// menu at screen point `pos`. pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui::Modifiers, pos: [f32; 2], doc: [f64; 2]) -> bool { if !pos.iter().all(|v| v.is_finite()) || !doc.iter().all(|v| v.is_finite()) { return false; diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 4a1210b874..b2102b1cfb 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -64,7 +64,9 @@ fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { /// Pointer input for the Frame tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Frame { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Frame || (app.ui.frame_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Frame) { return false; } let (square, centred) = (mods.shift, mods.alt); diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index be35f66028..dd91c1a74d 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -219,6 +219,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> app.gradient.drag = None; return false; } + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held drags a guide or the layer + // (#6288). A drag already under way keeps its events. + if app.gradient.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Gradient { + return false; + } let zoom = app.point_zoom().max(0.01); match ev { ToolEvent::Down { x, y, .. } => { diff --git a/crates/ui-egui/src/guide_cursor_tests.rs b/crates/ui-egui/src/guide_cursor_tests.rs new file mode 100644 index 0000000000..fd0e5c0595 --- /dev/null +++ b/crates/ui-egui/src/guide_cursor_tests.rs @@ -0,0 +1,204 @@ +//! #6288: ⌘ (Ctrl off the Mac) over a guide is the Move tool's guide drag with every tool on +//! which ⌘ is the Move tool, and the pointer shows it (the guide's double arrow), as in +//! Photoshop. Driven through real egui input, so the held key goes through `hold_keys`. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } + .to_screen(x, y) +} + +fn hover(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) -> egui::CursorIcon { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: f32, to: f32, m: Modifiers) { + let p = screen(h, from, 150.0); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let q = screen(h, from + (to - from) * k as f32 / 4.0, 150.0); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, to, 150.0); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +fn diagonal(h: &mut Harness<'static, PhotocraftApp>, a: [f32; 2], b: [f32; 2], m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, a[0], a[1]); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let t = k as f32 / 4.0; + let q = screen(h, a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, b[0], b[1]); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +/// The cursor over the guide with ⌘ held, and where a ⌘-drag from 200 to 240 leaves it. +fn command_drag(tool: Tool, locked: bool) -> (egui::CursorIcon, Vec) { + let mut h = harness(tool); + h.state_mut().ui.extras.lock_guides = locked; + let c = hover(&mut h, 200.0, 150.0, Modifiers::COMMAND); + drag(&mut h, 200.0, 240.0, Modifiers::COMMAND); + (c, h.state().session.active().unwrap().doc.guides.vertical.clone()) +} + +const GUIDE: egui::CursorIcon = egui::CursorIcon::ResizeHorizontal; + +#[test] +fn command_over_a_guide_shows_the_guide_cursor_and_drags_it_with_painting_tools() { + for tool in [ + Tool::Brush, + Tool::Eraser, + Tool::Gradient, + Tool::PaintBucket, + Tool::CloneStamp, + Tool::Eyedropper, + Tool::Dodge, + Tool::SpotHealing, + Tool::Smudge, + Tool::QuickSelection, + Tool::RectMarquee, + Tool::Lasso, + ] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } +} + +/// The Frame and Artboard tools took a ⌘ press as their own (a new frame or board) although ⌘ +/// is the Move tool on them, so the guide cursor promised a drag that never moved the guide. +#[test] +fn command_drags_a_guide_with_the_frame_and_artboard_tools() { + for tool in [Tool::Frame, Tool::Artboard] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } + // Off a guide, ⌘ is the Move tool too: no frame is drawn. + let mut h = harness(Tool::Frame); + let layers = h.state().session.active().unwrap().doc.layers.len(); + diagonal(&mut h, [100.0, 100.0], [160.0, 140.0], Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "⌘-drag with the Frame tool draws no frame"); + assert!(h.state().ui.frame_tool.drag.is_none()); + // Without ⌘ the Frame tool still draws its frame. + diagonal(&mut h, [20.0, 20.0], [120.0, 90.0], Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1, "a plain drag draws a frame"); +} + +#[test] +fn locked_guides_show_no_guide_cursor_and_do_not_move() { + for tool in [Tool::Brush, Tool::Eraser, Tool::Frame] { + let (cursor, guides) = command_drag(tool, true); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Tools that keep ⌘ for themselves (Photoshop: the Pen's Direct Selection, the Hand, Zoom, Crop, +/// type, shapes, slices) neither show the guide cursor nor drag the guide. +#[test] +fn tools_with_their_own_command_keep_it() { + for tool in [Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice] { + let (cursor, guides) = command_drag(tool, false); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Window › Modifier Keys: a latched ⌘ made the press take the guide but left the brush cursor. +#[test] +fn sticky_command_shows_the_guide_cursor() { + for tool in [Tool::Brush, Tool::Eraser, Tool::RectMarquee] { + let mut h = harness(tool); + h.state_mut().ui.shell.sticky_command = true; + assert_eq!(hover(&mut h, 200.0, 150.0, Modifiers::NONE), GUIDE, "{tool:?}"); + drag(&mut h, 200.0, 240.0, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.guides.vertical, vec![240.0], "{tool:?}"); + } +} + +/// The one decision for "a ⌘ press here takes the guide" outside the Move tool: not without ⌘, +/// not with a locked guide, not during a stroke, not for a tool that keeps ⌘. +#[test] +fn command_guide_at_decides_for_every_tool_that_moves_guides() { + use crate::canvas::command_guide_at; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let cmd = Modifiers::COMMAND; + for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::PaintBucket, Tool::Eyedropper, Tool::Frame, Tool::Artboard, Tool::MagicWand] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), Some((true, 0)), "{tool:?}"); + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], Modifiers::NONE), None, "{tool:?} without ⌘"); + assert_eq!(command_guide_at(&app, tool, [120.0, 100.0], cmd), None, "{tool:?} off the guide"); + } + for tool in [Tool::Move, Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice, Tool::PathSelection] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), None, "{tool:?} keeps ⌘"); + } + app.ui.extras.lock_guides = true; + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "locked guides"); + app.ui.extras.lock_guides = false; + // A stroke under way keeps the pointer: ⌘ pressed mid-stroke does not take the guide. + app.ui.tool = Tool::Brush; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "during a stroke"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5948bd4edf..fb2c42eaae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2835,6 +2835,9 @@ mod dropdown_press_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod guide_cursor_tests; + #[cfg(test)] mod canvas_menu_tests; diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 6caa6f00a9..a5f4bb0fd1 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -880,7 +880,17 @@ mod tests { fn command_drag_over_a_guide_moves_the_guide_with_any_tool() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - for (tool, locked) in [(Tool::Brush, false), (Tool::RectMarquee, false), (Tool::Lasso, false), (Tool::MagicWand, false), (Tool::RectMarquee, true)] { + for (tool, locked) in [ + (Tool::Brush, false), + (Tool::Eraser, false), + (Tool::Gradient, false), + (Tool::Frame, false), + (Tool::Artboard, false), + (Tool::RectMarquee, false), + (Tool::Lasso, false), + (Tool::MagicWand, false), + (Tool::RectMarquee, true), + ] { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); app.sync_views(); From f5af2f3ef5ca2a9d3d732ad9a7cfb25e4143ceed Mon Sep 17 00:00:00 2001 From: Persi <69083413+persi-man@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:22 +0200 Subject: [PATCH 0924/1452] PSD export: check the Background's opacity from its tiles (#5998) --- crates/io/src/flat.rs | 2 +- crates/io/src/psd_export.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 76f53d9703..39fd2dc634 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -495,7 +495,7 @@ fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 64a5e7073a..345406740d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -107,9 +107,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written From 8bed609ef045af7b7ef55301d48aa967325d3314 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:20:29 -0400 Subject: [PATCH 0925/1452] Flatpak on Wayland: say why a paste finds nothing, and how to paste anyway (#5031) (#5279) In a Flatpak on Wayland, arboard can't open at all: the sandbox hides the data-control protocol from it, and --socket=fallback-x11 gives no X server while a compositor runs. Paste then reported "the clipboard holds no image" even when it held one. Now the clipboard service reports that it had no clipboard to read, and the paste shows a notice that explains it and gives the XWayland command for this install. Also adds my contributors/people.toml entry. Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 14 ++++++-- contributors/people.toml | 3 ++ crates/ui-egui/src/i18n/cs.tsv | 4 +++ crates/ui-egui/src/i18n/de.tsv | 4 +++ crates/ui-egui/src/i18n/el.tsv | 4 +++ crates/ui-egui/src/i18n/es.tsv | 4 +++ crates/ui-egui/src/i18n/fr.tsv | 4 +++ crates/ui-egui/src/i18n/hu.tsv | 4 +++ crates/ui-egui/src/i18n/id.tsv | 4 +++ crates/ui-egui/src/i18n/it.tsv | 4 +++ crates/ui-egui/src/i18n/ja.tsv | 4 +++ crates/ui-egui/src/i18n/ko.tsv | 4 +++ crates/ui-egui/src/i18n/nl.tsv | 4 +++ crates/ui-egui/src/i18n/pl.tsv | 4 +++ crates/ui-egui/src/i18n/pt-br.tsv | 4 +++ crates/ui-egui/src/i18n/ru.tsv | 4 +++ crates/ui-egui/src/i18n/sr.tsv | 4 +++ crates/ui-egui/src/i18n/tr.tsv | 4 +++ crates/ui-egui/src/i18n/uk.tsv | 4 +++ crates/ui-egui/src/i18n/vi.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 +++ crates/ui-egui/src/lib.rs | 10 ++++++ crates/ui-egui/src/notices.rs | 56 +++++++++++++++++++++++++++++ 24 files changed, 161 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 80f0b2d3a1..fcacf81b26 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size}; use photocraft_format::RecoveryStore; use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -493,6 +493,7 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); + let clip_unreadable: Rc> = Rc::default(); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -566,11 +567,19 @@ pub fn native(automation: Option) -> }), clipboard_get_image: Some({ let clip = clip.clone(); + let unreadable = clip_unreadable.clone(); Box::new(move || { let mut slot = clip.try_borrow_mut().ok()?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => { + // arboard opens only when some backend works. In a Flatpak on Wayland none + // does (#5031): the sandbox hides the data-control protocol from it, and + // `--socket=fallback-x11` gives no X server while a compositor runs. + let opened = arboard::Clipboard::new(); + unreadable.set(opened.is_err()); + slot.insert(opened.ok()?) + } }; // Copied files first: Finder also puts the file's icon on the clipboard as an // image, which would otherwise paste instead of the file. @@ -586,6 +595,7 @@ pub fn native(automation: Option) -> None }) }), + clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), diff --git a/contributors/people.toml b/contributors/people.toml index 34c50d4d9f..bc3533777b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -159,6 +159,9 @@ sync_github_name = false real_name = "Ufuk Efe" sync_github_name = true +[people.nicolasfalesy] +sync_github_name = true + [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7894c00941..f6046fc207 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3798,6 +3798,10 @@ navigator Zoom Zvětšení # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Pasting from other apps is unavailable Vkládání z jiných aplikací není k dispozici + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft v této relaci nemá přístup k systémové schránce. Na Waylandu sandbox Flatpaku skrývá protokol, přes který se čtou obrázky z jiných aplikací. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Chcete-li vkládat z jiných aplikací, spusťte PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Není co vložit: PhotoCraft zde nemůže číst systémovou schránku Delete this set Smazat tuto sadu New Set… Nová sada… PhotoCraft Defaults Výchozí PhotoCraft diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6468ef3bf7..0ad7366c4b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3194,6 +3194,10 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Pasting from other apps is unavailable Einfügen aus anderen Apps ist nicht verfügbar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft kann in dieser Sitzung nicht auf die Systemzwischenablage zugreifen. Unter Wayland verbirgt eine Flatpak-Sandbox das Protokoll, über das Bilder aus anderen Apps gelesen werden. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Um aus anderen Apps einzufügen, starte PhotoCraft unter XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nichts einzufügen: PhotoCraft kann die Systemzwischenablage hier nicht lesen Delete this set Diesen Satz löschen New Set… Neuer Satz… PhotoCraft Defaults PhotoCraft-Standard diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a0f1fda29..83621629fe 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3890,6 +3890,10 @@ curves Smooth Εξομάλυνση # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Pasting from other apps is unavailable Η επικόλληση από άλλες εφαρμογές δεν είναι διαθέσιμη + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. Το PhotoCraft δεν έχει πρόσβαση στο πρόχειρο του συστήματος σε αυτή τη συνεδρία. Στο Wayland, το sandbox του Flatpak κρύβει το πρωτόκολλο μέσω του οποίου διαβάζονται εικόνες από άλλες εφαρμογές. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Για επικόλληση από άλλες εφαρμογές, ξεκινήστε το PhotoCraft σε XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Τίποτα για επικόλληση: το PhotoCraft δεν μπορεί να διαβάσει εδώ το πρόχειρο του συστήματος Delete this set Διαγραφή αυτού του συνόλου New Set… Νέο σύνολο… PhotoCraft Defaults Προεπιλογές PhotoCraft diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba1183354c..e23b4e84c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4152,6 +4152,10 @@ navigator Zoom Zoom # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Pasting from other apps is unavailable Pegar desde otras aplicaciones no está disponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft no puede acceder al portapapeles del sistema en esta sesión. En Wayland, el entorno aislado de Flatpak oculta el protocolo con el que se leen las imágenes de otras aplicaciones. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para pegar desde otras aplicaciones, inicia PhotoCraft en XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada que pegar: PhotoCraft no puede leer aquí el portapapeles del sistema Delete this set Eliminar este conjunto New Set… Nuevo conjunto… PhotoCraft Defaults Valores por defecto de PhotoCraft diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 12bafb51d3..3bcca7037c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3196,6 +3196,10 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Pasting from other apps is unavailable Le collage depuis d’autres applications est indisponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ne peut pas accéder au presse-papiers du système dans cette session. Sous Wayland, le bac à sable Flatpak masque le protocole par lequel les images des autres applications sont lues. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Pour coller depuis d’autres applications, lancez PhotoCraft sous XWayland : `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Rien à coller : PhotoCraft ne peut pas lire ici le presse-papiers du système Delete this set Supprimer ce jeu New Set… Nouveau jeu… PhotoCraft Defaults Valeurs par défaut de PhotoCraft diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c670182e6b..4907e2da0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4109,6 +4109,10 @@ cameraRaw Yellow Sárga # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Pasting from other apps is unavailable A beillesztés más alkalmazásokból nem érhető el + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. A PhotoCraft ebben a munkamenetben nem fér hozzá a rendszer vágólapjához. Waylanden a Flatpak homokozó elrejti azt a protokollt, amelyen keresztül más alkalmazások képei olvashatók. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Ha más alkalmazásokból szeretne beilleszteni, indítsa a PhotoCraftot XWayland alatt: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nincs mit beilleszteni: a PhotoCraft itt nem tudja olvasni a rendszer vágólapját Delete this set Készlet törlése New Set… Új készlet… PhotoCraft Defaults PhotoCraft alapértelmezések diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5142781b01..fc0d66afae 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3183,6 +3183,10 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Pasting from other apps is unavailable Menempel dari aplikasi lain tidak tersedia + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft tidak dapat mengakses papan klip sistem dalam sesi ini. Di Wayland, sandbox Flatpak menyembunyikan protokol yang digunakan untuk membaca gambar dari aplikasi lain. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Untuk menempel dari aplikasi lain, jalankan PhotoCraft di bawah XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Tidak ada yang ditempel: PhotoCraft tidak dapat membaca papan klip sistem di sini Delete this set Hapus set ini New Set… Set Baru… PhotoCraft Defaults Bawaan PhotoCraft diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9ccb67dd8c..609469635f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4311,6 +4311,10 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Pasting from other apps is unavailable Incollare da altre app non è disponibile + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft non può accedere agli appunti di sistema in questa sessione. Su Wayland, la sandbox di Flatpak nasconde il protocollo con cui vengono lette le immagini delle altre app. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Per incollare da altre app, avvia PhotoCraft in XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niente da incollare: qui PhotoCraft non può leggere gli appunti di sistema Delete this set Elimina questo set New Set… Nuovo set… PhotoCraft Defaults Predefiniti di PhotoCraft diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 372cd06906..39a38a1142 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3866,6 +3866,10 @@ curves Smooth スムーズ # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Pasting from other apps is unavailable 他のアプリからの貼り付けは利用できません + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. このセッションでは PhotoCraft がシステムのクリップボードにアクセスできません。Wayland では、Flatpak のサンドボックスが他のアプリの画像を読み取るためのプロトコルを隠します。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 他のアプリから貼り付けるには、XWayland 上で PhotoCraft を起動してください: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 貼り付けるものがありません: ここでは PhotoCraft がシステムのクリップボードを読み取れません Delete this set このセットを削除 New Set… 新規セット… PhotoCraft Defaults PhotoCraft 初期設定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 60f50975ab..241c81bef5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3370,6 +3370,10 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Pasting from other apps is unavailable 다른 앱에서 붙여넣기를 사용할 수 없습니다 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. 이 세션에서는 PhotoCraft가 시스템 클립보드에 접근할 수 없습니다. Wayland에서는 Flatpak 샌드박스가 다른 앱의 이미지를 읽는 데 쓰이는 프로토콜을 숨깁니다. + To paste from other apps, start PhotoCraft under XWayland: `{command}` 다른 앱에서 붙여넣으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 붙여넣을 내용 없음: 여기서는 PhotoCraft가 시스템 클립보드를 읽을 수 없습니다 Delete this set 이 세트 삭제 New Set… 새 세트… PhotoCraft Defaults PhotoCraft 기본값 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 18c37120e4..e0dd17ff57 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3950,6 +3950,10 @@ curves Smooth Vloeiend maken # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Pasting from other apps is unavailable Plakken vanuit andere apps is niet beschikbaar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft heeft in deze sessie geen toegang tot het systeemklembord. Onder Wayland verbergt een Flatpak-sandbox het protocol waarmee afbeeldingen uit andere apps worden gelezen. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Om vanuit andere apps te plakken, start je PhotoCraft onder XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niets om te plakken: PhotoCraft kan het systeemklembord hier niet lezen Delete this set Deze set verwijderen New Set… Nieuwe set… PhotoCraft Defaults PhotoCraft-standaard diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ca1f466d8f..10435771f5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3788,6 +3788,10 @@ curves Smooth Wygładź # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Pasting from other apps is unavailable Wklejanie z innych aplikacji jest niedostępne + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft nie ma w tej sesji dostępu do schowka systemowego. W Waylandzie piaskownica Flatpaka ukrywa protokół, przez który odczytywane są obrazy z innych aplikacji. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Aby wklejać z innych aplikacji, uruchom PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nie ma czego wkleić: PhotoCraft nie może tutaj odczytać schowka systemowego Delete this set Usuń ten zestaw New Set… Nowy zestaw… PhotoCraft Defaults Domyślne PhotoCraft diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f98a43622..f4dc857f7d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3980,6 +3980,10 @@ layerLabel No Color Sem cor # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Pasting from other apps is unavailable Colar de outros aplicativos não está disponível + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. O PhotoCraft não consegue acessar a área de transferência do sistema nesta sessão. No Wayland, a sandbox do Flatpak oculta o protocolo pelo qual as imagens de outros aplicativos são lidas. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para colar de outros aplicativos, execute o PhotoCraft no XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada para colar: o PhotoCraft não consegue ler a área de transferência do sistema aqui Delete this set Excluir este conjunto New Set… Novo conjunto… PhotoCraft Defaults Padrões do PhotoCraft diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1fd816bf90..b497b41855 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3161,6 +3161,10 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Pasting from other apps is unavailable Вставка из других приложений недоступна + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. В этом сеансе PhotoCraft не может получить доступ к системному буферу обмена. В Wayland песочница Flatpak скрывает протокол, через который читаются изображения из других приложений. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Чтобы вставлять из других приложений, запустите PhotoCraft под XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нечего вставить: здесь PhotoCraft не может прочитать системный буфер обмена Delete this set Удалить этот набор New Set… Новый набор… PhotoCraft Defaults Стандартные PhotoCraft diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a9ff4435bf..dc3608c514 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4263,6 +4263,10 @@ shape Appearance Izgled # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Pasting from other apps is unavailable Lepljenje iz drugih aplikacija nije dostupno + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft u ovoj sesiji ne može da pristupi sistemskoj ostavi. Na Wayland-u, Flatpak sandbox skriva protokol preko kog se čitaju slike iz drugih aplikacija. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Da biste lepili iz drugih aplikacija, pokrenite PhotoCraft pod XWayland-om: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nema šta da se nalepi: PhotoCraft ovde ne može da čita sistemsku ostavu Delete this set Obriši ovaj skup New Set… Novi skup… PhotoCraft Defaults Podrazumevano za PhotoCraft diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index fb4b426c2d..5e5326f9f0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3920,6 +3920,10 @@ cameraData Lens Lens # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Pasting from other apps is unavailable Diğer uygulamalardan yapıştırma kullanılamıyor + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft bu oturumda sistem panosuna erişemiyor. Wayland'de Flatpak korumalı alanı, diğer uygulamalardaki görüntülerin okunduğu protokolü gizler. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Diğer uygulamalardan yapıştırmak için PhotoCraft'ı XWayland altında başlatın: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Yapıştırılacak bir şey yok: PhotoCraft burada sistem panosunu okuyamıyor Delete this set Bu Kümeyi Sil New Set… Yeni Küme… PhotoCraft Defaults PhotoCraft Varsayılanları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d8510183de..cc3df88348 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3889,6 +3889,10 @@ curves Smooth Згладити # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Pasting from other apps is unavailable Вставлення з інших застосунків недоступне + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. У цьому сеансі PhotoCraft не має доступу до системного буфера обміну. У Wayland пісочниця Flatpak приховує протокол, через який читаються зображення з інших застосунків. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Щоб вставляти з інших застосунків, запустіть PhotoCraft під XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нічого вставити: тут PhotoCraft не може прочитати системний буфер обміну Delete this set Видалити цей набір New Set… Новий набір… PhotoCraft Defaults Стандартні PhotoCraft diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a1106bbbc2..d19e3d576a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3676,6 +3676,10 @@ curves Smooth Làm mượt # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Pasting from other apps is unavailable Không thể dán từ ứng dụng khác + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft không truy cập được bộ nhớ tạm của hệ thống trong phiên này. Trên Wayland, sandbox của Flatpak ẩn giao thức dùng để đọc hình ảnh từ ứng dụng khác. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Để dán từ ứng dụng khác, hãy khởi động PhotoCraft dưới XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Không có gì để dán: PhotoCraft không thể đọc bộ nhớ tạm của hệ thống ở đây Delete this set Xóa bộ này New Set… Bộ mới… PhotoCraft Defaults Mặc định của PhotoCraft diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c10a287def..675a544509 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2811,6 +2811,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Pasting from other apps is unavailable 无法从其他应用粘贴 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此会话中无法访问系统剪贴板。在 Wayland 上,Flatpak 沙盒会隐藏读取其他应用图像所用的协议。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 要从其他应用粘贴,请在 XWayland 下启动 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 没有可粘贴的内容:PhotoCraft 在此无法读取系统剪贴板 Delete this set 删除此组 New Set… 新建组… PhotoCraft Defaults PhotoCraft 默认值 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 82015e5754..982fa97319 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2796,6 +2796,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Pasting from other apps is unavailable 無法從其他應用程式貼上 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此工作階段中無法存取系統剪貼簿。在 Wayland 上,Flatpak 沙盒會隱藏讀取其他應用程式影像所用的協定。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 若要從其他應用程式貼上,請在 XWayland 下啟動 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 沒有可貼上的內容:PhotoCraft 在此無法讀取系統剪貼簿 Delete this set 刪除此組合 New Set… 新增組合… PhotoCraft Defaults PhotoCraft 預設值 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb2c42eaae..89f293a2cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -403,6 +403,8 @@ pub type EncodePngFn = Box Result, String>>; pub type ClipboardSetFn = Box Result<(), String>>; /// Read an RGBA8 image from the OS clipboard. pub type ClipboardGetFn = Box Option<(u32, u32, Vec)>>; +/// Whether the last clipboard read found no way to reach the OS clipboard at all. +pub type ClipboardUnreadableFn = Box bool>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). @@ -507,6 +509,9 @@ pub struct Services { /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, + /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on + /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. + pub clipboard_unreadable: Option, /// Preferences persistence: the platform config directory on native, browser storage on /// the web (see `prefs_ui`). pub load_prefs: Option, @@ -1136,6 +1141,11 @@ impl PhotocraftApp { self.import_os_clipboard(); } if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { + if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) { + // The clipboard may well hold an image that can't be reached: say why. + notices::clipboard_unreadable(self); + return Ok(serde_json::json!({"pasted": false})); + } // Enabled on the strength of the OS clipboard, which held no image: a quiet no-op. let message = "Nothing to paste: the clipboard holds no image"; self.ui.status = tl!(message).into(); diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index cd974fbbe7..76cdc7b9bb 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed"; +const CLIPBOARD_UNREADABLE: &str = "Pasting from other apps is unavailable"; /// At most this many notices are kept; older ones drop off. pub const MAX_NOTICES: usize = 3; /// Lines shown per notice before "…and N more". @@ -91,6 +92,30 @@ pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { } } +/// A paste found no clipboard to read (#5031). In a Flatpak on Wayland the sandbox hides the +/// data-control protocol that other apps' images are read through, and there is no X server to +/// fall back to, so the clipboard may hold an image all the same. Say so, and give the command +/// that starts this install under XWayland, where the X11 clipboard works, when there is one. +pub fn clipboard_unreadable(app: &mut PhotocraftApp) { + app.ui.status = tl!("Nothing to paste: PhotoCraft can't read the system clipboard here").into(); + app.ui.status_error = false; + if app.ui.notices.iter().any(|notice| notice.title == CLIPBOARD_UNREADABLE) { + return; + } + // English templates, translated when drawn, as in `wayland_file_drop_guidance`. + let mut lines = + vec!["PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through.".to_owned()]; + let mut args = Vec::new(); + if let Some(command) = &app.services.xwayland_command { + lines.push("To paste from other apps, start PhotoCraft under XWayland: `{command}`".to_owned()); + args.push(("command".to_owned(), command.clone())); + } + let id = post(app, CLIPBOARD_UNREADABLE, lines, false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { + notice.args = args; + } +} + /// A full refresh of document `doc` fell back to the CPU compositor (`reason`, the GPU's) and took /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). @@ -278,6 +303,37 @@ mod tests { assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); } + /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to + /// paste anyway, once while shown; a clipboard that simply holds no image stays a quiet no-op. + #[test] + fn unreadable_clipboard_paste_explains_the_sandbox() { + let command = "flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft"; + let services = |unreadable: bool| Services { + clipboard_get_image: Some(Box::new(|| None)), + clipboard_unreadable: Some(Box::new(move || unreadable)), + xwayland_command: Some(command.to_string()), + ..Default::default() + }; + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(Session::new(), services(true)); + for _ in 0..2 { + let r = crate::menus::invoke(&mut app, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(r["pasted"], serde_json::json!(false)); + } + assert_eq!(app.ui.status, "Nothing to paste: PhotoCraft can't read the system clipboard here"); + let shown: Vec<_> = app.ui.notices.iter().filter(|notice| notice.title == CLIPBOARD_UNREADABLE).collect(); + assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); + let guidance = english(shown[0]); + assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); + let es = crate::i18n::Lang::from_code("es").unwrap(); + assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template"); + + let mut empty = PhotocraftApp::new(Session::new(), services(false)); + crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(empty.ui.status, "Nothing to paste: the clipboard holds no image"); + assert!(empty.ui.notices.iter().all(|notice| notice.title != CLIPBOARD_UNREADABLE)); + } + /// A notice's lines in English with their arguments filled in. fn english(notice: &Notice) -> String { let args: Vec<(&str, &str)> = notice.args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); From 1a03735cc6758d6281fc12d4ce5c231aa479a7c5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:35 +0200 Subject: [PATCH 0926/1452] fix(engine): apply document-wide image and colour conversions to stored video frames (#4956) (#5078) --- crates/engine/src/canvas_geom.rs | 7 +++++- crates/engine/src/canvas_geom/tests.rs | 24 +++++++++++++++++++ crates/engine/src/image_cmds.rs | 32 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 99f7e4cc80..8de32f4327 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -253,7 +253,8 @@ impl Turn { } /// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object, -/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection. +/// fill), stored video frames (current and original), masks (layer, smart filter), alpha +/// channels, Quick Mask and the selection. fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { let re = |s: &mut Surface| *s = remap_surface(s, map); @@ -264,6 +265,10 @@ fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { if let Some(fc) = &mut l.fill_cache { re(&mut fc.surface); } + // Frames away from the playhead come back on scrubbing; they turn with the canvas (#4956). + if let Some(v) = &mut l.video { + v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()).for_each(re); + } match &mut l.content { LayerContent::Raster(s) => re(s), LayerContent::Text(t) => t.cache.iter_mut().for_each(re), diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 0dbf03d2ef..2ba0dfbd66 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -325,6 +325,30 @@ fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() { assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}"); } +/// A canvas turn moves every stored video frame, not just the displayed one (#4956). +#[test] +fn turns_move_stored_video_frames() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (x, y) == (1, 2) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute(command(Turn::FlipHorizontal), json!({})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(W as i32 - 2, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(frame.pixel(1, 2)[3], 0.0); + } +} + #[test] fn turns_on_empty_and_tiny_documents_never_panic() { for (w, h) in [(1, 1), (1, 7), (5, 1)] { diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 805fbde55b..54422f32c1 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -16,7 +16,8 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } /// Applies `f` to every pixel surface of a layer tree: content (raster or -/// cached text/shape/smart pixels), fill caches, and (if `masks`) masks. +/// cached text/shape/smart pixels), fill caches, stored video frames (current and original), +/// and (if `masks`) masks. pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn FnMut(&mut Surface, bool)) { for l in layers { if masks && let Some(m) = &mut l.mask { @@ -25,6 +26,13 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn if let Some(fc) = &mut l.fill_cache { f(&mut fc.surface, false); } + // The displayed content is only a copy of the frame at the playhead; the other frames + // would keep the old geometry and depth and come back stale when scrubbing (#4956). + if let Some(v) = &mut l.video { + for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) { + f(frame, false); + } + } match &mut l.content { LayerContent::Raster(s) => f(s, false), LayerContent::Text(t) => { @@ -938,6 +946,28 @@ mod tests { &s.active().unwrap().doc } + /// Document-wide conversions must move every stored video frame, not just the displayed one (#4956). + #[test] + fn crop_moves_stored_video_frames() { + let mut s = session(); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 60, "height": 30})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + let red = vec![1.0, 0.0, 0.0, 1.0]; + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(20, 10), red); + assert_eq!(frame.pixel(5, 5)[3], 0.0); + } + } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the /// resolution changes. From e306bcd68528ab6c5a0d8e3faad13319a7f7860a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:42 +0200 Subject: [PATCH 0927/1452] Crop: a turned crop rotates only what the frame keeps (#5006) (#5007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turned crop rotated the whole canvas, then cropped to the frame. With Delete Cropped Pixels on, every layer, mask and channel is now trimmed to the frame's source (plus the interpolation kernel's reach) before the turn, so the rotation costs the frame's size: a 512x442 frame turned 3° on a 3859x2594 two-layer document goes from 541 ms to 42 ms. Rotate Canvas also fills the Background with shared tiles instead of a canvas-sized float buffer. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/examples/bench_crop.rs | 40 ++++++++++++ crates/engine/src/image_cmds.rs | 95 +++++++++++++++++++++++++++- crates/engine/src/mode_cmds.rs | 3 +- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 crates/engine/examples/bench_crop.rs diff --git a/crates/engine/examples/bench_crop.rs b/crates/engine/examples/bench_crop.rs new file mode 100644 index 0000000000..1d301ff19c --- /dev/null +++ b/crates/engine/examples/bench_crop.rs @@ -0,0 +1,40 @@ +//! End-to-end timing of `image.crop`, upright and turned, on a two-layer document. +//! `cargo run --release -p photocraft-engine --example bench_crop -- [image] [reps]` +//! Without an image a 3859×2594 noise photo stands in (the size of the Great Wave scan). +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn main() { + let args: Vec = std::env::args().collect(); + let reps: usize = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut s = Session::new(); + match args.get(1) { + Some(path) => { + let bytes = std::fs::read(path).expect("read the image"); + let doc = photocraft_io::import(path, &bytes).expect("decode the image").document; + s.add_document(doc, None); + } + None => { + s.execute("file.new", json!({"width": 3859, "height": 2594, "background": "white"})).expect("new"); + s.execute("filter.noise.addNoise", json!({"amount": 50})).expect("noise"); + } + } + s.execute("layer.duplicate", json!({})).expect("a second layer"); + let cases: [(&str, Value); 3] = [ + ("upright 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442})), + ("turned 3° 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442, "angle": 3})), + ("turned 3° 3000x2000", json!({"x": 400, "y": 300, "width": 3000, "height": 2000, "angle": 3})), + ]; + for (label, p) in cases { + let mut best = Duration::MAX; + for _ in 0..reps { + let t = Instant::now(); + s.execute("image.crop", p.clone()).expect("crop"); + best = best.min(t.elapsed()); + s.execute("edit.undo", json!({})).expect("undo"); + } + println!("{label:24} best of {reps}: {best:.2?}"); + } +} diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 54422f32c1..6d5342b931 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -668,6 +668,21 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar rotated: true, }; crate::analysis_cmds::compound(s, "Crop", |s| { + // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame + // makes the rotation cost the frame's size, not the canvas's (a small turned crop of a + // 10 MP photo took over half a second). + if delete { + let keep = turned_crop_source(size, new, angle, target); + s.edit("Crop", |doc, _| { + for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep)); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = crop_surface(&ch.surface, keep); + } + // The crop drops the selection anyway. + doc.selection = None; + Ok(()) + })?; + } crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); @@ -685,6 +700,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) } +/// The pixels a turned crop reads, in document coordinates before the turn: the source of the +/// `target` frame (on the `new` canvas the rotation by `-angle` makes of `old`), grown by the widest +/// interpolation kernel's reach so every sample the frame's pixels take is kept. +fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect { + const KERNEL_REACH: f64 = 4.0; + // The inverse of `rotate_arbitrary`'s map: x = c·u + s·v + cx, y = −s·u + c·v + cy, where + // (u, v) is the point relative to the new canvas's centre. + let (sn, cs) = (-angle).to_radians().sin_cos(); + let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); + let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (px, py) in [(target.x0, target.y0), (target.x1, target.y0), (target.x0, target.y1), (target.x1, target.y1)] { + let (u, v) = (f64::from(px) - nx, f64::from(py) - ny); + let (x, y) = (cs * u + sn * v + cx, -sn * u + cs * v + cy); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + // `as` saturates: a frame near the coordinate limits keeps more, never less. + Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32) +} + /// Image → Trim. fn trim(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -1295,7 +1330,10 @@ mod tests { for y in 0..20 { for x in 0..10 { let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y)); - assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); + // A fully transparent pixel's colour is invisible, and the turned crop + // doesn't turn the pixels it deletes, which the warp spreads colour from. + let n = if b[3] > 1e-3 { 4 } else { 1 }; + assert!(a.iter().rev().zip(b.iter().rev()).take(n).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); } } } @@ -1336,6 +1374,61 @@ mod tests { } } + /// A turned crop that deletes the cropped pixels turns only what lands in the frame; inside the + /// new canvas every layer, mask and channel is exactly what turning everything gives (the + /// frame spans several tiles, at several depths and angles). + #[test] + fn turned_crop_deleting_pixels_matches_turning_the_whole_canvas() { + use photocraft_color::PixelFormat; + let pixels = || (0..500).flat_map(|y| (0..700).map(move |x| (x, y))); + for depth in [8, 16, 32] { + for angle in [3.0, -7.5, 33.0, 181.0] { + let mut sessions = [true, false].map(|delete| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 700, "height": 500, "depth": depth, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let wave = |x: i32, y: i32, k: i32| ((x * 7 + y * 13 + k * 31) % 97) as f32 / 96.0; + let all = Rect::new(0, 0, 700, 500); + let rgba: Vec = pixels().flat_map(|(x, y)| [wave(x, y, 0), wave(x, y, 1), wave(x, y, 2), wave(y, x, 3)]).collect(); + let gray: Vec = pixels().map(|(x, y)| wave(y, x, 4)).collect(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().write_region(all, &rgba); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.write_region(all, &gray); + l.mask = Some(mask); + let bg = doc.background_mut().unwrap().surface_mut().unwrap(); + bg.write_region(all, &pixels().flat_map(|(x, y)| [wave(x, y, 5), wave(x, y, 6), wave(x, y, 7), 1.0]).collect::>()); + let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(all, &gray); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + let p = json!({"x": 150, "y": 120, "width": 330, "height": 290, "angle": angle, "deleteCroppedPixels": delete}); + s.execute("image.crop", p).unwrap(); + s + }); + let [deleted, kept] = &mut sessions; + let (a, b) = (doc(deleted), doc(kept)); + assert_eq!(a.size, b.size, "{depth} {angle}"); + let canvas = a.bounds(); + for (la, lb) in a.layers.iter().zip(&b.layers) { + let (sa, sb) = (la.surface().unwrap(), lb.surface().unwrap()); + assert!(sa.to_interleaved(canvas) == sb.to_interleaved(canvas), "{depth} {angle}: layer {}", la.name); + if let (Some(ma), Some(mb)) = (&la.mask, &lb.mask) { + assert!(ma.surface.to_interleaved(canvas) == mb.surface.to_interleaved(canvas), "{depth} {angle}: mask of {}", la.name); + } + } + assert!(a.channels[0].surface.to_interleaved(canvas) == b.channels[0].surface.to_interleaved(canvas), "{depth} {angle}: alpha channel"); + // Deleting keeps nothing past the canvas. + let layer = a.layers[1].surface().unwrap(); + assert!(canvas.contains_rect(&layer.content_bounds()), "{depth} {angle}: {:?}", layer.content_bounds()); + } + } + } + /// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document. #[test] fn crop_at_angle_zero_is_the_plain_crop() { diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 41b4ddde1a..c28b17689a 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -112,7 +112,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?; let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); - base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); + // One shared tile per full tile, not a canvas-sized float buffer. + base.fill_rect(canvas, &fill); crate::transform_cmds::composite_over(&mut base, &rotated); base.prune(); *surf = base; From 4f15642ee8de34f9353c2d5b8c1545f8812044a1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:48 +0200 Subject: [PATCH 0928/1452] Toolbar: Edit Toolbar, Quick Mask and Screen Mode in every theme (part of #4653) (#4680) --- crates/ui-egui/src/panels.rs | 160 ++++++++++++++++++++--------------- docs/ui-design.md | 4 +- 2 files changed, 94 insertions(+), 70 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9e0a701cee..04e775439e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -128,7 +128,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) - + if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 } + + 8.0 + + if double { bx } else { bx * 2.0 + 3.0 } + 6.0; // As tall as the tools, so the colour chips sit right under them (Photoshop); a column // taller than the window scrolls and keeps the chips at the bottom. @@ -287,81 +288,79 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } } - if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { + if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); } reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); color_chips(app, ui, double); - if t.pro { - ui.add_space(8.0); + ui.add_space(8.0); - let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; + let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; - ui.with_layout(layout, |ui| { - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button_with_icon_size_tip( - ui, - "square-dashed", - bx, - (bx * 0.52).round(), - quick_mask, - if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, - tool_tips, - ) - .clicked() - { - let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); - } - let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); - paint_toolbar_flyout_indicator(ui, &t, &sm); + ui.with_layout(layout, |ui| { + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button_with_icon_size_tip( + ui, + "square-dashed", + bx, + (bx * 0.52).round(), + quick_mask, + if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, + ) + .clicked() + { + let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); + } + let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); + paint_toolbar_flyout_indicator(ui, &t, &sm); - // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, - // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. - let held_id = sm.id.with("held"); - let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); - let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, true)); - } else if was_held && !sm.is_pointer_button_down_on() { - ui.data_mut(|d| d.remove::(held_id)); - } - let cycle = sm.clicked() && !was_held; - if cycle { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - let open = if sm.secondary_clicked() || long_press { - Some(egui::SetOpenCommand::Bool(true)) - } else if cycle { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - // The release ending the hold counts as a click; it mustn't close the list it opened. - let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; - egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [ - ("view.screenMode.standard", tl!("Standard Screen Mode")), - ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), - ("view.screenMode.fullScreen", tl!("Full Screen Mode")), - ] { - let on = crate::view_cmds::checked(app, id).unwrap_or(false); - if ui.add(egui::Button::selectable(on, label)).clicked() { - let _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", tl!("Standard Screen Mode")), + ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), + ("view.screenMode.fullScreen", tl!("Full Screen Mode")), + ] { + let on = crate::view_cmds::checked(app, id).unwrap_or(false); + if ui.add(egui::Button::selectable(on, label)).clicked() { + let _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); } - }); + } }); - } + }); }); } @@ -5810,6 +5809,27 @@ mod toolbar_tests { } } + /// #4653: every theme draws Edit Toolbar, Quick Mask and Screen Mode, not only Pro. + #[test] + fn every_theme_has_the_toolbar_controls() { + for kind in crate::theme::ThemeKind::ALL { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(2); + for label in ["Edit Toolbar…", "Edit in Quick Mask Mode (Q)", "Change Screen Mode (F)"] { + assert!(h.query_by_label(label).is_some(), "{kind:?}: no {label:?} button"); + } + } + } + fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { h.hover_at(p); h.run_steps(1); @@ -6180,6 +6200,10 @@ mod compact_visual_regressions { phase: egui::TouchPhase::Move, }); harness.run_steps(30); + // Off the toolbar, so a tooltip of the tool scrolled under the pointer isn't a + // second "Zoom Tool" node. + harness.event(egui::Event::PointerMoved(pos2(300.0, 100.0))); + harness.run_steps(2); let node = harness.get_by_label("Zoom Tool (Z)"); let rect = node.rect(); assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: last tool remains outside the toolbar: {rect:?}"); @@ -6217,13 +6241,13 @@ mod compact_visual_regressions { } } - /// A window taller than the tools keeps the colour chips right under the last tool, as - /// Photoshop does, instead of at the bottom of the toolbar. + /// A window taller than the tools keeps the colour chips right under the tool column (its + /// last row is Edit Toolbar), as Photoshop does, instead of at the bottom of the toolbar. #[test] fn tall_toolbar_keeps_the_colour_chips_under_the_tools() { for kind in theme::ThemeKind::ALL { let harness = toolbar_harness_sized(kind, 1.0, 1600.0); - let last = harness.get_by_label("Zoom Tool (Z)").rect(); + let last = harness.get_by_label("Edit Toolbar…").rect(); let swap = harness.get_by_label("Swap colours (X)").rect(); assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}"); } diff --git a/docs/ui-design.md b/docs/ui-design.md index d4ac18d297..d0ee20f7bb 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -103,8 +103,8 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon ## Compact tool controls The toolbar scrolls its tool rows when two columns still exceed the available height. Foreground -and background colours stay visible below those rows, together with Pro's Quick Mask and Screen -Mode controls. Long options rows scroll horizontally. Both scrollers bring newly focused +and background colours stay visible below those rows, together with the Quick Mask and Screen +Mode controls in every theme. Long options rows scroll horizontally. Both scrollers bring newly focused controls into view and retain the existing tool actions and popups. The offscreen regression fixture uses production panels, fonts and SVG loaders across every From d5c7edacdf5c81b8d44f5e9b0c87eb7102244b70 Mon Sep 17 00:00:00 2001 From: llduyll10 <37955357+llduyll10@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:21:01 +0700 Subject: [PATCH 0929/1452] Layered TIFF: write PSD-style layer data unless a length overflows (Photoshop opened only the composite) (#5514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layered TIFF writer let the PSD exporter pick PSB from its size estimate, which counts every layer plane uncompressed. A document with many canvas-sized layers passes 2 GB there while its real layer data is a small fraction of it (a 3000×3441 mockup with 180 layers: estimate ~2.57 GB, real data ~111 MB), so tag 37724 got the PSB-style header ("Adobe Photoshop Document Data V0002", 8-byte lengths). Photoshop does not read that in a TIFF: it opens only the composite, as one locked Background. PsdExportOptions gains `size_limit_psb` (default true, .psd saves unchanged). The layered TIFF turns it off and writes PSD-style data ("Document Data Block"), falling back to PSB only past 30000 px, when forced, or when a length really overflows 32 bits. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/io/src/psd_export.rs | 17 +++++++- crates/io/src/tiff_layers.rs | 76 ++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 345406740d..57e30380c1 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -29,6 +29,12 @@ const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; pub struct PsdExportOptions { /// Write PSB even when the document fits PSD limits. pub force_psb: bool, + /// Switch to PSB when the estimated encoded size exceeds PSD's 2 GB limit. The estimate + /// counts every layer plane uncompressed, so a document with many canvas-sized layers can + /// pass it while its real file is far smaller. A writer that can measure the real size + /// instead turns this off: the layered TIFF writes PSD-style layer data and falls back to + /// PSB only when a length really overflows 32 bits. + pub size_limit_psb: bool, /// Matte the merged image against white where it is translucent, as Photoshop does /// (`false` keeps straight colour under the alpha: for containers that store the composite /// with its own alpha, such as a layered TIFF). @@ -48,7 +54,14 @@ pub struct PsdExportOptions { impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None } + PsdExportOptions { + force_psb: false, + size_limit_psb: true, + merged_matte: true, + merged_compression: Compression::Rle, + maximize_compatibility: true, + document_path: None, + } } } @@ -1522,7 +1535,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = !opts.force_psb && !big && psd_size_exceeds_limit(doc); + let size_exceeds_limit = opts.size_limit_psb && !opts.force_psb && !big && psd_size_exceeds_limit(doc); let version = psd_version(doc, opts.force_psb, size_exceeds_limit); let mut ex = Ex { fmt, diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 628517b6f7..7da61fa466 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -202,19 +202,38 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). + // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as + // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a + // TIFF and opens only the composite, as one locked Background. The exporter's size estimate + // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB + // while its real layer data is a small fraction of that; it is not used here. PSB-style data + // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { - force_psb: opts.force_psb || big, + force_psb, + size_limit_psb: false, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), }; - let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); + let (mut file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = ImageSourceData::from_psd(&file).to_bytes(order)?; + let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { + let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; + let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); + ctl.check().map_err(|_| IoError::Cancelled)?; + warnings.extend(w); + warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); + file = psb; + ImageSourceData::from_psd(&file).to_bytes(order)? + } + r => r?, + }; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_LAYERS_DONE); warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); @@ -284,3 +303,54 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec